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