Skip to main content

khive_runtime/
operations.rs

1//! High-level operations composing storage capabilities into user-facing verbs.
2
3use std::collections::HashMap;
4use std::str::FromStr;
5
6use uuid::Uuid;
7
8use khive_score::{rrf_score, DeterministicScore};
9use khive_storage::note::Note;
10use khive_storage::types::{
11    DeleteMode, Direction, EdgeSortField, GraphPath, LinkId, NeighborHit, NeighborQuery,
12    PageRequest, SortOrder, SqlStatement, TextDocument, TextFilter, TextQueryMode,
13    TextSearchRequest, TraversalRequest, VectorSearchRequest,
14};
15use khive_storage::{Edge, EdgeRelation, Entity, EntityFilter, Event};
16use khive_types::SubstrateKind;
17
18use crate::error::{RuntimeError, RuntimeResult};
19use crate::runtime::KhiveRuntime;
20
21/// A note search result with UUID and salience-weighted RRF score.
22#[derive(Clone, Debug)]
23pub struct NoteSearchHit {
24    pub note_id: Uuid,
25    pub score: DeterministicScore,
26}
27
28/// Result of resolving a UUID to its substrate kind.
29#[derive(Clone, Debug)]
30pub enum Resolved {
31    Entity(Entity),
32    Note(Note),
33    Event(Event),
34}
35
36impl KhiveRuntime {
37    // ---- Entity operations ----
38
39    /// Create and persist a new entity.
40    pub async fn create_entity(
41        &self,
42        namespace: Option<&str>,
43        kind: &str,
44        name: &str,
45        description: Option<&str>,
46        properties: Option<serde_json::Value>,
47        tags: Vec<String>,
48    ) -> RuntimeResult<Entity> {
49        let ns = self.ns(namespace);
50        let mut entity = Entity::new(ns, kind, name);
51        if let Some(d) = description {
52            entity = entity.with_description(d);
53        }
54        if let Some(p) = properties {
55            entity = entity.with_properties(p);
56        }
57        if !tags.is_empty() {
58            entity = entity.with_tags(tags);
59        }
60        self.entities(Some(ns))?
61            .upsert_entity(entity.clone())
62            .await?;
63
64        let body = match &entity.description {
65            Some(d) if !d.is_empty() => format!("{} {}", entity.name, d),
66            _ => entity.name.clone(),
67        };
68        self.text(namespace)?
69            .upsert_document(TextDocument {
70                subject_id: entity.id,
71                kind: SubstrateKind::Entity,
72                title: Some(entity.name.clone()),
73                body: body.clone(),
74                tags: entity.tags.clone(),
75                namespace: ns.to_string(),
76                metadata: entity.properties.clone(),
77                updated_at: chrono::Utc::now(),
78            })
79            .await?;
80
81        if self.config().embedding_model.is_some() {
82            let vector = self.embed(&body).await?;
83            self.vectors(namespace)?
84                .insert(entity.id, SubstrateKind::Entity, ns, vector)
85                .await?;
86        }
87
88        Ok(entity)
89    }
90
91    /// Retrieve an entity by ID.
92    ///
93    /// Returns `None` if the entity does not exist or belongs to a different namespace.
94    /// This enforces ADR-007 namespace isolation at the runtime layer.
95    pub async fn get_entity(
96        &self,
97        namespace: Option<&str>,
98        id: Uuid,
99    ) -> RuntimeResult<Option<Entity>> {
100        let entity = match self.entities(namespace)?.get_entity(id).await? {
101            Some(e) => e,
102            None => return Ok(None),
103        };
104        if entity.namespace != self.ns(namespace) {
105            return Ok(None);
106        }
107        Ok(Some(entity))
108    }
109
110    /// List entities in a namespace, optionally filtered by kind.
111    pub async fn list_entities(
112        &self,
113        namespace: Option<&str>,
114        kind: Option<&str>,
115        limit: u32,
116    ) -> RuntimeResult<Vec<Entity>> {
117        let filter = EntityFilter {
118            kinds: match kind {
119                Some(k) => vec![k.to_string()],
120                None => vec![],
121            },
122            ..Default::default()
123        };
124        let page = self
125            .entities(namespace)?
126            .query_entities(self.ns(namespace), filter, PageRequest { offset: 0, limit })
127            .await?;
128        Ok(page.items)
129    }
130
131    // ---- Edge operations ----
132
133    /// Validate that `source_id` and `target_id` are legal endpoints for `relation`.
134    ///
135    /// Centralises the ADR-002/ADR-019/ADR-024 three-case contract so that both
136    /// `link()` and `update_edge()` share identical enforcement:
137    ///
138    /// - `annotates`: source MUST be a note; target may be any substrate.
139    /// - `supersedes`: same-substrate only (note→note or entity→entity).
140    /// - All other 11 relations: both endpoints MUST be entities.
141    ///
142    /// Returns `Ok(())` when valid; otherwise `InvalidInput` or `NotFound` with
143    /// the same messages as the previous inline block (byte-identical behaviour).
144    async fn validate_edge_relation_endpoints(
145        &self,
146        namespace: Option<&str>,
147        source_id: Uuid,
148        target_id: Uuid,
149        relation: EdgeRelation,
150    ) -> RuntimeResult<()> {
151        if relation == EdgeRelation::Annotates {
152            // Source must be a note in namespace.
153            match self.resolve(namespace, source_id).await? {
154                Some(Resolved::Note(_)) => {}
155                Some(_) => {
156                    return Err(RuntimeError::InvalidInput(format!(
157                        "annotates source {source_id} must be a note"
158                    )));
159                }
160                None => {
161                    // Existing edge used as annotates source: wrong kind, not absent.
162                    if self.get_edge(namespace, source_id).await?.is_some() {
163                        return Err(RuntimeError::InvalidInput(format!(
164                            "annotates source {source_id} must be a note"
165                        )));
166                    }
167                    return Err(RuntimeError::NotFound(format!(
168                        "link source {source_id} not found in namespace"
169                    )));
170                }
171            }
172            // Target may be any substrate (entity, note, event, or edge).
173            if !self.substrate_exists_in_ns(namespace, target_id).await? {
174                return Err(RuntimeError::NotFound(format!(
175                    "link target {target_id} not found in namespace"
176                )));
177            }
178        } else if relation == EdgeRelation::Supersedes {
179            // supersedes: same-substrate only (note→note or entity→entity).
180            // Event and edge endpoints are invalid regardless of the other endpoint.
181            let src = match self.resolve(namespace, source_id).await? {
182                Some(r) => r,
183                None => {
184                    if self.get_edge(namespace, source_id).await?.is_some() {
185                        return Err(RuntimeError::InvalidInput(format!(
186                            "supersedes source {source_id} must be a note or entity (got edge)"
187                        )));
188                    }
189                    return Err(RuntimeError::NotFound(format!(
190                        "link source {source_id} not found in namespace"
191                    )));
192                }
193            };
194            let tgt = match self.resolve(namespace, target_id).await? {
195                Some(r) => r,
196                None => {
197                    if self.get_edge(namespace, target_id).await?.is_some() {
198                        return Err(RuntimeError::InvalidInput(format!(
199                            "supersedes target {target_id} must be a note or entity (got edge)"
200                        )));
201                    }
202                    return Err(RuntimeError::NotFound(format!(
203                        "link target {target_id} not found in namespace"
204                    )));
205                }
206            };
207            match (&src, &tgt) {
208                (Resolved::Entity(_), Resolved::Entity(_)) => {}
209                (Resolved::Note(_), Resolved::Note(_)) => {}
210                (Resolved::Event(_), _) => {
211                    return Err(RuntimeError::InvalidInput(format!(
212                        "supersedes does not apply to events; source {source_id} is an event"
213                    )));
214                }
215                (_, Resolved::Event(_)) => {
216                    return Err(RuntimeError::InvalidInput(format!(
217                        "supersedes does not apply to events; target {target_id} is an event"
218                    )));
219                }
220                (Resolved::Entity(_), Resolved::Note(_)) => {
221                    return Err(RuntimeError::InvalidInput(format!(
222                        "supersedes endpoints must be the same substrate (note→note or entity→entity); \
223                         got source={source_id} (entity) target={target_id} (note)"
224                    )));
225                }
226                (Resolved::Note(_), Resolved::Entity(_)) => {
227                    return Err(RuntimeError::InvalidInput(format!(
228                        "supersedes endpoints must be the same substrate (note→note or entity→entity); \
229                         got source={source_id} (note) target={target_id} (entity)"
230                    )));
231                }
232            }
233        } else {
234            // All 11 entity→entity relations: both endpoints must be entities.
235            // resolve() covers entity/note/event; get_edge() covers edges (not in resolve).
236            // None from resolve + Some from get_edge → InvalidInput (wrong substrate kind).
237            // None from both → NotFound (phantom / cross-namespace).
238            match self.resolve(namespace, source_id).await? {
239                Some(Resolved::Entity(_)) => {}
240                Some(_) => {
241                    return Err(RuntimeError::InvalidInput(format!(
242                        "link source {source_id} must be an entity for relation {relation:?} \
243                         (ADR-002: only `annotates` crosses substrates)"
244                    )));
245                }
246                None => {
247                    if self.get_edge(namespace, source_id).await?.is_some() {
248                        return Err(RuntimeError::InvalidInput(format!(
249                            "link source {source_id} must be an entity for relation {relation:?} \
250                             (ADR-002: only `annotates` crosses substrates)"
251                        )));
252                    }
253                    return Err(RuntimeError::NotFound(format!(
254                        "link source {source_id} not found in namespace"
255                    )));
256                }
257            }
258            match self.resolve(namespace, target_id).await? {
259                Some(Resolved::Entity(_)) => {}
260                Some(_) => {
261                    return Err(RuntimeError::InvalidInput(format!(
262                        "link target {target_id} must be an entity for relation {relation:?} \
263                         (ADR-002: only `annotates` crosses substrates)"
264                    )));
265                }
266                None => {
267                    if self.get_edge(namespace, target_id).await?.is_some() {
268                        return Err(RuntimeError::InvalidInput(format!(
269                            "link target {target_id} must be an entity for relation {relation:?} \
270                             (ADR-002: only `annotates` crosses substrates)"
271                        )));
272                    }
273                    return Err(RuntimeError::NotFound(format!(
274                        "link target {target_id} not found in namespace"
275                    )));
276                }
277            }
278        }
279        Ok(())
280    }
281
282    /// Create a directed edge between two substrates.
283    ///
284    /// Enforces the ADR-002/ADR-019/ADR-024 three-case relation contract via
285    /// `validate_edge_relation_endpoints`. See that method for the full contract.
286    ///
287    /// A record that exists but belongs to a different namespace is treated as not found
288    /// (fail-closed; no cross-namespace existence leak).
289    pub async fn link(
290        &self,
291        namespace: Option<&str>,
292        source_id: Uuid,
293        target_id: Uuid,
294        relation: EdgeRelation,
295        weight: f64,
296    ) -> RuntimeResult<Edge> {
297        self.validate_edge_relation_endpoints(namespace, source_id, target_id, relation)
298            .await?;
299        let edge = Edge {
300            id: LinkId::from(Uuid::new_v4()),
301            source_id,
302            target_id,
303            relation,
304            weight,
305            created_at: chrono::Utc::now(),
306            metadata: None,
307        };
308        self.graph(namespace)?.upsert_edge(edge.clone()).await?;
309        Ok(edge)
310    }
311
312    /// Returns `true` if `id` resolves to a live substrate record in `namespace`.
313    ///
314    /// Covers entity, note, event (via `resolve`) and edge (via `get_edge`).
315    /// A record that exists in a different namespace returns `false` (fail-closed).
316    async fn substrate_exists_in_ns(
317        &self,
318        namespace: Option<&str>,
319        id: Uuid,
320    ) -> RuntimeResult<bool> {
321        if self.resolve(namespace, id).await?.is_some() {
322            return Ok(true);
323        }
324        Ok(self.get_edge(namespace, id).await?.is_some())
325    }
326
327    /// Get immediate neighbors of a node, optionally filtered by relation type.
328    ///
329    /// Pass `relations: Some(vec![EdgeRelation::Annotates])` to retrieve only
330    /// annotation edges, enabling cross-substrate navigation as described in ADR-024.
331    pub async fn neighbors(
332        &self,
333        namespace: Option<&str>,
334        node_id: Uuid,
335        direction: Direction,
336        limit: Option<u32>,
337        relations: Option<Vec<EdgeRelation>>,
338    ) -> RuntimeResult<Vec<NeighborHit>> {
339        let query = NeighborQuery {
340            direction,
341            relations,
342            limit,
343            min_weight: None,
344        };
345        Ok(self.graph(namespace)?.neighbors(node_id, query).await?)
346    }
347
348    /// Traverse the graph from a set of root nodes.
349    pub async fn traverse(
350        &self,
351        namespace: Option<&str>,
352        request: TraversalRequest,
353    ) -> RuntimeResult<Vec<GraphPath>> {
354        Ok(self.graph(namespace)?.traverse(request).await?)
355    }
356
357    // ---- Note operations ----
358
359    /// Create and persist a note, optionally with properties and annotation targets.
360    ///
361    /// After creating the note:
362    /// - Always indexes into FTS5 at the `notes_<namespace>` key.
363    /// - If an embedding model is configured, indexes into the vector store with
364    ///   `SubstrateKind::Note`.
365    /// - For each UUID in `annotates`, creates an `EdgeRelation::Annotates` edge from
366    ///   the note to that target.
367    #[allow(clippy::too_many_arguments)]
368    pub async fn create_note(
369        &self,
370        namespace: Option<&str>,
371        kind: &str,
372        name: Option<&str>,
373        content: &str,
374        salience: f64,
375        properties: Option<serde_json::Value>,
376        annotates: Vec<Uuid>,
377    ) -> RuntimeResult<Note> {
378        let ns = self.ns(namespace);
379
380        // Validate all annotates targets before any write (ADR-024:295 atomicity).
381        for &target_id in &annotates {
382            if !self.substrate_exists_in_ns(namespace, target_id).await? {
383                return Err(RuntimeError::NotFound(format!(
384                    "create_note annotates target {target_id} not found in namespace"
385                )));
386            }
387        }
388
389        let mut note = Note::new(ns, kind, content).with_salience(salience);
390        if let Some(n) = name {
391            note = note.with_name(n);
392        }
393        if let Some(p) = properties {
394            note = note.with_properties(p);
395        }
396        self.notes(Some(ns))?.upsert_note(note.clone()).await?;
397
398        let body = match &note.name {
399            Some(n) => format!("{n} {}", note.content),
400            None => note.content.clone(),
401        };
402
403        // Index into FTS5.
404        self.text_for_notes(Some(ns))?
405            .upsert_document(TextDocument {
406                subject_id: note.id,
407                kind: SubstrateKind::Note,
408                title: note.name.clone(),
409                body,
410                tags: vec![],
411                namespace: ns.to_string(),
412                metadata: note.properties.clone(),
413                updated_at: chrono::Utc::now(),
414            })
415            .await?;
416
417        // Index into vector store if model is configured.
418        if self.config().embedding_model.is_some() {
419            let vector = self.embed(&note.content).await?;
420            self.vectors(Some(ns))?
421                .insert(note.id, SubstrateKind::Note, ns, vector)
422                .await?;
423        }
424
425        // Create annotates edges.
426        for target_id in annotates {
427            self.link(Some(ns), note.id, target_id, EdgeRelation::Annotates, 1.0)
428                .await?;
429        }
430
431        Ok(note)
432    }
433
434    /// List notes, optionally filtered by kind.
435    pub async fn list_notes(
436        &self,
437        namespace: Option<&str>,
438        kind: Option<&str>,
439        limit: u32,
440    ) -> RuntimeResult<Vec<Note>> {
441        let page = self
442            .notes(namespace)?
443            .query_notes(self.ns(namespace), kind, PageRequest { offset: 0, limit })
444            .await?;
445        Ok(page.items)
446    }
447
448    /// Search notes using a hybrid FTS5 + vector pipeline with salience weighting.
449    ///
450    /// Pipeline (per ADR-024):
451    /// 1. FTS5 query against `notes_<namespace>`.
452    /// 2. If embedding model is configured: vector search filtered to `kind="note"`.
453    /// 3. RRF fusion (k=60).
454    /// 4. Salience-weighted rerank: `score *= (0.5 + 0.5 * note.salience)`.
455    /// 5. Filter soft-deleted notes (`deleted_at IS NOT NULL`).
456    /// 6. Truncate to `limit`.
457    pub async fn search_notes(
458        &self,
459        namespace: Option<&str>,
460        query_text: &str,
461        query_vector: Option<Vec<f32>>,
462        limit: u32,
463    ) -> RuntimeResult<Vec<NoteSearchHit>> {
464        const RRF_K: usize = 60;
465        let candidates = limit.saturating_mul(4).max(limit);
466        let ns = self.ns(namespace).to_string();
467
468        // FTS5 over the notes index.
469        let text_hits = self
470            .text_for_notes(namespace)?
471            .search(TextSearchRequest {
472                query: query_text.to_string(),
473                mode: TextQueryMode::Plain,
474                filter: Some(TextFilter {
475                    namespaces: vec![ns.clone()],
476                    ..TextFilter::default()
477                }),
478                top_k: candidates,
479                snippet_chars: 200,
480            })
481            .await?;
482
483        // Vector search filtered to notes.
484        let vector_hits = if let Some(vec) = query_vector {
485            self.vectors(namespace)?
486                .search(VectorSearchRequest {
487                    query_embedding: vec,
488                    top_k: candidates,
489                    namespace: Some(ns.clone()),
490                    kind: Some(SubstrateKind::Note),
491                })
492                .await?
493        } else {
494            vec![]
495        };
496
497        // RRF fusion.
498        let mut buckets: HashMap<Uuid, DeterministicScore> = HashMap::new();
499        for (i, hit) in text_hits.into_iter().enumerate() {
500            let rank = i + 1;
501            let entry = buckets.entry(hit.subject_id).or_default();
502            *entry = *entry + rrf_score(rank, RRF_K);
503        }
504        for (i, hit) in vector_hits.into_iter().enumerate() {
505            let rank = i + 1;
506            let entry = buckets.entry(hit.subject_id).or_default();
507            *entry = *entry + rrf_score(rank, RRF_K);
508        }
509
510        let candidate_ids: Vec<Uuid> = buckets.keys().copied().collect();
511        if candidate_ids.is_empty() {
512            return Ok(vec![]);
513        }
514
515        // Fetch each candidate note individually to get salience and apply soft-delete filter.
516        let note_store = self.notes(namespace)?;
517        let mut alive_notes: HashMap<Uuid, Note> = HashMap::new();
518        for id in &candidate_ids {
519            if let Some(note) = note_store.get_note(*id).await? {
520                if note.deleted_at.is_none() {
521                    alive_notes.insert(*id, note);
522                }
523            }
524        }
525
526        // Drop superseded notes: any note targeted by a `supersedes` edge is
527        // obsolete and excluded from default search (ADR-019, ADR-024).
528        if !alive_notes.is_empty() {
529            let graph = self.graph(namespace)?;
530            let mut superseded: std::collections::HashSet<Uuid> = std::collections::HashSet::new();
531            for &note_id in alive_notes.keys() {
532                let inbound = graph
533                    .neighbors(
534                        note_id,
535                        NeighborQuery {
536                            direction: Direction::In,
537                            relations: Some(vec![EdgeRelation::Supersedes]),
538                            limit: Some(1),
539                            min_weight: None,
540                        },
541                    )
542                    .await?;
543                if !inbound.is_empty() {
544                    superseded.insert(note_id);
545                }
546            }
547            alive_notes.retain(|id, _| !superseded.contains(id));
548        }
549
550        // Apply salience weighting and collect final hits.
551        let mut hits: Vec<NoteSearchHit> = buckets
552            .into_iter()
553            .filter_map(|(id, rrf)| {
554                let note = alive_notes.get(&id)?;
555                let weight = 0.5 + 0.5 * note.salience;
556                let weighted = DeterministicScore::from_f64(rrf.to_f64() * weight);
557                Some(NoteSearchHit {
558                    note_id: id,
559                    score: weighted,
560                })
561            })
562            .collect();
563
564        hits.sort_by(|a, b| b.score.cmp(&a.score).then(a.note_id.cmp(&b.note_id)));
565        hits.truncate(limit as usize);
566        Ok(hits)
567    }
568
569    /// Resolve a short UUID prefix (8+ hex chars) to a full UUID.
570    ///
571    /// Searches entities, notes, and edges tables for a UUID starting with the
572    /// given prefix, scoped to the caller's namespace. Returns `Ok(Some(uuid))`
573    /// if exactly one match is found, `Ok(None)` if no matches, or an error if
574    /// ambiguous (multiple matches).
575    pub async fn resolve_prefix(
576        &self,
577        namespace: Option<&str>,
578        prefix: &str,
579    ) -> RuntimeResult<Option<Uuid>> {
580        use khive_storage::types::{SqlStatement, SqlValue};
581
582        let ns = self.ns(namespace).to_string();
583        let pattern = format!("{}%", prefix);
584
585        let tables = [("entities", true), ("notes", true), ("graph_edges", false)];
586
587        let mut matches: Vec<String> = Vec::new();
588        let mut reader = self.sql().reader().await.map_err(RuntimeError::Storage)?;
589
590        for (table, has_deleted_at) in tables {
591            let deleted_filter = if has_deleted_at {
592                " AND deleted_at IS NULL"
593            } else {
594                ""
595            };
596            let sql = SqlStatement {
597                sql: format!(
598                    "SELECT id FROM {table} WHERE id LIKE ?1 AND namespace = ?2{deleted_filter} LIMIT 2"
599                ),
600                params: vec![
601                    SqlValue::Text(pattern.clone()),
602                    SqlValue::Text(ns.clone()),
603                ],
604                label: Some("resolve_prefix".into()),
605            };
606            match reader.query_all(sql).await {
607                Ok(rows) => {
608                    for row in rows {
609                        if let Some(col) = row.columns.first() {
610                            if let SqlValue::Text(s) = &col.value {
611                                matches.push(s.clone());
612                            }
613                        }
614                    }
615                }
616                Err(e) => {
617                    let msg = e.to_string();
618                    if msg.contains("no such table") {
619                        continue;
620                    }
621                    return Err(RuntimeError::Storage(e));
622                }
623            }
624            if matches.len() > 1 {
625                break;
626            }
627        }
628
629        match matches.len() {
630            0 => Ok(None),
631            1 => {
632                let uuid = Uuid::from_str(&matches[0])
633                    .map_err(|e| RuntimeError::Internal(format!("stored UUID is invalid: {e}")))?;
634                Ok(Some(uuid))
635            }
636            _ => Err(RuntimeError::Ambiguous(format!(
637                "prefix '{prefix}' matches multiple UUIDs"
638            ))),
639        }
640    }
641
642    /// Resolve a UUID to its substrate kind by trying entity, then note, then event stores.
643    ///
644    /// Returns `None` if the UUID is not found in any substrate.
645    /// Cost: at most 3 store lookups per call (cheap for v0.1).
646    pub async fn resolve(
647        &self,
648        namespace: Option<&str>,
649        id: Uuid,
650    ) -> RuntimeResult<Option<Resolved>> {
651        let ns = self.ns(namespace);
652
653        // Entity: use the namespace-checked getter (returns None on mismatch).
654        if let Some(entity) = self.get_entity(namespace, id).await? {
655            return Ok(Some(Resolved::Entity(entity)));
656        }
657
658        // Note: storage get_note is ID-only — verify namespace after fetch.
659        if let Some(note) = self.notes(namespace)?.get_note(id).await? {
660            if note.namespace == ns {
661                return Ok(Some(Resolved::Note(note)));
662            }
663        }
664
665        // Event: storage get_event is ID-only — verify namespace after fetch.
666        if let Some(event) = self.events(namespace)?.get_event(id).await? {
667            if event.namespace == ns {
668                return Ok(Some(Resolved::Event(event)));
669            }
670        }
671
672        Ok(None)
673    }
674
675    /// Delete a note by ID, enforcing namespace isolation.
676    ///
677    /// Returns `false` without deleting if the note does not exist or belongs to
678    /// a different namespace (ADR-007 namespace isolation).
679    pub async fn delete_note(
680        &self,
681        namespace: Option<&str>,
682        id: Uuid,
683        hard: bool,
684    ) -> RuntimeResult<bool> {
685        let ns = self.ns(namespace);
686        let note_store = self.notes(namespace)?;
687        let note = match note_store.get_note(id).await? {
688            Some(n) => n,
689            None => return Ok(false),
690        };
691        if note.namespace != ns {
692            return Ok(false);
693        }
694        let mode = if hard {
695            DeleteMode::Hard
696        } else {
697            DeleteMode::Soft
698        };
699        Ok(note_store.delete_note(id, mode).await?)
700    }
701
702    // ---- Query operations ----
703
704    /// Execute a GQL or SPARQL query string, returning raw SQL rows.
705    ///
706    /// The query is compiled to SQL with the namespace scope applied.
707    /// GQL syntax: `MATCH (a:concept)-[e:extends]->(b) RETURN a, b LIMIT 10`
708    /// SPARQL syntax: `SELECT ?a WHERE { ?a :kind "concept" . }`
709    pub async fn query(
710        &self,
711        namespace: Option<&str>,
712        query: &str,
713    ) -> RuntimeResult<Vec<khive_storage::types::SqlRow>> {
714        let ns = self.ns(namespace);
715        let ast = khive_query::parse_auto(query)?;
716        let opts = khive_query::CompileOptions {
717            scopes: vec![ns.to_string()],
718            ..Default::default()
719        };
720        let compiled = khive_query::compile(&ast, &opts)?;
721        let mut reader = self.sql().reader().await?;
722        let stmt = SqlStatement {
723            sql: compiled.sql,
724            params: compiled.params,
725            label: None,
726        };
727        Ok(reader.query_all(stmt).await?)
728    }
729
730    /// Delete an entity by ID (soft delete by default).
731    ///
732    /// On hard delete, cascades to remove all incident edges (both inbound and
733    /// outbound) to prevent dangling references. Soft delete leaves edges in
734    /// place — queries already filter by `deleted_at IS NULL`.
735    ///
736    /// Returns `false` without deleting if the entity exists but belongs to a
737    /// different namespace (ADR-007 namespace isolation).
738    pub async fn delete_entity(
739        &self,
740        namespace: Option<&str>,
741        id: Uuid,
742        hard: bool,
743    ) -> RuntimeResult<bool> {
744        let entity = match self.entities(namespace)?.get_entity(id).await? {
745            Some(e) => e,
746            None => return Ok(false),
747        };
748        if entity.namespace != self.ns(namespace) {
749            return Ok(false);
750        }
751        let mode = if hard {
752            DeleteMode::Hard
753        } else {
754            DeleteMode::Soft
755        };
756
757        // On hard delete, cascade-remove incident edges to prevent dangling refs.
758        if hard {
759            let graph = self.graph(namespace)?;
760            for direction in [Direction::Out, Direction::In] {
761                let hits = graph
762                    .neighbors(
763                        id,
764                        NeighborQuery {
765                            direction,
766                            relations: None,
767                            limit: None,
768                            min_weight: None,
769                        },
770                    )
771                    .await?;
772                for hit in hits {
773                    graph.delete_edge(LinkId::from(hit.edge_id)).await?;
774                }
775            }
776            self.remove_from_indexes(namespace, id).await?;
777        }
778
779        Ok(self.entities(namespace)?.delete_entity(id, mode).await?)
780    }
781
782    /// Count entities in a namespace, optionally filtered.
783    pub async fn count_entities(
784        &self,
785        namespace: Option<&str>,
786        kind: Option<&str>,
787    ) -> RuntimeResult<u64> {
788        let filter = EntityFilter {
789            kinds: match kind {
790                Some(k) => vec![k.to_string()],
791                None => vec![],
792            },
793            ..Default::default()
794        };
795        Ok(self
796            .entities(namespace)?
797            .count_entities(self.ns(namespace), filter)
798            .await?)
799    }
800
801    // ---- Edge CRUD operations ----
802
803    /// Fetch a single edge by id. Returns `None` if the edge does not exist.
804    pub async fn get_edge(
805        &self,
806        namespace: Option<&str>,
807        edge_id: Uuid,
808    ) -> RuntimeResult<Option<Edge>> {
809        Ok(self
810            .graph(namespace)?
811            .get_edge(LinkId::from(edge_id))
812            .await?)
813    }
814
815    /// List edges matching `filter`. `limit` is capped at 1000; defaults to 100.
816    pub async fn list_edges(
817        &self,
818        namespace: Option<&str>,
819        filter: crate::curation::EdgeListFilter,
820        limit: u32,
821    ) -> RuntimeResult<Vec<Edge>> {
822        let limit = limit.clamp(1, 1000);
823        let page = self
824            .graph(namespace)?
825            .query_edges(
826                filter.into(),
827                vec![SortOrder {
828                    field: EdgeSortField::CreatedAt,
829                    direction: khive_storage::types::SortDirection::Asc,
830                }],
831                PageRequest { offset: 0, limit },
832            )
833            .await?;
834        Ok(page.items)
835    }
836
837    /// Patch-style edge update. Only `Some(_)` fields are applied.
838    ///
839    /// When `relation` is `Some(new_rel)`, validates that the edge's existing endpoints
840    /// are legal for `new_rel` before persisting. Weight-only updates (`relation = None`)
841    /// skip validation. Returns `InvalidInput` if the new relation would violate the
842    /// ADR-002/ADR-019/ADR-024 three-case contract; the edge is NOT mutated on error.
843    pub async fn update_edge(
844        &self,
845        namespace: Option<&str>,
846        edge_id: Uuid,
847        relation: Option<EdgeRelation>,
848        weight: Option<f64>,
849    ) -> RuntimeResult<Edge> {
850        let graph = self.graph(namespace)?;
851        let mut edge = graph
852            .get_edge(LinkId::from(edge_id))
853            .await?
854            .ok_or_else(|| crate::RuntimeError::NotFound(format!("edge {edge_id}")))?;
855
856        if let Some(r) = relation {
857            // Validate before mutating — use the existing endpoints with the new relation.
858            self.validate_edge_relation_endpoints(namespace, edge.source_id, edge.target_id, r)
859                .await?;
860            edge.relation = r;
861        }
862        if let Some(w) = weight {
863            edge.weight = w.clamp(0.0, 1.0);
864        }
865
866        graph.upsert_edge(edge.clone()).await?;
867        Ok(edge)
868    }
869
870    /// Hard-delete an edge by id. Returns `true` if an edge was removed.
871    pub async fn delete_edge(&self, namespace: Option<&str>, edge_id: Uuid) -> RuntimeResult<bool> {
872        Ok(self
873            .graph(namespace)?
874            .delete_edge(LinkId::from(edge_id))
875            .await?)
876    }
877
878    /// Count edges matching `filter`.
879    pub async fn count_edges(
880        &self,
881        namespace: Option<&str>,
882        filter: crate::curation::EdgeListFilter,
883    ) -> RuntimeResult<u64> {
884        Ok(self.graph(namespace)?.count_edges(filter.into()).await?)
885    }
886}
887
888#[cfg(test)]
889mod tests {
890    use super::*;
891    use crate::curation::EdgeListFilter;
892    use crate::runtime::KhiveRuntime;
893
894    fn rt() -> KhiveRuntime {
895        KhiveRuntime::memory().unwrap()
896    }
897
898    #[tokio::test]
899    async fn update_edge_changes_weight() {
900        let rt = rt();
901        let a = rt
902            .create_entity(None, "concept", "A", None, None, vec![])
903            .await
904            .unwrap();
905        let b = rt
906            .create_entity(None, "concept", "B", None, None, vec![])
907            .await
908            .unwrap();
909        let edge = rt
910            .link(None, a.id, b.id, EdgeRelation::Extends, 1.0)
911            .await
912            .unwrap();
913        let edge_id: Uuid = edge.id.into();
914
915        let updated = rt
916            .update_edge(None, edge_id, None, Some(0.5))
917            .await
918            .unwrap();
919        assert!((updated.weight - 0.5).abs() < 0.001);
920    }
921
922    #[tokio::test]
923    async fn update_edge_changes_relation() {
924        let rt = rt();
925        let a = rt
926            .create_entity(None, "concept", "A", None, None, vec![])
927            .await
928            .unwrap();
929        let b = rt
930            .create_entity(None, "concept", "B", None, None, vec![])
931            .await
932            .unwrap();
933        let edge = rt
934            .link(None, a.id, b.id, EdgeRelation::Extends, 1.0)
935            .await
936            .unwrap();
937        let edge_id: Uuid = edge.id.into();
938
939        let updated = rt
940            .update_edge(None, edge_id, Some(EdgeRelation::VariantOf), None)
941            .await
942            .unwrap();
943        assert_eq!(updated.relation, EdgeRelation::VariantOf);
944    }
945
946    // ---- Round-5 tests: update_edge endpoint validation (ADR-002 bypass fix) ----
947
948    // update_edge: note→entity annotates → set relation=Supersedes → InvalidInput (crossing).
949    // Edge must NOT be mutated in the store.
950    #[tokio::test]
951    async fn update_edge_annotates_note_to_entity_set_supersedes_returns_invalid_input() {
952        let rt = rt();
953        let note = rt
954            .create_note(None, "observation", None, "a note", 0.5, None, vec![])
955            .await
956            .unwrap();
957        let entity = rt
958            .create_entity(None, "concept", "E", None, None, vec![])
959            .await
960            .unwrap();
961        // Create a valid note→entity annotates edge.
962        let edge = rt
963            .link(None, note.id, entity.id, EdgeRelation::Annotates, 1.0)
964            .await
965            .unwrap();
966        let edge_id: Uuid = edge.id.into();
967
968        // Attempt to change relation to Supersedes (crossing substrates → invalid).
969        let result = rt
970            .update_edge(None, edge_id, Some(EdgeRelation::Supersedes), None)
971            .await;
972        assert!(
973            matches!(result, Err(RuntimeError::InvalidInput(_))),
974            "update to Supersedes on note→entity edge must return InvalidInput, got {result:?}"
975        );
976
977        // Edge must NOT be mutated — re-fetch and verify relation unchanged.
978        let fetched = rt.get_edge(None, edge_id).await.unwrap().unwrap();
979        assert_eq!(
980            fetched.relation,
981            EdgeRelation::Annotates,
982            "edge relation must be unchanged after failed update"
983        );
984    }
985
986    // update_edge: entity→entity extends → set relation=Annotates → InvalidInput
987    // (annotates source must be a note).
988    #[tokio::test]
989    async fn update_edge_entity_to_entity_set_annotates_returns_invalid_input() {
990        let rt = rt();
991        let a = rt
992            .create_entity(None, "concept", "A", None, None, vec![])
993            .await
994            .unwrap();
995        let b = rt
996            .create_entity(None, "concept", "B", None, None, vec![])
997            .await
998            .unwrap();
999        let edge = rt
1000            .link(None, a.id, b.id, EdgeRelation::Extends, 1.0)
1001            .await
1002            .unwrap();
1003        let edge_id: Uuid = edge.id.into();
1004
1005        let result = rt
1006            .update_edge(None, edge_id, Some(EdgeRelation::Annotates), None)
1007            .await;
1008        assert!(
1009            matches!(result, Err(RuntimeError::InvalidInput(_))),
1010            "update to Annotates on entity→entity edge must return InvalidInput, got {result:?}"
1011        );
1012    }
1013
1014    // update_edge: entity→entity extends → set relation=Supersedes → Ok
1015    // (entity→entity is valid for supersedes).
1016    #[tokio::test]
1017    async fn update_edge_entity_to_entity_set_supersedes_succeeds() {
1018        let rt = rt();
1019        let a = rt
1020            .create_entity(None, "concept", "A", None, None, vec![])
1021            .await
1022            .unwrap();
1023        let b = rt
1024            .create_entity(None, "concept", "B", None, None, vec![])
1025            .await
1026            .unwrap();
1027        let edge = rt
1028            .link(None, a.id, b.id, EdgeRelation::Extends, 1.0)
1029            .await
1030            .unwrap();
1031        let edge_id: Uuid = edge.id.into();
1032
1033        let updated = rt
1034            .update_edge(None, edge_id, Some(EdgeRelation::Supersedes), None)
1035            .await
1036            .unwrap();
1037        assert_eq!(updated.relation, EdgeRelation::Supersedes);
1038
1039        // Verify persisted.
1040        let fetched = rt.get_edge(None, edge_id).await.unwrap().unwrap();
1041        assert_eq!(fetched.relation, EdgeRelation::Supersedes);
1042    }
1043
1044    // update_edge: weight-only (relation = None) → Ok, no validation, unchanged relation.
1045    #[tokio::test]
1046    async fn update_edge_weight_only_skips_validation() {
1047        let rt = rt();
1048        let a = rt
1049            .create_entity(None, "concept", "A", None, None, vec![])
1050            .await
1051            .unwrap();
1052        let b = rt
1053            .create_entity(None, "concept", "B", None, None, vec![])
1054            .await
1055            .unwrap();
1056        let edge = rt
1057            .link(None, a.id, b.id, EdgeRelation::Extends, 1.0)
1058            .await
1059            .unwrap();
1060        let edge_id: Uuid = edge.id.into();
1061
1062        let updated = rt
1063            .update_edge(None, edge_id, None, Some(0.3))
1064            .await
1065            .unwrap();
1066        assert_eq!(updated.relation, EdgeRelation::Extends);
1067        assert!((updated.weight - 0.3).abs() < 0.001);
1068    }
1069
1070    // update_edge: entity→entity extends → set relation=VariantOf (same class) → Ok.
1071    #[tokio::test]
1072    async fn update_edge_same_class_relation_change_succeeds() {
1073        let rt = rt();
1074        let a = rt
1075            .create_entity(None, "concept", "A", None, None, vec![])
1076            .await
1077            .unwrap();
1078        let b = rt
1079            .create_entity(None, "concept", "B", None, None, vec![])
1080            .await
1081            .unwrap();
1082        let edge = rt
1083            .link(None, a.id, b.id, EdgeRelation::Extends, 1.0)
1084            .await
1085            .unwrap();
1086        let edge_id: Uuid = edge.id.into();
1087
1088        let updated = rt
1089            .update_edge(None, edge_id, Some(EdgeRelation::VariantOf), None)
1090            .await
1091            .unwrap();
1092        assert_eq!(updated.relation, EdgeRelation::VariantOf);
1093    }
1094
1095    #[tokio::test]
1096    async fn list_edges_filters_by_relation() {
1097        let rt = rt();
1098        let a = rt
1099            .create_entity(None, "concept", "A", None, None, vec![])
1100            .await
1101            .unwrap();
1102        let b = rt
1103            .create_entity(None, "concept", "B", None, None, vec![])
1104            .await
1105            .unwrap();
1106        let c = rt
1107            .create_entity(None, "concept", "C", None, None, vec![])
1108            .await
1109            .unwrap();
1110
1111        rt.link(None, a.id, b.id, EdgeRelation::Extends, 1.0)
1112            .await
1113            .unwrap();
1114        rt.link(None, a.id, c.id, EdgeRelation::DependsOn, 1.0)
1115            .await
1116            .unwrap();
1117
1118        let filter = EdgeListFilter {
1119            relations: vec![EdgeRelation::Extends],
1120            ..Default::default()
1121        };
1122        let edges = rt.list_edges(None, filter, 100).await.unwrap();
1123        assert_eq!(edges.len(), 1);
1124        assert_eq!(edges[0].relation, EdgeRelation::Extends);
1125    }
1126
1127    #[tokio::test]
1128    async fn list_edges_filters_by_source() {
1129        let rt = rt();
1130        let a = rt
1131            .create_entity(None, "concept", "A", None, None, vec![])
1132            .await
1133            .unwrap();
1134        let b = rt
1135            .create_entity(None, "concept", "B", None, None, vec![])
1136            .await
1137            .unwrap();
1138        let c = rt
1139            .create_entity(None, "concept", "C", None, None, vec![])
1140            .await
1141            .unwrap();
1142        let d = rt
1143            .create_entity(None, "concept", "D", None, None, vec![])
1144            .await
1145            .unwrap();
1146
1147        rt.link(None, a.id, b.id, EdgeRelation::Extends, 1.0)
1148            .await
1149            .unwrap();
1150        rt.link(None, c.id, d.id, EdgeRelation::Extends, 1.0)
1151            .await
1152            .unwrap();
1153
1154        let filter = EdgeListFilter {
1155            source_id: Some(a.id),
1156            ..Default::default()
1157        };
1158        let edges = rt.list_edges(None, filter, 100).await.unwrap();
1159        assert_eq!(edges.len(), 1);
1160        let src: Uuid = edges[0].source_id;
1161        assert_eq!(src, a.id);
1162    }
1163
1164    #[tokio::test]
1165    async fn delete_edge_removes_from_storage() {
1166        let rt = rt();
1167        let a = rt
1168            .create_entity(None, "concept", "A", None, None, vec![])
1169            .await
1170            .unwrap();
1171        let b = rt
1172            .create_entity(None, "concept", "B", None, None, vec![])
1173            .await
1174            .unwrap();
1175        let edge = rt
1176            .link(None, a.id, b.id, EdgeRelation::Extends, 1.0)
1177            .await
1178            .unwrap();
1179        let edge_id: Uuid = edge.id.into();
1180
1181        let deleted = rt.delete_edge(None, edge_id).await.unwrap();
1182        assert!(deleted);
1183
1184        let fetched = rt.get_edge(None, edge_id).await.unwrap();
1185        assert!(fetched.is_none(), "edge should be gone after delete");
1186    }
1187
1188    #[tokio::test]
1189    async fn count_edges_matches_filter() {
1190        let rt = rt();
1191        let a = rt
1192            .create_entity(None, "concept", "A", None, None, vec![])
1193            .await
1194            .unwrap();
1195        let b = rt
1196            .create_entity(None, "concept", "B", None, None, vec![])
1197            .await
1198            .unwrap();
1199        let c = rt
1200            .create_entity(None, "concept", "C", None, None, vec![])
1201            .await
1202            .unwrap();
1203
1204        rt.link(None, a.id, b.id, EdgeRelation::Extends, 1.0)
1205            .await
1206            .unwrap();
1207        rt.link(None, a.id, c.id, EdgeRelation::DependsOn, 1.0)
1208            .await
1209            .unwrap();
1210
1211        let all = rt
1212            .count_edges(None, EdgeListFilter::default())
1213            .await
1214            .unwrap();
1215        assert_eq!(all, 2);
1216
1217        let just_extends = rt
1218            .count_edges(
1219                None,
1220                EdgeListFilter {
1221                    relations: vec![EdgeRelation::Extends],
1222                    ..Default::default()
1223                },
1224            )
1225            .await
1226            .unwrap();
1227        assert_eq!(just_extends, 1);
1228    }
1229
1230    #[tokio::test]
1231    async fn get_entity_namespace_isolation() {
1232        let rt = rt();
1233        let entity = rt
1234            .create_entity(Some("ns-a"), "concept", "Alpha", None, None, vec![])
1235            .await
1236            .unwrap();
1237
1238        // Same namespace: visible.
1239        let found = rt.get_entity(Some("ns-a"), entity.id).await.unwrap();
1240        assert!(found.is_some(), "should be visible in its own namespace");
1241
1242        // Different namespace: invisible.
1243        let not_found = rt.get_entity(Some("ns-b"), entity.id).await.unwrap();
1244        assert!(
1245            not_found.is_none(),
1246            "should not be visible across namespaces"
1247        );
1248    }
1249
1250    #[tokio::test]
1251    async fn delete_entity_namespace_isolation() {
1252        let rt = rt();
1253        let entity = rt
1254            .create_entity(Some("ns-a"), "concept", "Beta", None, None, vec![])
1255            .await
1256            .unwrap();
1257
1258        // Delete from wrong namespace: no-op, returns false.
1259        let deleted = rt
1260            .delete_entity(Some("ns-b"), entity.id, true)
1261            .await
1262            .unwrap();
1263        assert!(!deleted, "cross-namespace delete must return false");
1264
1265        // Entity still present in its own namespace.
1266        let still_there = rt.get_entity(Some("ns-a"), entity.id).await.unwrap();
1267        assert!(
1268            still_there.is_some(),
1269            "entity must survive cross-ns delete attempt"
1270        );
1271
1272        // Delete from correct namespace: succeeds.
1273        let deleted_ok = rt
1274            .delete_entity(Some("ns-a"), entity.id, true)
1275            .await
1276            .unwrap();
1277        assert!(deleted_ok, "same-namespace delete must succeed");
1278    }
1279
1280    // ---- Note ADR-024 tests ----
1281
1282    #[tokio::test]
1283    async fn create_note_indexes_into_fts5() {
1284        let rt = rt();
1285        let note = rt
1286            .create_note(
1287                None,
1288                "observation",
1289                None,
1290                "FlashAttention reduces memory by using tiling",
1291                0.8,
1292                None,
1293                vec![],
1294            )
1295            .await
1296            .unwrap();
1297
1298        // FTS5 should have indexed the note content.
1299        let ns = rt.ns(None).to_string();
1300        let hits = rt
1301            .text_for_notes(None)
1302            .unwrap()
1303            .search(khive_storage::types::TextSearchRequest {
1304                query: "FlashAttention".to_string(),
1305                mode: khive_storage::types::TextQueryMode::Plain,
1306                filter: Some(khive_storage::types::TextFilter {
1307                    namespaces: vec![ns],
1308                    ..Default::default()
1309                }),
1310                top_k: 10,
1311                snippet_chars: 100,
1312            })
1313            .await
1314            .unwrap();
1315
1316        assert!(
1317            hits.iter().any(|h| h.subject_id == note.id),
1318            "note should be indexed in FTS5 after create"
1319        );
1320    }
1321
1322    #[tokio::test]
1323    async fn create_note_with_properties() {
1324        let rt = rt();
1325        let props = serde_json::json!({"source": "arxiv:2205.14135"});
1326        let note = rt
1327            .create_note(
1328                None,
1329                "insight",
1330                None,
1331                "FlashAttention is IO-aware",
1332                0.9,
1333                Some(props.clone()),
1334                vec![],
1335            )
1336            .await
1337            .unwrap();
1338
1339        assert_eq!(note.properties.as_ref().unwrap(), &props);
1340    }
1341
1342    #[tokio::test]
1343    async fn create_note_creates_annotates_edges() {
1344        let rt = rt();
1345        let entity = rt
1346            .create_entity(None, "concept", "FlashAttention", None, None, vec![])
1347            .await
1348            .unwrap();
1349
1350        let note = rt
1351            .create_note(
1352                None,
1353                "observation",
1354                None,
1355                "FlashAttention uses SRAM tiling for memory efficiency",
1356                0.9,
1357                None,
1358                vec![entity.id],
1359            )
1360            .await
1361            .unwrap();
1362
1363        // The note should have an outbound `annotates` edge to the entity.
1364        let out_neighbors = rt
1365            .neighbors(
1366                None,
1367                note.id,
1368                Direction::Out,
1369                None,
1370                Some(vec![EdgeRelation::Annotates]),
1371            )
1372            .await
1373            .unwrap();
1374        assert_eq!(out_neighbors.len(), 1);
1375        assert_eq!(out_neighbors[0].node_id, entity.id);
1376        assert_eq!(out_neighbors[0].relation, EdgeRelation::Annotates);
1377
1378        // The entity should have an inbound `annotates` edge from the note.
1379        let in_neighbors = rt
1380            .neighbors(
1381                None,
1382                entity.id,
1383                Direction::In,
1384                None,
1385                Some(vec![EdgeRelation::Annotates]),
1386            )
1387            .await
1388            .unwrap();
1389        assert_eq!(in_neighbors.len(), 1);
1390        assert_eq!(in_neighbors[0].node_id, note.id);
1391    }
1392
1393    #[tokio::test]
1394    async fn neighbors_without_relation_filter_returns_all() {
1395        let rt = rt();
1396        let a = rt
1397            .create_entity(None, "concept", "A", None, None, vec![])
1398            .await
1399            .unwrap();
1400        let b = rt
1401            .create_entity(None, "concept", "B", None, None, vec![])
1402            .await
1403            .unwrap();
1404        let c = rt
1405            .create_entity(None, "concept", "C", None, None, vec![])
1406            .await
1407            .unwrap();
1408
1409        rt.link(None, a.id, b.id, EdgeRelation::Extends, 1.0)
1410            .await
1411            .unwrap();
1412        rt.link(None, a.id, c.id, EdgeRelation::DependsOn, 1.0)
1413            .await
1414            .unwrap();
1415
1416        let all = rt
1417            .neighbors(None, a.id, Direction::Out, None, None)
1418            .await
1419            .unwrap();
1420        assert_eq!(all.len(), 2);
1421    }
1422
1423    #[tokio::test]
1424    async fn neighbors_with_relation_filter_returns_subset() {
1425        let rt = rt();
1426        let a = rt
1427            .create_entity(None, "concept", "A", None, None, vec![])
1428            .await
1429            .unwrap();
1430        let b = rt
1431            .create_entity(None, "concept", "B", None, None, vec![])
1432            .await
1433            .unwrap();
1434        let c = rt
1435            .create_entity(None, "concept", "C", None, None, vec![])
1436            .await
1437            .unwrap();
1438
1439        rt.link(None, a.id, b.id, EdgeRelation::Extends, 1.0)
1440            .await
1441            .unwrap();
1442        rt.link(None, a.id, c.id, EdgeRelation::DependsOn, 1.0)
1443            .await
1444            .unwrap();
1445
1446        let filtered = rt
1447            .neighbors(
1448                None,
1449                a.id,
1450                Direction::Out,
1451                None,
1452                Some(vec![EdgeRelation::Extends]),
1453            )
1454            .await
1455            .unwrap();
1456        assert_eq!(filtered.len(), 1);
1457        assert_eq!(filtered[0].node_id, b.id);
1458        assert_eq!(filtered[0].relation, EdgeRelation::Extends);
1459    }
1460
1461    #[tokio::test]
1462    async fn search_notes_returns_relevant_note() {
1463        let rt = rt();
1464        rt.create_note(
1465            None,
1466            "observation",
1467            None,
1468            "GQA reduces KV cache memory for large models",
1469            0.8,
1470            None,
1471            vec![],
1472        )
1473        .await
1474        .unwrap();
1475
1476        let results = rt
1477            .search_notes(None, "GQA KV cache", None, 10)
1478            .await
1479            .unwrap();
1480
1481        assert!(!results.is_empty(), "search should return the indexed note");
1482    }
1483
1484    #[tokio::test]
1485    async fn search_notes_excludes_soft_deleted() {
1486        let rt = rt();
1487        let note = rt
1488            .create_note(
1489                None,
1490                "observation",
1491                None,
1492                "RoPE positional encoding rotary embeddings",
1493                0.7,
1494                None,
1495                vec![],
1496            )
1497            .await
1498            .unwrap();
1499
1500        // Soft-delete the note.
1501        rt.notes(None)
1502            .unwrap()
1503            .delete_note(note.id, DeleteMode::Soft)
1504            .await
1505            .unwrap();
1506
1507        let results = rt
1508            .search_notes(None, "RoPE rotary positional", None, 10)
1509            .await
1510            .unwrap();
1511
1512        assert!(
1513            results.iter().all(|h| h.note_id != note.id),
1514            "soft-deleted note should be excluded from search"
1515        );
1516    }
1517
1518    #[tokio::test]
1519    async fn resolve_returns_entity() {
1520        let rt = rt();
1521        let entity = rt
1522            .create_entity(None, "concept", "LoRA", None, None, vec![])
1523            .await
1524            .unwrap();
1525
1526        let resolved = rt.resolve(None, entity.id).await.unwrap();
1527        match resolved {
1528            Some(Resolved::Entity(e)) => assert_eq!(e.id, entity.id),
1529            other => panic!("expected Resolved::Entity, got {:?}", other),
1530        }
1531    }
1532
1533    #[tokio::test]
1534    async fn resolve_returns_note() {
1535        let rt = rt();
1536        let note = rt
1537            .create_note(
1538                None,
1539                "observation",
1540                None,
1541                "LoRA fine-tunes LLMs with low-rank adapters",
1542                0.85,
1543                None,
1544                vec![],
1545            )
1546            .await
1547            .unwrap();
1548
1549        let resolved = rt.resolve(None, note.id).await.unwrap();
1550        match resolved {
1551            Some(Resolved::Note(n)) => assert_eq!(n.id, note.id),
1552            other => panic!("expected Resolved::Note, got {:?}", other),
1553        }
1554    }
1555
1556    #[tokio::test]
1557    async fn resolve_returns_none_for_unknown_uuid() {
1558        let rt = rt();
1559        let unknown = Uuid::new_v4();
1560        let resolved = rt.resolve(None, unknown).await.unwrap();
1561        assert!(resolved.is_none(), "unknown UUID should resolve to None");
1562    }
1563
1564    #[tokio::test]
1565    async fn resolve_prefix_finds_entity_in_own_namespace() {
1566        let rt = rt();
1567        let entity = rt
1568            .create_entity(None, "concept", "PrefixTest", None, None, vec![])
1569            .await
1570            .unwrap();
1571        let prefix = &entity.id.to_string()[..8];
1572
1573        let resolved = rt.resolve_prefix(None, prefix).await.unwrap();
1574        assert_eq!(resolved, Some(entity.id));
1575    }
1576
1577    #[tokio::test]
1578    async fn resolve_prefix_invisible_across_namespaces() {
1579        let rt = rt();
1580        let entity = rt
1581            .create_entity(Some("ns_a"), "concept", "Invisible", None, None, vec![])
1582            .await
1583            .unwrap();
1584        let prefix = &entity.id.to_string()[..8];
1585
1586        // From ns_b, the entity in ns_a should not be visible.
1587        let resolved = rt.resolve_prefix(Some("ns_b"), prefix).await.unwrap();
1588        assert_eq!(resolved, None);
1589    }
1590
1591    #[tokio::test]
1592    async fn resolve_prefix_ambiguous_same_namespace() {
1593        use khive_storage::entity::Entity;
1594
1595        let rt = rt();
1596        // Two entities with UUIDs sharing the same 8-char prefix "aabbccdd".
1597        let id_a = Uuid::parse_str("aabbccdd-1111-4000-8000-000000000001").unwrap();
1598        let id_b = Uuid::parse_str("aabbccdd-2222-4000-8000-000000000002").unwrap();
1599
1600        let mut entity_a = Entity::new("local", "concept", "AmbigA");
1601        entity_a.id = id_a;
1602        let mut entity_b = Entity::new("local", "concept", "AmbigB");
1603        entity_b.id = id_b;
1604
1605        let store = rt.entities(None).unwrap();
1606        store.upsert_entity(entity_a).await.unwrap();
1607        store.upsert_entity(entity_b).await.unwrap();
1608
1609        let result = rt.resolve_prefix(None, "aabbccdd").await;
1610        assert!(
1611            result.is_err(),
1612            "shared 8-char prefix must return Ambiguous error"
1613        );
1614    }
1615
1616    // ---- Referential integrity tests (fix/link-referential-integrity) ----
1617
1618    #[tokio::test]
1619    async fn link_phantom_source_returns_not_found() {
1620        let rt = rt();
1621        let b = rt
1622            .create_entity(None, "concept", "B", None, None, vec![])
1623            .await
1624            .unwrap();
1625        let phantom = Uuid::new_v4();
1626
1627        let result = rt
1628            .link(None, phantom, b.id, EdgeRelation::Extends, 1.0)
1629            .await;
1630        match result {
1631            Err(RuntimeError::NotFound(msg)) => {
1632                assert!(
1633                    msg.contains("source"),
1634                    "error message must name 'source': {msg}"
1635                );
1636            }
1637            other => panic!("expected NotFound for phantom source, got {other:?}"),
1638        }
1639    }
1640
1641    #[tokio::test]
1642    async fn link_phantom_target_returns_not_found() {
1643        let rt = rt();
1644        let a = rt
1645            .create_entity(None, "concept", "A", None, None, vec![])
1646            .await
1647            .unwrap();
1648        let phantom = Uuid::new_v4();
1649
1650        let result = rt
1651            .link(None, a.id, phantom, EdgeRelation::Extends, 1.0)
1652            .await;
1653        match result {
1654            Err(RuntimeError::NotFound(msg)) => {
1655                assert!(
1656                    msg.contains("target"),
1657                    "error message must name 'target': {msg}"
1658                );
1659            }
1660            other => panic!("expected NotFound for phantom target, got {other:?}"),
1661        }
1662    }
1663
1664    #[tokio::test]
1665    async fn link_real_entities_succeeds() {
1666        let rt = rt();
1667        let a = rt
1668            .create_entity(None, "concept", "A", None, None, vec![])
1669            .await
1670            .unwrap();
1671        let b = rt
1672            .create_entity(None, "concept", "B", None, None, vec![])
1673            .await
1674            .unwrap();
1675
1676        let edge = rt
1677            .link(None, a.id, b.id, EdgeRelation::Extends, 0.8)
1678            .await
1679            .unwrap();
1680        assert_eq!(edge.source_id, a.id);
1681        assert_eq!(edge.target_id, b.id);
1682        assert_eq!(edge.relation, EdgeRelation::Extends);
1683    }
1684
1685    #[tokio::test]
1686    async fn create_note_annotates_phantom_returns_not_found() {
1687        let rt = rt();
1688        let phantom = Uuid::new_v4();
1689
1690        let result = rt
1691            .create_note(
1692                None,
1693                "observation",
1694                None,
1695                "some content",
1696                0.5,
1697                None,
1698                vec![phantom],
1699            )
1700            .await;
1701        assert!(
1702            matches!(result, Err(RuntimeError::NotFound(_))),
1703            "annotates with phantom uuid must return NotFound, got {result:?}"
1704        );
1705    }
1706
1707    #[tokio::test]
1708    async fn create_note_annotates_real_entity_succeeds() {
1709        let rt = rt();
1710        let entity = rt
1711            .create_entity(None, "concept", "RealTarget", None, None, vec![])
1712            .await
1713            .unwrap();
1714
1715        let note = rt
1716            .create_note(
1717                None,
1718                "observation",
1719                None,
1720                "content",
1721                0.5,
1722                None,
1723                vec![entity.id],
1724            )
1725            .await
1726            .unwrap();
1727
1728        let neighbors = rt
1729            .neighbors(
1730                None,
1731                note.id,
1732                Direction::Out,
1733                None,
1734                Some(vec![EdgeRelation::Annotates]),
1735            )
1736            .await
1737            .unwrap();
1738        assert_eq!(neighbors.len(), 1);
1739        assert_eq!(neighbors[0].node_id, entity.id);
1740    }
1741
1742    #[tokio::test]
1743    async fn link_target_in_different_namespace_returns_not_found() {
1744        let rt = rt();
1745        let a = rt
1746            .create_entity(Some("ns-a"), "concept", "A", None, None, vec![])
1747            .await
1748            .unwrap();
1749        let b = rt
1750            .create_entity(Some("ns-b"), "concept", "B", None, None, vec![])
1751            .await
1752            .unwrap();
1753
1754        // Linking from ns-a: target b lives in ns-b — must be treated as not found.
1755        let result = rt
1756            .link(Some("ns-a"), a.id, b.id, EdgeRelation::Extends, 1.0)
1757            .await;
1758        assert!(
1759            matches!(result, Err(RuntimeError::NotFound(_))),
1760            "target in different namespace must return NotFound (fail-closed), got {result:?}"
1761        );
1762    }
1763
1764    #[tokio::test]
1765    async fn link_phantom_self_loop_returns_not_found() {
1766        let rt = rt();
1767        let phantom = Uuid::new_v4();
1768
1769        let result = rt
1770            .link(None, phantom, phantom, EdgeRelation::Extends, 1.0)
1771            .await;
1772        match result {
1773            Err(RuntimeError::NotFound(msg)) => {
1774                assert!(
1775                    msg.contains("source"),
1776                    "self-loop must fail on source first: {msg}"
1777                );
1778            }
1779            other => panic!("expected NotFound for phantom self-loop, got {other:?}"),
1780        }
1781    }
1782
1783    // ---- Round-2 tests: edge target coverage + atomicity ----
1784
1785    #[tokio::test]
1786    async fn link_note_to_edge_annotates_succeeds() {
1787        let rt = rt();
1788        let a = rt
1789            .create_entity(None, "concept", "A", None, None, vec![])
1790            .await
1791            .unwrap();
1792        let b = rt
1793            .create_entity(None, "concept", "B", None, None, vec![])
1794            .await
1795            .unwrap();
1796        // Create a real edge between a and b, capture its UUID.
1797        let edge = rt
1798            .link(None, a.id, b.id, EdgeRelation::Extends, 1.0)
1799            .await
1800            .unwrap();
1801        let edge_uuid: Uuid = edge.id.into();
1802
1803        // Create a note and annotate the edge itself (edge is a valid substrate target per ADR-024).
1804        let note = rt
1805            .create_note(None, "observation", None, "edge note", 0.5, None, vec![])
1806            .await
1807            .unwrap();
1808
1809        let result = rt
1810            .link(None, note.id, edge_uuid, EdgeRelation::Annotates, 1.0)
1811            .await;
1812        assert!(
1813            result.is_ok(),
1814            "note→edge Annotates must succeed, got {result:?}"
1815        );
1816    }
1817
1818    #[tokio::test]
1819    async fn create_note_annotates_real_edge_succeeds() {
1820        let rt = rt();
1821        let a = rt
1822            .create_entity(None, "concept", "A", None, None, vec![])
1823            .await
1824            .unwrap();
1825        let b = rt
1826            .create_entity(None, "concept", "B", None, None, vec![])
1827            .await
1828            .unwrap();
1829        let edge = rt
1830            .link(None, a.id, b.id, EdgeRelation::Extends, 1.0)
1831            .await
1832            .unwrap();
1833        let edge_uuid: Uuid = edge.id.into();
1834
1835        let note = rt
1836            .create_note(
1837                None,
1838                "observation",
1839                None,
1840                "annotating an edge",
1841                0.5,
1842                None,
1843                vec![edge_uuid],
1844            )
1845            .await
1846            .unwrap();
1847
1848        let neighbors = rt
1849            .neighbors(
1850                None,
1851                note.id,
1852                Direction::Out,
1853                None,
1854                Some(vec![EdgeRelation::Annotates]),
1855            )
1856            .await
1857            .unwrap();
1858        assert_eq!(neighbors.len(), 1);
1859        assert_eq!(neighbors[0].node_id, edge_uuid);
1860    }
1861
1862    #[tokio::test]
1863    async fn create_note_annotates_phantom_is_atomic_no_note_persisted() {
1864        let rt = rt();
1865        let phantom = Uuid::new_v4();
1866
1867        let before_count = rt.list_notes(None, None, 1000).await.unwrap().len();
1868
1869        let result = rt
1870            .create_note(
1871                None,
1872                "observation",
1873                None,
1874                "should not persist",
1875                0.5,
1876                None,
1877                vec![phantom],
1878            )
1879            .await;
1880        assert!(
1881            matches!(result, Err(RuntimeError::NotFound(_))),
1882            "phantom annotates target must return NotFound, got {result:?}"
1883        );
1884
1885        // Atomicity: the note row must NOT have been written.
1886        let after_count = rt.list_notes(None, None, 1000).await.unwrap().len();
1887        assert_eq!(
1888            before_count, after_count,
1889            "failed create_note must not persist any note row (atomicity)"
1890        );
1891
1892        // FTS must not contain the content either.
1893        let search_hits = rt
1894            .search_notes(None, "should not persist", None, 10)
1895            .await
1896            .unwrap();
1897        assert!(
1898            search_hits.is_empty(),
1899            "failed create_note must not index into FTS (atomicity)"
1900        );
1901        // Vector-store row: only written when an embedding model is configured; the rt()
1902        // harness has none, so no vector assertion is needed here.
1903    }
1904
1905    // ---- Round-3 tests: relation-aware endpoint contract (ADR-002) ----
1906
1907    // Test #2: entity→entity with non-annotates rejects an edge UUID as target.
1908    #[tokio::test]
1909    async fn link_entity_to_edge_uuid_non_annotates_returns_invalid_input() {
1910        let rt = rt();
1911        let a = rt
1912            .create_entity(None, "concept", "A", None, None, vec![])
1913            .await
1914            .unwrap();
1915        let b = rt
1916            .create_entity(None, "concept", "B", None, None, vec![])
1917            .await
1918            .unwrap();
1919        // Create a real edge; capture its UUID as the bad target.
1920        let edge = rt
1921            .link(None, a.id, b.id, EdgeRelation::Extends, 1.0)
1922            .await
1923            .unwrap();
1924        let edge_uuid: Uuid = edge.id.into();
1925
1926        let result = rt
1927            .link(None, a.id, edge_uuid, EdgeRelation::Extends, 1.0)
1928            .await;
1929        match result {
1930            Err(RuntimeError::InvalidInput(msg)) => {
1931                assert!(
1932                    msg.contains("target"),
1933                    "error message must name 'target': {msg}"
1934                );
1935            }
1936            other => {
1937                panic!("expected InvalidInput for edge-uuid target with Extends, got {other:?}")
1938            }
1939        }
1940    }
1941
1942    // Test #3: non-annotates rejects a note UUID as source.
1943    #[tokio::test]
1944    async fn link_note_as_source_non_annotates_returns_invalid_input() {
1945        let rt = rt();
1946        let note = rt
1947            .create_note(None, "observation", None, "a note", 0.5, None, vec![])
1948            .await
1949            .unwrap();
1950        let entity = rt
1951            .create_entity(None, "concept", "E", None, None, vec![])
1952            .await
1953            .unwrap();
1954
1955        let result = rt
1956            .link(None, note.id, entity.id, EdgeRelation::DependsOn, 1.0)
1957            .await;
1958        match result {
1959            Err(RuntimeError::InvalidInput(msg)) => {
1960                assert!(
1961                    msg.contains("source"),
1962                    "error message must name 'source': {msg}"
1963                );
1964            }
1965            other => panic!("expected InvalidInput for note source with DependsOn, got {other:?}"),
1966        }
1967    }
1968
1969    // Test #4: annotates rejects entity as source (source must be a note).
1970    #[tokio::test]
1971    async fn link_entity_as_annotates_source_returns_invalid_input() {
1972        let rt = rt();
1973        let a = rt
1974            .create_entity(None, "concept", "A", None, None, vec![])
1975            .await
1976            .unwrap();
1977        let b = rt
1978            .create_entity(None, "concept", "B", None, None, vec![])
1979            .await
1980            .unwrap();
1981
1982        let result = rt
1983            .link(None, a.id, b.id, EdgeRelation::Annotates, 1.0)
1984            .await;
1985        match result {
1986            Err(RuntimeError::InvalidInput(msg)) => {
1987                assert!(
1988                    msg.contains("source") && msg.contains("note"),
1989                    "error must say source must be a note: {msg}"
1990                );
1991            }
1992            other => {
1993                panic!("expected InvalidInput for entity source with Annotates, got {other:?}")
1994            }
1995        }
1996    }
1997
1998    #[tokio::test]
1999    async fn link_edge_as_annotates_source_returns_invalid_input() {
2000        let rt = rt();
2001        let a = rt
2002            .create_entity(None, "concept", "A", None, None, vec![])
2003            .await
2004            .unwrap();
2005        let b = rt
2006            .create_entity(None, "concept", "B", None, None, vec![])
2007            .await
2008            .unwrap();
2009        let edge = rt
2010            .link(None, a.id, b.id, EdgeRelation::Extends, 1.0)
2011            .await
2012            .unwrap();
2013        let edge_uuid: Uuid = edge.id.into();
2014
2015        // An existing edge used as an annotates source: wrong kind, not absent.
2016        let result = rt
2017            .link(None, edge_uuid, a.id, EdgeRelation::Annotates, 1.0)
2018            .await;
2019        match result {
2020            Err(RuntimeError::InvalidInput(msg)) => {
2021                assert!(
2022                    msg.contains("source") && msg.contains("note"),
2023                    "edge-as-annotates-source must report wrong kind, not NotFound: {msg}"
2024                );
2025            }
2026            other => panic!("expected InvalidInput for edge source with Annotates, got {other:?}"),
2027        }
2028    }
2029
2030    // Test #5: note→event with annotates succeeds (event is a valid annotates target).
2031    #[tokio::test]
2032    async fn link_note_to_event_annotates_succeeds() {
2033        use khive_storage::Event;
2034        use khive_types::SubstrateKind;
2035
2036        let rt = rt();
2037        let note = rt
2038            .create_note(
2039                None,
2040                "observation",
2041                None,
2042                "observing an event",
2043                0.6,
2044                None,
2045                vec![],
2046            )
2047            .await
2048            .unwrap();
2049
2050        // Build an event directly via the store (no runtime create_event exists).
2051        let ns = rt.ns(None);
2052        let event = Event::new(ns, "test_verb", SubstrateKind::Entity, "test_actor");
2053        let event_id = event.id;
2054        rt.events(None).unwrap().append_event(event).await.unwrap();
2055
2056        let result = rt
2057            .link(None, note.id, event_id, EdgeRelation::Annotates, 1.0)
2058            .await;
2059        assert!(
2060            result.is_ok(),
2061            "note→event Annotates must succeed, got {result:?}"
2062        );
2063    }
2064
2065    // Test #6: create_note with event as annotates target succeeds.
2066    #[tokio::test]
2067    async fn create_note_annotates_event_succeeds() {
2068        use khive_storage::Event;
2069        use khive_types::SubstrateKind;
2070
2071        let rt = rt();
2072        let ns = rt.ns(None);
2073        let event = Event::new(ns, "test_verb", SubstrateKind::Entity, "test_actor");
2074        let event_id = event.id;
2075        rt.events(None).unwrap().append_event(event).await.unwrap();
2076
2077        let result = rt
2078            .create_note(
2079                None,
2080                "observation",
2081                None,
2082                "note annotating an event",
2083                0.5,
2084                None,
2085                vec![event_id],
2086            )
2087            .await;
2088        assert!(
2089            result.is_ok(),
2090            "create_note with event annotates target must succeed, got {result:?}"
2091        );
2092        // Verify the annotates edge was created.
2093        let note = result.unwrap();
2094        let neighbors = rt
2095            .neighbors(
2096                None,
2097                note.id,
2098                Direction::Out,
2099                None,
2100                Some(vec![EdgeRelation::Annotates]),
2101            )
2102            .await
2103            .unwrap();
2104        assert_eq!(neighbors.len(), 1);
2105        assert_eq!(neighbors[0].node_id, event_id);
2106    }
2107
2108    // ---- Round-4 tests: supersedes same-substrate contract (ADR-019/ADR-024) ----
2109
2110    // Headline regression: note→note supersedes must succeed (was wrongly rejected before this fix).
2111    #[tokio::test]
2112    async fn link_supersedes_note_to_note_succeeds() {
2113        let rt = rt();
2114        let old_note = rt
2115            .create_note(
2116                None,
2117                "observation",
2118                None,
2119                "old observation",
2120                0.7,
2121                None,
2122                vec![],
2123            )
2124            .await
2125            .unwrap();
2126        let new_note = rt
2127            .create_note(
2128                None,
2129                "observation",
2130                None,
2131                "revised observation superseding the old one",
2132                0.9,
2133                None,
2134                vec![],
2135            )
2136            .await
2137            .unwrap();
2138
2139        let result = rt
2140            .link(
2141                None,
2142                new_note.id,
2143                old_note.id,
2144                EdgeRelation::Supersedes,
2145                1.0,
2146            )
2147            .await;
2148        assert!(
2149            result.is_ok(),
2150            "note→note Supersedes must succeed (ADR-019 note supersession), got {result:?}"
2151        );
2152    }
2153
2154    #[tokio::test]
2155    async fn link_supersedes_entity_to_entity_succeeds() {
2156        let rt = rt();
2157        let old_entity = rt
2158            .create_entity(None, "concept", "OldConcept", None, None, vec![])
2159            .await
2160            .unwrap();
2161        let new_entity = rt
2162            .create_entity(None, "concept", "NewConcept", None, None, vec![])
2163            .await
2164            .unwrap();
2165
2166        let result = rt
2167            .link(
2168                None,
2169                new_entity.id,
2170                old_entity.id,
2171                EdgeRelation::Supersedes,
2172                1.0,
2173            )
2174            .await;
2175        assert!(
2176            result.is_ok(),
2177            "entity→entity Supersedes must succeed, got {result:?}"
2178        );
2179    }
2180
2181    #[tokio::test]
2182    async fn link_supersedes_note_to_entity_returns_invalid_input() {
2183        let rt = rt();
2184        let note = rt
2185            .create_note(None, "observation", None, "a note", 0.5, None, vec![])
2186            .await
2187            .unwrap();
2188        let entity = rt
2189            .create_entity(None, "concept", "SomeEntity", None, None, vec![])
2190            .await
2191            .unwrap();
2192
2193        let result = rt
2194            .link(None, note.id, entity.id, EdgeRelation::Supersedes, 1.0)
2195            .await;
2196        match result {
2197            Err(RuntimeError::InvalidInput(msg)) => {
2198                assert!(
2199                    msg.contains("same substrate") || msg.contains("same-substrate"),
2200                    "error must name the same-substrate rule: {msg}"
2201                );
2202            }
2203            other => panic!(
2204                "expected InvalidInput for note→entity Supersedes (cross-substrate), got {other:?}"
2205            ),
2206        }
2207    }
2208
2209    #[tokio::test]
2210    async fn link_supersedes_entity_to_note_returns_invalid_input() {
2211        let rt = rt();
2212        let entity = rt
2213            .create_entity(None, "concept", "SomeEntity", None, None, vec![])
2214            .await
2215            .unwrap();
2216        let note = rt
2217            .create_note(None, "observation", None, "a note", 0.5, None, vec![])
2218            .await
2219            .unwrap();
2220
2221        let result = rt
2222            .link(None, entity.id, note.id, EdgeRelation::Supersedes, 1.0)
2223            .await;
2224        match result {
2225            Err(RuntimeError::InvalidInput(msg)) => {
2226                assert!(
2227                    msg.contains("same substrate") || msg.contains("same-substrate"),
2228                    "error must name the same-substrate rule: {msg}"
2229                );
2230            }
2231            other => panic!(
2232                "expected InvalidInput for entity→note Supersedes (cross-substrate), got {other:?}"
2233            ),
2234        }
2235    }
2236
2237    #[tokio::test]
2238    async fn link_supersedes_event_source_returns_invalid_input() {
2239        use khive_storage::Event;
2240        use khive_types::SubstrateKind;
2241
2242        let rt = rt();
2243        let ns = rt.ns(None);
2244        let event = Event::new(ns, "test_verb", SubstrateKind::Entity, "test_actor");
2245        let event_id = event.id;
2246        rt.events(None).unwrap().append_event(event).await.unwrap();
2247
2248        let entity = rt
2249            .create_entity(None, "concept", "SomeEntity", None, None, vec![])
2250            .await
2251            .unwrap();
2252
2253        let result = rt
2254            .link(None, event_id, entity.id, EdgeRelation::Supersedes, 1.0)
2255            .await;
2256        match result {
2257            Err(RuntimeError::InvalidInput(msg)) => {
2258                assert!(msg.contains("event"), "error must mention 'event': {msg}");
2259            }
2260            other => {
2261                panic!("expected InvalidInput for event source with Supersedes, got {other:?}")
2262            }
2263        }
2264    }
2265
2266    #[tokio::test]
2267    async fn link_supersedes_event_target_returns_invalid_input() {
2268        use khive_storage::Event;
2269        use khive_types::SubstrateKind;
2270
2271        let rt = rt();
2272        let ns = rt.ns(None);
2273        let event = Event::new(ns, "test_verb", SubstrateKind::Entity, "test_actor");
2274        let event_id = event.id;
2275        rt.events(None).unwrap().append_event(event).await.unwrap();
2276
2277        let entity = rt
2278            .create_entity(None, "concept", "SomeEntity", None, None, vec![])
2279            .await
2280            .unwrap();
2281
2282        let result = rt
2283            .link(None, entity.id, event_id, EdgeRelation::Supersedes, 1.0)
2284            .await;
2285        match result {
2286            Err(RuntimeError::InvalidInput(msg)) => {
2287                assert!(msg.contains("event"), "error must mention 'event': {msg}");
2288            }
2289            other => {
2290                panic!("expected InvalidInput for event target with Supersedes, got {other:?}")
2291            }
2292        }
2293    }
2294
2295    #[tokio::test]
2296    async fn link_supersedes_edge_source_returns_invalid_input() {
2297        let rt = rt();
2298        let a = rt
2299            .create_entity(None, "concept", "A", None, None, vec![])
2300            .await
2301            .unwrap();
2302        let b = rt
2303            .create_entity(None, "concept", "B", None, None, vec![])
2304            .await
2305            .unwrap();
2306        let edge = rt
2307            .link(None, a.id, b.id, EdgeRelation::Extends, 1.0)
2308            .await
2309            .unwrap();
2310        let edge_uuid: Uuid = edge.id.into();
2311
2312        let result = rt
2313            .link(None, edge_uuid, a.id, EdgeRelation::Supersedes, 1.0)
2314            .await;
2315        match result {
2316            Err(RuntimeError::InvalidInput(msg)) => {
2317                assert!(msg.contains("source"), "error must name 'source': {msg}");
2318            }
2319            other => {
2320                panic!("expected InvalidInput for edge-uuid source with Supersedes, got {other:?}")
2321            }
2322        }
2323    }
2324
2325    #[tokio::test]
2326    async fn link_supersedes_edge_target_returns_invalid_input() {
2327        let rt = rt();
2328        let a = rt
2329            .create_entity(None, "concept", "A", None, None, vec![])
2330            .await
2331            .unwrap();
2332        let b = rt
2333            .create_entity(None, "concept", "B", None, None, vec![])
2334            .await
2335            .unwrap();
2336        let edge = rt
2337            .link(None, a.id, b.id, EdgeRelation::Extends, 1.0)
2338            .await
2339            .unwrap();
2340        let edge_uuid: Uuid = edge.id.into();
2341
2342        let result = rt
2343            .link(None, a.id, edge_uuid, EdgeRelation::Supersedes, 1.0)
2344            .await;
2345        match result {
2346            Err(RuntimeError::InvalidInput(msg)) => {
2347                assert!(msg.contains("target"), "error must name 'target': {msg}");
2348            }
2349            other => {
2350                panic!("expected InvalidInput for edge-uuid target with Supersedes, got {other:?}")
2351            }
2352        }
2353    }
2354
2355    #[tokio::test]
2356    async fn link_supersedes_phantom_source_returns_not_found() {
2357        let rt = rt();
2358        let note = rt
2359            .create_note(
2360                None,
2361                "observation",
2362                None,
2363                "existing note",
2364                0.5,
2365                None,
2366                vec![],
2367            )
2368            .await
2369            .unwrap();
2370        let phantom = Uuid::new_v4();
2371
2372        let result = rt
2373            .link(None, phantom, note.id, EdgeRelation::Supersedes, 1.0)
2374            .await;
2375        match result {
2376            Err(RuntimeError::NotFound(msg)) => {
2377                assert!(msg.contains("source"), "error must name 'source': {msg}");
2378            }
2379            other => panic!("expected NotFound for phantom source with Supersedes, got {other:?}"),
2380        }
2381    }
2382
2383    #[tokio::test]
2384    async fn link_supersedes_phantom_target_returns_not_found() {
2385        let rt = rt();
2386        let note = rt
2387            .create_note(
2388                None,
2389                "observation",
2390                None,
2391                "existing note",
2392                0.5,
2393                None,
2394                vec![],
2395            )
2396            .await
2397            .unwrap();
2398        let phantom = Uuid::new_v4();
2399
2400        let result = rt
2401            .link(None, note.id, phantom, EdgeRelation::Supersedes, 1.0)
2402            .await;
2403        match result {
2404            Err(RuntimeError::NotFound(msg)) => {
2405                assert!(msg.contains("target"), "error must name 'target': {msg}");
2406            }
2407            other => panic!("expected NotFound for phantom target with Supersedes, got {other:?}"),
2408        }
2409    }
2410
2411    #[tokio::test]
2412    async fn link_supersedes_cross_namespace_source_returns_not_found() {
2413        let rt = rt();
2414        let note_a = rt
2415            .create_note(
2416                Some("ns-a"),
2417                "observation",
2418                None,
2419                "note in ns-a",
2420                0.5,
2421                None,
2422                vec![],
2423            )
2424            .await
2425            .unwrap();
2426        let note_b = rt
2427            .create_note(
2428                Some("ns-b"),
2429                "observation",
2430                None,
2431                "note in ns-b",
2432                0.5,
2433                None,
2434                vec![],
2435            )
2436            .await
2437            .unwrap();
2438
2439        // From ns-a perspective, note_b is in a different namespace — treated as not found.
2440        let result = rt
2441            .link(
2442                Some("ns-a"),
2443                note_b.id,
2444                note_a.id,
2445                EdgeRelation::Supersedes,
2446                1.0,
2447            )
2448            .await;
2449        assert!(
2450            matches!(result, Err(RuntimeError::NotFound(_))),
2451            "cross-namespace source with Supersedes must return NotFound (fail-closed), got {result:?}"
2452        );
2453    }
2454
2455    // Sanity: extends (non-annotates, non-supersedes) still requires entity→entity.
2456    #[tokio::test]
2457    async fn link_extends_note_source_still_returns_invalid_input() {
2458        let rt = rt();
2459        let note = rt
2460            .create_note(
2461                None,
2462                "observation",
2463                None,
2464                "a note that cannot be an extends source",
2465                0.5,
2466                None,
2467                vec![],
2468            )
2469            .await
2470            .unwrap();
2471        let entity = rt
2472            .create_entity(None, "concept", "E", None, None, vec![])
2473            .await
2474            .unwrap();
2475
2476        let result = rt
2477            .link(None, note.id, entity.id, EdgeRelation::Extends, 1.0)
2478            .await;
2479        assert!(
2480            matches!(result, Err(RuntimeError::InvalidInput(_))),
2481            "note source with Extends must still return InvalidInput after this fix, got {result:?}"
2482        );
2483    }
2484
2485    // Sanity: annotates note→edge still succeeds (unchanged path not broken by this fix).
2486    #[tokio::test]
2487    async fn link_annotates_note_to_edge_still_succeeds_after_fix() {
2488        let rt = rt();
2489        let a = rt
2490            .create_entity(None, "concept", "A", None, None, vec![])
2491            .await
2492            .unwrap();
2493        let b = rt
2494            .create_entity(None, "concept", "B", None, None, vec![])
2495            .await
2496            .unwrap();
2497        let edge = rt
2498            .link(None, a.id, b.id, EdgeRelation::Extends, 1.0)
2499            .await
2500            .unwrap();
2501        let edge_uuid: Uuid = edge.id.into();
2502
2503        let note = rt
2504            .create_note(
2505                None,
2506                "observation",
2507                None,
2508                "annotating an edge",
2509                0.5,
2510                None,
2511                vec![],
2512            )
2513            .await
2514            .unwrap();
2515
2516        let result = rt
2517            .link(None, note.id, edge_uuid, EdgeRelation::Annotates, 1.0)
2518            .await;
2519        assert!(
2520            result.is_ok(),
2521            "note→edge Annotates must still succeed after supersedes fix, got {result:?}"
2522        );
2523    }
2524}