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 serde::Serialize;
7use uuid::Uuid;
8
9use khive_score::{rrf_score, DeterministicScore};
10use khive_storage::note::Note;
11use khive_storage::types::{
12    DeleteMode, Direction, EdgeSortField, GraphPath, LinkId, NeighborHit, NeighborQuery, Page,
13    PageRequest, SortOrder, SqlRow, SqlStatement, TextDocument, TextFilter, TextQueryMode,
14    TextSearchRequest, TraversalRequest,
15};
16use khive_storage::{Edge, EdgeRelation, Entity, EntityFilter, Event, EventFilter};
17use khive_types::{EdgeEndpointRule, EndpointKind, SubstrateKind};
18
19use crate::error::{RuntimeError, RuntimeResult};
20use crate::runtime::KhiveRuntime;
21
22// Test-only failure injection for `create_note_inner`.
23//
24// A test sets `LINK_FAIL_AFTER` to N > 0 before calling `create_note`.  The
25// Nth `link` call inside the loop returns `RuntimeError::Internal("injected
26// link failure")` instead of calling the real implementation.  The counter is
27// reset to 0 after each call regardless of whether it triggered, so tests are
28// isolated from one another.
29#[cfg(test)]
30std::thread_local! {
31    static LINK_FAIL_AFTER: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
32}
33
34/// A note search result with UUID, salience-weighted RRF score, and display text.
35#[derive(Clone, Debug)]
36pub struct NoteSearchHit {
37    pub note_id: Uuid,
38    pub score: DeterministicScore,
39    pub title: Option<String>,
40    pub snippet: Option<String>,
41}
42
43fn text_preview(text: &str, max_chars: usize) -> Option<String> {
44    let trimmed = text.trim();
45    if trimmed.is_empty() {
46        None
47    } else {
48        Some(trimmed.chars().take(max_chars).collect())
49    }
50}
51
52fn note_title(note: &Note) -> Option<String> {
53    note.name
54        .clone()
55        .filter(|s| !s.trim().is_empty())
56        .or_else(|| text_preview(&note.content, 80))
57}
58
59fn note_snippet(note: &Note) -> Option<String> {
60    text_preview(&note.content, 200)
61}
62
63/// Result of resolving a UUID to its substrate kind.
64#[derive(Clone, Debug)]
65pub enum Resolved {
66    Entity(Entity),
67    Note(Note),
68    Event(Event),
69}
70
71/// Map a resolved endpoint to its `(substrate, kind)` pair, or `None` if
72/// the substrate is not a valid edge endpoint (events, edges).
73fn resolved_pair(r: Option<&Resolved>) -> Option<(&'static str, &str)> {
74    match r? {
75        Resolved::Entity(e) => Some(("entity", e.kind.as_str())),
76        Resolved::Note(n) => Some(("note", n.kind.as_str())),
77        Resolved::Event(_) => None,
78    }
79}
80
81/// `true` if `spec` matches the given substrate + kind pair.
82fn endpoint_matches(spec: &EndpointKind, substrate: &str, kind: &str) -> bool {
83    match spec {
84        EndpointKind::EntityOfKind(k) => substrate == "entity" && *k == kind,
85        EndpointKind::NoteOfKind(k) => substrate == "note" && *k == kind,
86    }
87}
88
89/// `true` if any pack-declared edge endpoint rule allows the
90/// `(source, relation, target)` triple. ADR-031: rules are additive only.
91fn pack_rule_allows(
92    rules: &[EdgeEndpointRule],
93    relation: EdgeRelation,
94    src: Option<&Resolved>,
95    tgt: Option<&Resolved>,
96) -> bool {
97    let Some((src_sub, src_kind)) = resolved_pair(src) else {
98        return false;
99    };
100    let Some((tgt_sub, tgt_kind)) = resolved_pair(tgt) else {
101        return false;
102    };
103    rules.iter().any(|r| {
104        r.relation == relation
105            && endpoint_matches(&r.source, src_sub, src_kind)
106            && endpoint_matches(&r.target, tgt_sub, tgt_kind)
107    })
108}
109
110impl KhiveRuntime {
111    // ---- Entity operations ----
112
113    /// Create and persist a new entity.
114    pub async fn create_entity(
115        &self,
116        namespace: Option<&str>,
117        kind: &str,
118        name: &str,
119        description: Option<&str>,
120        properties: Option<serde_json::Value>,
121        tags: Vec<String>,
122    ) -> RuntimeResult<Entity> {
123        let ns = self.ns(namespace);
124        let mut entity = Entity::new(ns, kind, name);
125        if let Some(d) = description {
126            entity = entity.with_description(d);
127        }
128        if let Some(p) = properties {
129            entity = entity.with_properties(p);
130        }
131        if !tags.is_empty() {
132            entity = entity.with_tags(tags);
133        }
134        self.entities(Some(ns))?
135            .upsert_entity(entity.clone())
136            .await?;
137
138        let body = match &entity.description {
139            Some(d) if !d.is_empty() => format!("{} {}", entity.name, d),
140            _ => entity.name.clone(),
141        };
142        self.text(namespace)?
143            .upsert_document(TextDocument {
144                subject_id: entity.id,
145                kind: SubstrateKind::Entity,
146                title: Some(entity.name.clone()),
147                body: body.clone(),
148                tags: entity.tags.clone(),
149                namespace: ns.to_string(),
150                metadata: entity.properties.clone(),
151                updated_at: chrono::Utc::now(),
152            })
153            .await?;
154
155        if self.config().embedding_model.is_some() {
156            let vector = self.embed(&body).await?;
157            self.vectors(namespace)?
158                .insert(entity.id, SubstrateKind::Entity, ns, vector)
159                .await?;
160        }
161
162        Ok(entity)
163    }
164
165    /// Retrieve an entity by ID.
166    ///
167    /// Returns `None` if the entity does not exist or belongs to a different namespace.
168    /// This enforces ADR-007 namespace isolation at the runtime layer.
169    pub async fn get_entity(
170        &self,
171        namespace: Option<&str>,
172        id: Uuid,
173    ) -> RuntimeResult<Option<Entity>> {
174        let entity = match self.entities(namespace)?.get_entity(id).await? {
175            Some(e) => e,
176            None => return Ok(None),
177        };
178        if entity.namespace != self.ns(namespace) {
179            return Ok(None);
180        }
181        Ok(Some(entity))
182    }
183
184    /// List entities in a namespace, optionally filtered by kind.
185    pub async fn list_entities(
186        &self,
187        namespace: Option<&str>,
188        kind: Option<&str>,
189        limit: u32,
190        offset: u32,
191    ) -> RuntimeResult<Vec<Entity>> {
192        let filter = EntityFilter {
193            kinds: match kind {
194                Some(k) => vec![k.to_string()],
195                None => vec![],
196            },
197            ..Default::default()
198        };
199        let page = self
200            .entities(namespace)?
201            .query_entities(
202                self.ns(namespace),
203                filter,
204                PageRequest {
205                    offset: offset.into(),
206                    limit,
207                },
208            )
209            .await?;
210        Ok(page.items)
211    }
212
213    /// List events in a namespace, optionally filtered.
214    pub async fn list_events(
215        &self,
216        namespace: Option<&str>,
217        filter: EventFilter,
218        limit: u32,
219        offset: u32,
220    ) -> RuntimeResult<Page<Event>> {
221        let limit = limit.clamp(1, 1000);
222        let page = self
223            .events(namespace)?
224            .query_events(
225                filter,
226                PageRequest {
227                    offset: offset.into(),
228                    limit,
229                },
230            )
231            .await?;
232        Ok(page)
233    }
234
235    // ---- Edge operations ----
236
237    /// Validate that `source_id` and `target_id` are legal endpoints for `relation`.
238    ///
239    /// Centralises the ADR-002/ADR-019/ADR-024 three-case contract so that both
240    /// `link()` and `update_edge()` share identical enforcement:
241    ///
242    /// - `annotates`: source MUST be a note; target may be any substrate.
243    /// - `supersedes`: same-substrate only (note→note or entity→entity).
244    /// - All other 11 relations: both endpoints MUST be entities.
245    ///
246    /// Returns `Ok(())` when valid; otherwise `InvalidInput` or `NotFound` with
247    /// the same messages as the previous inline block (byte-identical behaviour).
248    async fn validate_edge_relation_endpoints(
249        &self,
250        namespace: Option<&str>,
251        source_id: Uuid,
252        target_id: Uuid,
253        relation: EdgeRelation,
254    ) -> RuntimeResult<()> {
255        if relation == EdgeRelation::Annotates {
256            // Source must be a note in namespace.
257            match self.resolve(namespace, source_id).await? {
258                Some(Resolved::Note(_)) => {}
259                Some(_) => {
260                    return Err(RuntimeError::InvalidInput(format!(
261                        "annotates source {source_id} must be a note"
262                    )));
263                }
264                None => {
265                    // Existing edge used as annotates source: wrong kind, not absent.
266                    if self.get_edge(namespace, source_id).await?.is_some() {
267                        return Err(RuntimeError::InvalidInput(format!(
268                            "annotates source {source_id} must be a note"
269                        )));
270                    }
271                    return Err(RuntimeError::NotFound(format!(
272                        "link source {source_id} not found in namespace"
273                    )));
274                }
275            }
276            // Target may be any substrate (entity, note, event, or edge).
277            if !self.substrate_exists_in_ns(namespace, target_id).await? {
278                return Err(RuntimeError::NotFound(format!(
279                    "link target {target_id} not found in namespace"
280                )));
281            }
282        } else if relation == EdgeRelation::Supersedes {
283            // supersedes: same-substrate only (note→note or entity→entity).
284            // Event and edge endpoints are invalid regardless of the other endpoint.
285            let src = match self.resolve(namespace, source_id).await? {
286                Some(r) => r,
287                None => {
288                    if self.get_edge(namespace, source_id).await?.is_some() {
289                        return Err(RuntimeError::InvalidInput(format!(
290                            "supersedes source {source_id} must be a note or entity (got edge)"
291                        )));
292                    }
293                    return Err(RuntimeError::NotFound(format!(
294                        "link source {source_id} not found in namespace"
295                    )));
296                }
297            };
298            let tgt = match self.resolve(namespace, target_id).await? {
299                Some(r) => r,
300                None => {
301                    if self.get_edge(namespace, target_id).await?.is_some() {
302                        return Err(RuntimeError::InvalidInput(format!(
303                            "supersedes target {target_id} must be a note or entity (got edge)"
304                        )));
305                    }
306                    return Err(RuntimeError::NotFound(format!(
307                        "link target {target_id} not found in namespace"
308                    )));
309                }
310            };
311            match (&src, &tgt) {
312                (Resolved::Entity(_), Resolved::Entity(_)) => {}
313                (Resolved::Note(_), Resolved::Note(_)) => {}
314                (Resolved::Event(_), _) => {
315                    return Err(RuntimeError::InvalidInput(format!(
316                        "supersedes does not apply to events; source {source_id} is an event"
317                    )));
318                }
319                (_, Resolved::Event(_)) => {
320                    return Err(RuntimeError::InvalidInput(format!(
321                        "supersedes does not apply to events; target {target_id} is an event"
322                    )));
323                }
324                (Resolved::Entity(_), Resolved::Note(_)) => {
325                    return Err(RuntimeError::InvalidInput(format!(
326                        "supersedes endpoints must be the same substrate (note→note or entity→entity); \
327                         got source={source_id} (entity) target={target_id} (note)"
328                    )));
329                }
330                (Resolved::Note(_), Resolved::Entity(_)) => {
331                    return Err(RuntimeError::InvalidInput(format!(
332                        "supersedes endpoints must be the same substrate (note→note or entity→entity); \
333                         got source={source_id} (note) target={target_id} (entity)"
334                    )));
335                }
336            }
337        } else {
338            // All 11 entity-default relations: ADR-002 base contract is
339            // entity→entity. ADR-031 allows packs to extend allowed endpoint
340            // pairs additively (e.g. GTD lets `depends_on` span task→task).
341            //
342            // Strategy: resolve both endpoints once, consult pack rules; on
343            // miss, fall through to the original base-rule error messages.
344            let src_res = self.resolve(namespace, source_id).await?;
345            let tgt_res = self.resolve(namespace, target_id).await?;
346
347            if pack_rule_allows(
348                &self.pack_edge_rules(),
349                relation,
350                src_res.as_ref(),
351                tgt_res.as_ref(),
352            ) {
353                return Ok(());
354            }
355
356            // Base-rule check. Same error messages as the pre-ADR-031 surface.
357            match src_res {
358                Some(Resolved::Entity(_)) => {}
359                Some(_) => {
360                    return Err(RuntimeError::InvalidInput(format!(
361                        "link source {source_id} must be an entity for relation {relation:?} \
362                         (ADR-002: only `annotates` crosses substrates)"
363                    )));
364                }
365                None => {
366                    if self.get_edge(namespace, source_id).await?.is_some() {
367                        return Err(RuntimeError::InvalidInput(format!(
368                            "link source {source_id} must be an entity for relation {relation:?} \
369                             (ADR-002: only `annotates` crosses substrates)"
370                        )));
371                    }
372                    return Err(RuntimeError::NotFound(format!(
373                        "link source {source_id} not found in namespace"
374                    )));
375                }
376            }
377            match tgt_res {
378                Some(Resolved::Entity(_)) => {}
379                Some(_) => {
380                    return Err(RuntimeError::InvalidInput(format!(
381                        "link target {target_id} must be an entity for relation {relation:?} \
382                         (ADR-002: only `annotates` crosses substrates)"
383                    )));
384                }
385                None => {
386                    if self.get_edge(namespace, target_id).await?.is_some() {
387                        return Err(RuntimeError::InvalidInput(format!(
388                            "link target {target_id} must be an entity for relation {relation:?} \
389                             (ADR-002: only `annotates` crosses substrates)"
390                        )));
391                    }
392                    return Err(RuntimeError::NotFound(format!(
393                        "link target {target_id} not found in namespace"
394                    )));
395                }
396            }
397        }
398        Ok(())
399    }
400
401    /// Create a directed edge between two substrates.
402    ///
403    /// Enforces the ADR-002/ADR-019/ADR-024 three-case relation contract via
404    /// `validate_edge_relation_endpoints`. See that method for the full contract.
405    ///
406    /// A record that exists but belongs to a different namespace is treated as not found
407    /// (fail-closed; no cross-namespace existence leak).
408    pub async fn link(
409        &self,
410        namespace: Option<&str>,
411        source_id: Uuid,
412        target_id: Uuid,
413        relation: EdgeRelation,
414        weight: f64,
415    ) -> RuntimeResult<Edge> {
416        self.validate_edge_relation_endpoints(namespace, source_id, target_id, relation)
417            .await?;
418        let edge = Edge {
419            id: LinkId::from(Uuid::new_v4()),
420            source_id,
421            target_id,
422            relation,
423            weight,
424            created_at: chrono::Utc::now(),
425            metadata: None,
426        };
427        self.graph(namespace)?.upsert_edge(edge.clone()).await?;
428        Ok(edge)
429    }
430
431    /// Returns `true` if `id` resolves to a live substrate record in `namespace`.
432    ///
433    /// Covers entity, note, event (via `resolve`) and edge (via `get_edge`).
434    /// A record that exists in a different namespace returns `false` (fail-closed).
435    async fn substrate_exists_in_ns(
436        &self,
437        namespace: Option<&str>,
438        id: Uuid,
439    ) -> RuntimeResult<bool> {
440        if self.resolve(namespace, id).await?.is_some() {
441            return Ok(true);
442        }
443        Ok(self.get_edge(namespace, id).await?.is_some())
444    }
445
446    /// Get immediate neighbors of a node, optionally filtered by relation type.
447    ///
448    /// Pass `relations: Some(vec![EdgeRelation::Annotates])` to retrieve only
449    /// annotation edges, enabling cross-substrate navigation as described in ADR-024.
450    pub async fn neighbors(
451        &self,
452        namespace: Option<&str>,
453        node_id: Uuid,
454        direction: Direction,
455        limit: Option<u32>,
456        relations: Option<Vec<EdgeRelation>>,
457    ) -> RuntimeResult<Vec<NeighborHit>> {
458        self.neighbors_with_query(
459            namespace,
460            node_id,
461            NeighborQuery {
462                direction,
463                relations,
464                limit,
465                min_weight: None,
466            },
467        )
468        .await
469    }
470
471    /// Get neighbors with full query control (includes `min_weight`).
472    pub async fn neighbors_with_query(
473        &self,
474        namespace: Option<&str>,
475        node_id: Uuid,
476        query: NeighborQuery,
477    ) -> RuntimeResult<Vec<NeighborHit>> {
478        let mut hits = self.graph(namespace)?.neighbors(node_id, query).await?;
479        self.enrich_neighbor_hits(namespace, &mut hits).await;
480        Ok(hits)
481    }
482
483    /// Traverse the graph from a set of root nodes.
484    pub async fn traverse(
485        &self,
486        namespace: Option<&str>,
487        request: TraversalRequest,
488    ) -> RuntimeResult<Vec<GraphPath>> {
489        let mut paths = self.graph(namespace)?.traverse(request).await?;
490        self.enrich_path_nodes(namespace, &mut paths).await;
491        Ok(paths)
492    }
493
494    /// Populate `name` and `kind` on each `NeighborHit` from the corresponding
495    /// entity record (#162). Best-effort — IDs that don't resolve to an entity
496    /// (e.g. note-to-note `annotates` edges) leave the fields `None`.
497    ///
498    /// Done as a single batched entity fetch instead of an SQL JOIN at the
499    /// graph store, so test databases that wire up a graph store without an
500    /// entities table still work. Cost: one query per neighbors() call.
501    async fn enrich_neighbor_hits(&self, namespace: Option<&str>, hits: &mut [NeighborHit]) {
502        if hits.is_empty() {
503            return;
504        }
505        let store = match self.entities(namespace) {
506            Ok(s) => s,
507            Err(_) => return, // no entity store configured; leave name/kind as None
508        };
509        for hit in hits.iter_mut() {
510            if let Ok(Some(entity)) = store.get_entity(hit.node_id).await {
511                hit.name = Some(entity.name);
512                hit.kind = Some(entity.kind);
513            }
514        }
515    }
516
517    /// Populate `name` and `kind` on each `PathNode` from the corresponding
518    /// entity record (#162). Same best-effort policy as `enrich_neighbor_hits`.
519    async fn enrich_path_nodes(&self, namespace: Option<&str>, paths: &mut [GraphPath]) {
520        if paths.is_empty() {
521            return;
522        }
523        let store = match self.entities(namespace) {
524            Ok(s) => s,
525            Err(_) => return,
526        };
527        for path in paths.iter_mut() {
528            for node in path.nodes.iter_mut() {
529                if let Ok(Some(entity)) = store.get_entity(node.node_id).await {
530                    node.name = Some(entity.name);
531                    node.kind = Some(entity.kind);
532                }
533            }
534        }
535    }
536
537    // ---- Note operations ----
538
539    /// Create and persist a note, optionally with properties and annotation targets.
540    ///
541    /// After creating the note:
542    /// - Always indexes into FTS5 at the `notes_<namespace>` key.
543    /// - If an embedding model is configured, indexes into the vector store with
544    ///   `SubstrateKind::Note`.
545    /// - For each UUID in `annotates`, creates an `EdgeRelation::Annotates` edge from
546    ///   the note to that target.
547    #[allow(clippy::too_many_arguments)]
548    pub async fn create_note(
549        &self,
550        namespace: Option<&str>,
551        kind: &str,
552        name: Option<&str>,
553        content: &str,
554        salience: f64,
555        properties: Option<serde_json::Value>,
556        annotates: Vec<Uuid>,
557    ) -> RuntimeResult<Note> {
558        self.create_note_inner(
559            namespace, kind, name, content, salience, None, properties, annotates,
560        )
561        .await
562    }
563
564    /// Like [`create_note`] but also sets a non-zero decay factor on the note.
565    #[allow(clippy::too_many_arguments)]
566    pub async fn create_note_with_decay(
567        &self,
568        namespace: Option<&str>,
569        kind: &str,
570        name: Option<&str>,
571        content: &str,
572        salience: f64,
573        decay_factor: f64,
574        properties: Option<serde_json::Value>,
575        annotates: Vec<Uuid>,
576    ) -> RuntimeResult<Note> {
577        self.create_note_inner(
578            namespace,
579            kind,
580            name,
581            content,
582            salience,
583            Some(decay_factor),
584            properties,
585            annotates,
586        )
587        .await
588    }
589
590    #[allow(clippy::too_many_arguments)]
591    async fn create_note_inner(
592        &self,
593        namespace: Option<&str>,
594        kind: &str,
595        name: Option<&str>,
596        content: &str,
597        salience: f64,
598        decay_factor: Option<f64>,
599        properties: Option<serde_json::Value>,
600        annotates: Vec<Uuid>,
601    ) -> RuntimeResult<Note> {
602        let ns = self.ns(namespace);
603
604        // Validate all annotates targets before any write (ADR-024:295 atomicity).
605        for &target_id in &annotates {
606            if !self.substrate_exists_in_ns(namespace, target_id).await? {
607                return Err(RuntimeError::NotFound(format!(
608                    "create_note annotates target {target_id} not found in namespace"
609                )));
610            }
611        }
612
613        let mut note = Note::new(ns, kind, content).with_salience(salience);
614        if let Some(df) = decay_factor {
615            note = note.with_decay(df);
616        }
617        if let Some(n) = name {
618            note = note.with_name(n);
619        }
620        if let Some(p) = properties {
621            note = note.with_properties(p);
622        }
623        self.notes(Some(ns))?.upsert_note(note.clone()).await?;
624
625        let body = match &note.name {
626            Some(n) => format!("{n} {}", note.content),
627            None => note.content.clone(),
628        };
629
630        self.text_for_notes(Some(ns))?
631            .upsert_document(TextDocument {
632                subject_id: note.id,
633                kind: SubstrateKind::Note,
634                title: note.name.clone(),
635                body,
636                tags: vec![],
637                namespace: ns.to_string(),
638                metadata: note.properties.clone(),
639                updated_at: chrono::Utc::now(),
640            })
641            .await?;
642
643        if self.config().embedding_model.is_some() {
644            let vector = self.embed(&note.content).await?;
645            self.vectors(Some(ns))?
646                .insert(note.id, SubstrateKind::Note, ns, vector)
647                .await?;
648        }
649
650        // Create annotates edges, compensating on failure to preserve atomicity.
651        //
652        // Pre-validation (above) ensures all targets exist, so link failures are
653        // unexpected. If one occurs: delete any edges already created, then remove
654        // the note, its FTS document, and its vector entry.
655        let mut created_edges: Vec<Uuid> = Vec::with_capacity(annotates.len());
656
657        // In test builds, iterate with an index so the failure-injection hook can
658        // target a specific call.  In release builds, skip the enumerate overhead.
659        #[cfg(test)]
660        let annotates_iter: Vec<(usize, Uuid)> = annotates
661            .iter()
662            .enumerate()
663            .map(|(i, &id)| (i, id))
664            .collect();
665        #[cfg(test)]
666        macro_rules! next_target {
667            ($pair:expr) => {
668                $pair.1
669            };
670        }
671        #[cfg(not(test))]
672        let annotates_iter: Vec<Uuid> = annotates.to_vec();
673        #[cfg(not(test))]
674        macro_rules! next_target {
675            ($pair:expr) => {
676                $pair
677            };
678        }
679
680        for pair in annotates_iter {
681            let target_id = next_target!(pair);
682
683            // Test-only: inject a failure on the configured call index (1-based).
684            #[cfg(test)]
685            let injected_err: Option<RuntimeError> = {
686                let call_idx = pair.0;
687                LINK_FAIL_AFTER.with(|cell| {
688                    let n = cell.get();
689                    if n > 0 && call_idx + 1 == n {
690                        cell.set(0); // reset so subsequent calls are unaffected
691                        Some(RuntimeError::Internal("injected link failure".to_string()))
692                    } else {
693                        None
694                    }
695                })
696            };
697            #[cfg(not(test))]
698            let injected_err: Option<RuntimeError> = None;
699
700            let link_result = if let Some(e) = injected_err {
701                Err(e)
702            } else {
703                self.link(Some(ns), note.id, target_id, EdgeRelation::Annotates, 1.0)
704                    .await
705            };
706
707            match link_result {
708                Ok(edge) => created_edges.push(edge.id.into()),
709                Err(e) => {
710                    // Best-effort compensation — ignore cleanup errors.
711                    for edge_id in created_edges {
712                        let _ = self.delete_edge(Some(ns), edge_id).await;
713                    }
714                    if let Ok(store) = self.notes(Some(ns)) {
715                        let _ = store.delete_note(note.id, DeleteMode::Hard).await;
716                    }
717                    if let Ok(fts) = self.text_for_notes(Some(ns)) {
718                        let _ = fts.delete_document(ns, note.id).await;
719                    }
720                    if self.config().embedding_model.is_some() {
721                        if let Ok(vs) = self.vectors(Some(ns)) {
722                            let _ = vs.delete(note.id).await;
723                        }
724                    }
725                    return Err(e);
726                }
727            }
728        }
729
730        Ok(note)
731    }
732
733    /// List notes, optionally filtered by kind.
734    pub async fn list_notes(
735        &self,
736        namespace: Option<&str>,
737        kind: Option<&str>,
738        limit: u32,
739        offset: u32,
740    ) -> RuntimeResult<Vec<Note>> {
741        let page = self
742            .notes(namespace)?
743            .query_notes(
744                self.ns(namespace),
745                kind,
746                PageRequest {
747                    offset: offset.into(),
748                    limit,
749                },
750            )
751            .await?;
752        Ok(page.items)
753    }
754
755    /// Search notes using a hybrid FTS5 + vector pipeline with salience weighting.
756    ///
757    /// Pipeline (per ADR-024):
758    /// 1. FTS5 query against `notes_<namespace>`.
759    /// 2. If embedding model is configured: vector search filtered to `kind="note"`.
760    /// 3. RRF fusion (k=60).
761    /// 4. Salience-weighted rerank: `score *= (0.5 + 0.5 * note.salience)`.
762    /// 5. Filter soft-deleted notes (`deleted_at IS NOT NULL`).
763    /// 6. Truncate to `limit`.
764    pub async fn search_notes(
765        &self,
766        namespace: Option<&str>,
767        query_text: &str,
768        query_vector: Option<Vec<f32>>,
769        limit: u32,
770        note_kind: Option<&str>,
771    ) -> RuntimeResult<Vec<NoteSearchHit>> {
772        const RRF_K: usize = 60;
773        let candidates = limit.saturating_mul(4).max(limit);
774        let ns = self.ns(namespace).to_string();
775
776        // FTS5 over the notes index.
777        let text_hits = self
778            .text_for_notes(namespace)?
779            .search(TextSearchRequest {
780                query: query_text.to_string(),
781                mode: TextQueryMode::Plain,
782                filter: Some(TextFilter {
783                    namespaces: vec![ns.clone()],
784                    ..TextFilter::default()
785                }),
786                top_k: candidates,
787                snippet_chars: 200,
788            })
789            .await?;
790
791        // Vector search filtered to notes.
792        let vector_hits = if query_vector.is_some() || self.config().embedding_model.is_some() {
793            self.vector_search(
794                namespace,
795                query_vector,
796                Some(query_text),
797                candidates,
798                Some(SubstrateKind::Note),
799            )
800            .await?
801        } else {
802            vec![]
803        };
804
805        // RRF fusion.
806        #[derive(Default)]
807        struct Bucket {
808            score: DeterministicScore,
809            title: Option<String>,
810            snippet: Option<String>,
811        }
812
813        let mut buckets: HashMap<Uuid, Bucket> = HashMap::new();
814        for (i, hit) in text_hits.into_iter().enumerate() {
815            let rank = i + 1;
816            let entry = buckets.entry(hit.subject_id).or_default();
817            entry.score = entry.score + rrf_score(rank, RRF_K);
818            if entry.title.is_none() {
819                entry.title = hit.title;
820            }
821            if entry.snippet.is_none() {
822                entry.snippet = hit.snippet;
823            }
824        }
825        for (i, hit) in vector_hits.into_iter().enumerate() {
826            let rank = i + 1;
827            let entry = buckets.entry(hit.subject_id).or_default();
828            entry.score = entry.score + rrf_score(rank, RRF_K);
829        }
830
831        let candidate_ids: Vec<Uuid> = buckets.keys().copied().collect();
832        if candidate_ids.is_empty() {
833            return Ok(vec![]);
834        }
835
836        // Fetch each candidate note individually to get salience and apply
837        // soft-delete + (optional) kind filtering. Notes whose `kind` doesn't
838        // match `note_kind` are dropped post-fetch — they're a small set
839        // bounded by `candidates`, so the extra read is cheap.
840        let note_store = self.notes(namespace)?;
841        let mut alive_notes: HashMap<Uuid, Note> = HashMap::new();
842        for id in &candidate_ids {
843            if let Some(note) = note_store.get_note(*id).await? {
844                if note.deleted_at.is_some() {
845                    continue;
846                }
847                if let Some(want_kind) = note_kind {
848                    if note.kind != want_kind {
849                        continue;
850                    }
851                }
852                alive_notes.insert(*id, note);
853            }
854        }
855
856        // Drop superseded notes: any note targeted by a `supersedes` edge is
857        // obsolete and excluded from default search (ADR-019, ADR-024).
858        if !alive_notes.is_empty() {
859            let graph = self.graph(namespace)?;
860            let mut superseded: std::collections::HashSet<Uuid> = std::collections::HashSet::new();
861            for &note_id in alive_notes.keys() {
862                let inbound = graph
863                    .neighbors(
864                        note_id,
865                        NeighborQuery {
866                            direction: Direction::In,
867                            relations: Some(vec![EdgeRelation::Supersedes]),
868                            limit: Some(1),
869                            min_weight: None,
870                        },
871                    )
872                    .await?;
873                if !inbound.is_empty() {
874                    superseded.insert(note_id);
875                }
876            }
877            alive_notes.retain(|id, _| !superseded.contains(id));
878        }
879
880        // Apply salience weighting and collect final hits.
881        let mut hits: Vec<NoteSearchHit> = buckets
882            .into_iter()
883            .filter_map(|(id, bucket)| {
884                let note = alive_notes.get(&id)?;
885                let weight = 0.5 + 0.5 * note.salience;
886                let weighted = DeterministicScore::from_f64(bucket.score.to_f64() * weight);
887                Some(NoteSearchHit {
888                    note_id: id,
889                    score: weighted,
890                    title: bucket.title.or_else(|| note_title(note)),
891                    snippet: bucket.snippet.or_else(|| note_snippet(note)),
892                })
893            })
894            .collect();
895
896        hits.sort_by(|a, b| b.score.cmp(&a.score).then(a.note_id.cmp(&b.note_id)));
897        hits.truncate(limit as usize);
898        Ok(hits)
899    }
900
901    /// Resolve a short UUID prefix (8+ hex chars) to a full UUID.
902    ///
903    /// Searches entities, notes, and edges tables for a UUID starting with the
904    /// given prefix, scoped to the caller's namespace. Returns `Ok(Some(uuid))`
905    /// if exactly one match is found, `Ok(None)` if no matches, or an error if
906    /// ambiguous (multiple matches).
907    pub async fn resolve_prefix(
908        &self,
909        namespace: Option<&str>,
910        prefix: &str,
911    ) -> RuntimeResult<Option<Uuid>> {
912        use khive_storage::types::{SqlStatement, SqlValue};
913
914        let ns = self.ns(namespace).to_string();
915        let pattern = format!("{}%", prefix);
916
917        let tables = [
918            ("entities", true),
919            ("notes", true),
920            ("events", false),
921            ("graph_edges", false),
922        ];
923
924        let mut matches: Vec<String> = Vec::new();
925        let mut reader = self.sql().reader().await.map_err(RuntimeError::Storage)?;
926
927        for (table, has_deleted_at) in tables {
928            let deleted_filter = if has_deleted_at {
929                " AND deleted_at IS NULL"
930            } else {
931                ""
932            };
933            let sql = SqlStatement {
934                sql: format!(
935                    "SELECT id FROM {table} WHERE id LIKE ?1 AND namespace = ?2{deleted_filter} LIMIT 2"
936                ),
937                params: vec![
938                    SqlValue::Text(pattern.clone()),
939                    SqlValue::Text(ns.clone()),
940                ],
941                label: Some("resolve_prefix".into()),
942            };
943            match reader.query_all(sql).await {
944                Ok(rows) => {
945                    for row in rows {
946                        if let Some(col) = row.columns.first() {
947                            if let SqlValue::Text(s) = &col.value {
948                                matches.push(s.clone());
949                            }
950                        }
951                    }
952                }
953                Err(e) => {
954                    let msg = e.to_string();
955                    if msg.contains("no such table") {
956                        continue;
957                    }
958                    return Err(RuntimeError::Storage(e));
959                }
960            }
961            if matches.len() > 1 {
962                break;
963            }
964        }
965
966        match matches.len() {
967            0 => Ok(None),
968            1 => {
969                let uuid = Uuid::from_str(&matches[0])
970                    .map_err(|e| RuntimeError::Internal(format!("stored UUID is invalid: {e}")))?;
971                Ok(Some(uuid))
972            }
973            _ => Err(RuntimeError::Ambiguous(format!(
974                "prefix '{prefix}' matches multiple UUIDs"
975            ))),
976        }
977    }
978
979    /// Resolve a UUID to its substrate kind by trying entity, then note, then event stores.
980    ///
981    /// Returns `None` if the UUID is not found in any substrate.
982    /// Cost: at most 3 store lookups per call (cheap for v0.1).
983    pub async fn resolve(
984        &self,
985        namespace: Option<&str>,
986        id: Uuid,
987    ) -> RuntimeResult<Option<Resolved>> {
988        let ns = self.ns(namespace);
989
990        // Entity: use the namespace-checked getter (returns None on mismatch).
991        if let Some(entity) = self.get_entity(namespace, id).await? {
992            return Ok(Some(Resolved::Entity(entity)));
993        }
994
995        // Note: storage get_note is ID-only — verify namespace after fetch.
996        if let Some(note) = self.notes(namespace)?.get_note(id).await? {
997            if note.namespace == ns {
998                return Ok(Some(Resolved::Note(note)));
999            }
1000        }
1001
1002        // Event: storage get_event is ID-only — verify namespace after fetch.
1003        if let Some(event) = self.events(namespace)?.get_event(id).await? {
1004            if event.namespace == ns {
1005                return Ok(Some(Resolved::Event(event)));
1006            }
1007        }
1008
1009        Ok(None)
1010    }
1011
1012    /// Delete a note by ID, enforcing namespace isolation.
1013    ///
1014    /// On hard delete, cascades to remove all incident edges (both inbound and
1015    /// outbound) and cleans up FTS and vector indexes, preventing dangling
1016    /// references for `annotates` edges that target this note (ADR-002, ADR-024).
1017    /// Soft delete also cleans FTS and vector indexes; edges are left in place.
1018    ///
1019    /// Returns `false` without deleting if the note does not exist or belongs to
1020    /// a different namespace (ADR-007 namespace isolation).
1021    pub async fn delete_note(
1022        &self,
1023        namespace: Option<&str>,
1024        id: Uuid,
1025        hard: bool,
1026    ) -> RuntimeResult<bool> {
1027        let ns = self.ns(namespace);
1028        let note_store = self.notes(namespace)?;
1029        let note = match note_store.get_note(id).await? {
1030            Some(n) => n,
1031            None => return Ok(false),
1032        };
1033        if note.namespace != ns {
1034            return Ok(false);
1035        }
1036        let mode = if hard {
1037            DeleteMode::Hard
1038        } else {
1039            DeleteMode::Soft
1040        };
1041
1042        // On hard delete, cascade-remove incident edges and clean up indexes.
1043        if hard {
1044            let graph = self.graph(namespace)?;
1045            for direction in [Direction::Out, Direction::In] {
1046                let hits = graph
1047                    .neighbors(
1048                        id,
1049                        NeighborQuery {
1050                            direction,
1051                            relations: None,
1052                            limit: None,
1053                            min_weight: None,
1054                        },
1055                    )
1056                    .await?;
1057                for hit in hits {
1058                    graph.delete_edge(LinkId::from(hit.edge_id)).await?;
1059                }
1060            }
1061            let ns_str = ns.to_string();
1062            self.text_for_notes(namespace)?
1063                .delete_document(&ns_str, id)
1064                .await?;
1065            if self.config().embedding_model.is_some() {
1066                self.vectors(namespace)?.delete(id).await?;
1067            }
1068        }
1069
1070        let deleted = note_store.delete_note(id, mode).await?;
1071        if !hard && deleted {
1072            let ns_str = ns.to_string();
1073            self.text_for_notes(namespace)?
1074                .delete_document(&ns_str, id)
1075                .await?;
1076            if self.config().embedding_model.is_some() {
1077                self.vectors(namespace)?.delete(id).await?;
1078            }
1079        }
1080        Ok(deleted)
1081    }
1082}
1083
1084/// Result of a GQL/SPARQL query with optional validation warnings.
1085#[derive(Clone, Debug, Serialize)]
1086pub struct QueryResult {
1087    pub rows: Vec<SqlRow>,
1088    #[serde(skip_serializing_if = "Vec::is_empty")]
1089    pub warnings: Vec<String>,
1090}
1091
1092impl KhiveRuntime {
1093    // ---- Query operations ----
1094
1095    /// Execute a GQL or SPARQL query string, returning raw SQL rows.
1096    ///
1097    /// The query is compiled to SQL with the namespace scope applied.
1098    /// GQL syntax: `MATCH (a:concept)-[e:extends]->(b) RETURN a, b LIMIT 10`
1099    /// SPARQL syntax: `SELECT ?a WHERE { ?a :kind "concept" . }`
1100    pub async fn query(&self, namespace: Option<&str>, query: &str) -> RuntimeResult<Vec<SqlRow>> {
1101        Ok(self.query_with_metadata(namespace, query).await?.rows)
1102    }
1103
1104    /// Execute a GQL/SPARQL query, returning rows and any validation warnings.
1105    pub async fn query_with_metadata(
1106        &self,
1107        namespace: Option<&str>,
1108        query: &str,
1109    ) -> RuntimeResult<QueryResult> {
1110        let ns = self.ns(namespace);
1111        let ast = khive_query::parse_auto(query)?;
1112        let opts = khive_query::CompileOptions {
1113            scopes: vec![ns.to_string()],
1114            ..Default::default()
1115        };
1116        let compiled = khive_query::compile(&ast, &opts)?;
1117        let warnings = compiled.warnings;
1118        let mut reader = self.sql().reader().await?;
1119        let stmt = SqlStatement {
1120            sql: compiled.sql,
1121            params: compiled.params,
1122            label: None,
1123        };
1124        let rows = reader.query_all(stmt).await?;
1125        Ok(QueryResult { rows, warnings })
1126    }
1127
1128    /// Delete an entity by ID (soft delete by default).
1129    ///
1130    /// On hard delete, cascades to remove all incident edges (both inbound and
1131    /// outbound) to prevent dangling references. Soft delete also cleans FTS
1132    /// and vector indexes; edges are left in place.
1133    ///
1134    /// Returns `false` without deleting if the entity exists but belongs to a
1135    /// different namespace (ADR-007 namespace isolation).
1136    pub async fn delete_entity(
1137        &self,
1138        namespace: Option<&str>,
1139        id: Uuid,
1140        hard: bool,
1141    ) -> RuntimeResult<bool> {
1142        let entity = match self.entities(namespace)?.get_entity(id).await? {
1143            Some(e) => e,
1144            None => return Ok(false),
1145        };
1146        if entity.namespace != self.ns(namespace) {
1147            return Ok(false);
1148        }
1149        let mode = if hard {
1150            DeleteMode::Hard
1151        } else {
1152            DeleteMode::Soft
1153        };
1154
1155        // On hard delete, cascade-remove incident edges to prevent dangling refs.
1156        if hard {
1157            let graph = self.graph(namespace)?;
1158            for direction in [Direction::Out, Direction::In] {
1159                let hits = graph
1160                    .neighbors(
1161                        id,
1162                        NeighborQuery {
1163                            direction,
1164                            relations: None,
1165                            limit: None,
1166                            min_weight: None,
1167                        },
1168                    )
1169                    .await?;
1170                for hit in hits {
1171                    graph.delete_edge(LinkId::from(hit.edge_id)).await?;
1172                }
1173            }
1174            self.remove_from_indexes(namespace, id).await?;
1175        }
1176
1177        let deleted = self.entities(namespace)?.delete_entity(id, mode).await?;
1178        if !hard && deleted {
1179            self.remove_from_indexes(namespace, id).await?;
1180        }
1181        Ok(deleted)
1182    }
1183
1184    /// Count entities in a namespace, optionally filtered.
1185    pub async fn count_entities(
1186        &self,
1187        namespace: Option<&str>,
1188        kind: Option<&str>,
1189    ) -> RuntimeResult<u64> {
1190        let filter = EntityFilter {
1191            kinds: match kind {
1192                Some(k) => vec![k.to_string()],
1193                None => vec![],
1194            },
1195            ..Default::default()
1196        };
1197        Ok(self
1198            .entities(namespace)?
1199            .count_entities(self.ns(namespace), filter)
1200            .await?)
1201    }
1202
1203    // ---- Edge CRUD operations ----
1204
1205    /// Fetch a single edge by id. Returns `None` if the edge does not exist.
1206    pub async fn get_edge(
1207        &self,
1208        namespace: Option<&str>,
1209        edge_id: Uuid,
1210    ) -> RuntimeResult<Option<Edge>> {
1211        Ok(self
1212            .graph(namespace)?
1213            .get_edge(LinkId::from(edge_id))
1214            .await?)
1215    }
1216
1217    /// List edges matching `filter`. `limit` is capped at 1000; defaults to 100.
1218    pub async fn list_edges(
1219        &self,
1220        namespace: Option<&str>,
1221        filter: crate::curation::EdgeListFilter,
1222        limit: u32,
1223    ) -> RuntimeResult<Vec<Edge>> {
1224        let limit = limit.clamp(1, 1000);
1225        let page = self
1226            .graph(namespace)?
1227            .query_edges(
1228                filter.into(),
1229                vec![SortOrder {
1230                    field: EdgeSortField::CreatedAt,
1231                    direction: khive_storage::types::SortDirection::Asc,
1232                }],
1233                PageRequest { offset: 0, limit },
1234            )
1235            .await?;
1236        Ok(page.items)
1237    }
1238
1239    /// Patch-style edge update. Only `Some(_)` fields are applied.
1240    ///
1241    /// When `relation` is `Some(new_rel)`, validates that the edge's existing endpoints
1242    /// are legal for `new_rel` before persisting. Weight-only updates (`relation = None`)
1243    /// skip validation. Returns `InvalidInput` if the new relation would violate the
1244    /// ADR-002/ADR-019/ADR-024 three-case contract; the edge is NOT mutated on error.
1245    pub async fn update_edge(
1246        &self,
1247        namespace: Option<&str>,
1248        edge_id: Uuid,
1249        relation: Option<EdgeRelation>,
1250        weight: Option<f64>,
1251    ) -> RuntimeResult<Edge> {
1252        let graph = self.graph(namespace)?;
1253        let mut edge = graph
1254            .get_edge(LinkId::from(edge_id))
1255            .await?
1256            .ok_or_else(|| crate::RuntimeError::NotFound(format!("edge {edge_id}")))?;
1257
1258        if let Some(r) = relation {
1259            // Validate before mutating — use the existing endpoints with the new relation.
1260            self.validate_edge_relation_endpoints(namespace, edge.source_id, edge.target_id, r)
1261                .await?;
1262            edge.relation = r;
1263        }
1264        if let Some(w) = weight {
1265            edge.weight = w.clamp(0.0, 1.0);
1266        }
1267
1268        graph.upsert_edge(edge.clone()).await?;
1269        Ok(edge)
1270    }
1271
1272    /// Hard-delete an edge by id.
1273    ///
1274    /// Cascades to remove any `annotates` edges whose target is the deleted edge
1275    /// (ADR-002: `annotates` is note → anything; deleting an edge target leaves
1276    /// annotation edges dangling if not cleaned up). Returns `true` if the primary
1277    /// edge was removed.
1278    ///
1279    /// If `edge_id` does not refer to an edge (e.g. the caller passes an entity or
1280    /// note UUID by mistake), this method returns `Ok(false)` immediately with no
1281    /// side effects — it does **not** cascade inbound edges of the non-edge record.
1282    pub async fn delete_edge(&self, namespace: Option<&str>, edge_id: Uuid) -> RuntimeResult<bool> {
1283        let graph = self.graph(namespace)?;
1284
1285        // Guard: verify `edge_id` is actually an edge before touching anything.
1286        // Without this check, passing an entity/note UUID would delete all inbound
1287        // annotates edges targeting that record and then return false — a destructive
1288        // side effect on an invalid call.
1289        if graph.get_edge(LinkId::from(edge_id)).await?.is_none() {
1290            return Ok(false);
1291        }
1292
1293        // Cascade: remove annotate edges that target this edge (inbound from note sources).
1294        let inbound = graph
1295            .neighbors(
1296                edge_id,
1297                NeighborQuery {
1298                    direction: Direction::In,
1299                    relations: None,
1300                    limit: None,
1301                    min_weight: None,
1302                },
1303            )
1304            .await?;
1305        for hit in inbound {
1306            graph.delete_edge(LinkId::from(hit.edge_id)).await?;
1307        }
1308
1309        Ok(graph.delete_edge(LinkId::from(edge_id)).await?)
1310    }
1311
1312    /// Count edges matching `filter`.
1313    pub async fn count_edges(
1314        &self,
1315        namespace: Option<&str>,
1316        filter: crate::curation::EdgeListFilter,
1317    ) -> RuntimeResult<u64> {
1318        Ok(self.graph(namespace)?.count_edges(filter.into()).await?)
1319    }
1320}
1321
1322#[cfg(test)]
1323mod tests {
1324    use super::*;
1325    use crate::curation::EdgeListFilter;
1326    use crate::runtime::KhiveRuntime;
1327
1328    fn rt() -> KhiveRuntime {
1329        KhiveRuntime::memory().unwrap()
1330    }
1331
1332    #[tokio::test]
1333    async fn update_edge_changes_weight() {
1334        let rt = rt();
1335        let a = rt
1336            .create_entity(None, "concept", "A", None, None, vec![])
1337            .await
1338            .unwrap();
1339        let b = rt
1340            .create_entity(None, "concept", "B", None, None, vec![])
1341            .await
1342            .unwrap();
1343        let edge = rt
1344            .link(None, a.id, b.id, EdgeRelation::Extends, 1.0)
1345            .await
1346            .unwrap();
1347        let edge_id: Uuid = edge.id.into();
1348
1349        let updated = rt
1350            .update_edge(None, edge_id, None, Some(0.5))
1351            .await
1352            .unwrap();
1353        assert!((updated.weight - 0.5).abs() < 0.001);
1354    }
1355
1356    #[tokio::test]
1357    async fn update_edge_changes_relation() {
1358        let rt = rt();
1359        let a = rt
1360            .create_entity(None, "concept", "A", None, None, vec![])
1361            .await
1362            .unwrap();
1363        let b = rt
1364            .create_entity(None, "concept", "B", None, None, vec![])
1365            .await
1366            .unwrap();
1367        let edge = rt
1368            .link(None, a.id, b.id, EdgeRelation::Extends, 1.0)
1369            .await
1370            .unwrap();
1371        let edge_id: Uuid = edge.id.into();
1372
1373        let updated = rt
1374            .update_edge(None, edge_id, Some(EdgeRelation::VariantOf), None)
1375            .await
1376            .unwrap();
1377        assert_eq!(updated.relation, EdgeRelation::VariantOf);
1378    }
1379
1380    // ---- Round-5 tests: update_edge endpoint validation (ADR-002 bypass fix) ----
1381
1382    // update_edge: note→entity annotates → set relation=Supersedes → InvalidInput (crossing).
1383    // Edge must NOT be mutated in the store.
1384    #[tokio::test]
1385    async fn update_edge_annotates_note_to_entity_set_supersedes_returns_invalid_input() {
1386        let rt = rt();
1387        let note = rt
1388            .create_note(None, "observation", None, "a note", 0.5, None, vec![])
1389            .await
1390            .unwrap();
1391        let entity = rt
1392            .create_entity(None, "concept", "E", None, None, vec![])
1393            .await
1394            .unwrap();
1395        // Create a valid note→entity annotates edge.
1396        let edge = rt
1397            .link(None, note.id, entity.id, EdgeRelation::Annotates, 1.0)
1398            .await
1399            .unwrap();
1400        let edge_id: Uuid = edge.id.into();
1401
1402        // Attempt to change relation to Supersedes (crossing substrates → invalid).
1403        let result = rt
1404            .update_edge(None, edge_id, Some(EdgeRelation::Supersedes), None)
1405            .await;
1406        assert!(
1407            matches!(result, Err(RuntimeError::InvalidInput(_))),
1408            "update to Supersedes on note→entity edge must return InvalidInput, got {result:?}"
1409        );
1410
1411        // Edge must NOT be mutated — re-fetch and verify relation unchanged.
1412        let fetched = rt.get_edge(None, edge_id).await.unwrap().unwrap();
1413        assert_eq!(
1414            fetched.relation,
1415            EdgeRelation::Annotates,
1416            "edge relation must be unchanged after failed update"
1417        );
1418    }
1419
1420    // update_edge: entity→entity extends → set relation=Annotates → InvalidInput
1421    // (annotates source must be a note).
1422    #[tokio::test]
1423    async fn update_edge_entity_to_entity_set_annotates_returns_invalid_input() {
1424        let rt = rt();
1425        let a = rt
1426            .create_entity(None, "concept", "A", None, None, vec![])
1427            .await
1428            .unwrap();
1429        let b = rt
1430            .create_entity(None, "concept", "B", None, None, vec![])
1431            .await
1432            .unwrap();
1433        let edge = rt
1434            .link(None, a.id, b.id, EdgeRelation::Extends, 1.0)
1435            .await
1436            .unwrap();
1437        let edge_id: Uuid = edge.id.into();
1438
1439        let result = rt
1440            .update_edge(None, edge_id, Some(EdgeRelation::Annotates), None)
1441            .await;
1442        assert!(
1443            matches!(result, Err(RuntimeError::InvalidInput(_))),
1444            "update to Annotates on entity→entity edge must return InvalidInput, got {result:?}"
1445        );
1446    }
1447
1448    // update_edge: entity→entity extends → set relation=Supersedes → Ok
1449    // (entity→entity is valid for supersedes).
1450    #[tokio::test]
1451    async fn update_edge_entity_to_entity_set_supersedes_succeeds() {
1452        let rt = rt();
1453        let a = rt
1454            .create_entity(None, "concept", "A", None, None, vec![])
1455            .await
1456            .unwrap();
1457        let b = rt
1458            .create_entity(None, "concept", "B", None, None, vec![])
1459            .await
1460            .unwrap();
1461        let edge = rt
1462            .link(None, a.id, b.id, EdgeRelation::Extends, 1.0)
1463            .await
1464            .unwrap();
1465        let edge_id: Uuid = edge.id.into();
1466
1467        let updated = rt
1468            .update_edge(None, edge_id, Some(EdgeRelation::Supersedes), None)
1469            .await
1470            .unwrap();
1471        assert_eq!(updated.relation, EdgeRelation::Supersedes);
1472
1473        // Verify persisted.
1474        let fetched = rt.get_edge(None, edge_id).await.unwrap().unwrap();
1475        assert_eq!(fetched.relation, EdgeRelation::Supersedes);
1476    }
1477
1478    // update_edge: weight-only (relation = None) → Ok, no validation, unchanged relation.
1479    #[tokio::test]
1480    async fn update_edge_weight_only_skips_validation() {
1481        let rt = rt();
1482        let a = rt
1483            .create_entity(None, "concept", "A", None, None, vec![])
1484            .await
1485            .unwrap();
1486        let b = rt
1487            .create_entity(None, "concept", "B", None, None, vec![])
1488            .await
1489            .unwrap();
1490        let edge = rt
1491            .link(None, a.id, b.id, EdgeRelation::Extends, 1.0)
1492            .await
1493            .unwrap();
1494        let edge_id: Uuid = edge.id.into();
1495
1496        let updated = rt
1497            .update_edge(None, edge_id, None, Some(0.3))
1498            .await
1499            .unwrap();
1500        assert_eq!(updated.relation, EdgeRelation::Extends);
1501        assert!((updated.weight - 0.3).abs() < 0.001);
1502    }
1503
1504    // update_edge: entity→entity extends → set relation=VariantOf (same class) → Ok.
1505    #[tokio::test]
1506    async fn update_edge_same_class_relation_change_succeeds() {
1507        let rt = rt();
1508        let a = rt
1509            .create_entity(None, "concept", "A", None, None, vec![])
1510            .await
1511            .unwrap();
1512        let b = rt
1513            .create_entity(None, "concept", "B", None, None, vec![])
1514            .await
1515            .unwrap();
1516        let edge = rt
1517            .link(None, a.id, b.id, EdgeRelation::Extends, 1.0)
1518            .await
1519            .unwrap();
1520        let edge_id: Uuid = edge.id.into();
1521
1522        let updated = rt
1523            .update_edge(None, edge_id, Some(EdgeRelation::VariantOf), None)
1524            .await
1525            .unwrap();
1526        assert_eq!(updated.relation, EdgeRelation::VariantOf);
1527    }
1528
1529    #[tokio::test]
1530    async fn list_edges_filters_by_relation() {
1531        let rt = rt();
1532        let a = rt
1533            .create_entity(None, "concept", "A", None, None, vec![])
1534            .await
1535            .unwrap();
1536        let b = rt
1537            .create_entity(None, "concept", "B", None, None, vec![])
1538            .await
1539            .unwrap();
1540        let c = rt
1541            .create_entity(None, "concept", "C", None, None, vec![])
1542            .await
1543            .unwrap();
1544
1545        rt.link(None, a.id, b.id, EdgeRelation::Extends, 1.0)
1546            .await
1547            .unwrap();
1548        rt.link(None, a.id, c.id, EdgeRelation::DependsOn, 1.0)
1549            .await
1550            .unwrap();
1551
1552        let filter = EdgeListFilter {
1553            relations: vec![EdgeRelation::Extends],
1554            ..Default::default()
1555        };
1556        let edges = rt.list_edges(None, filter, 100).await.unwrap();
1557        assert_eq!(edges.len(), 1);
1558        assert_eq!(edges[0].relation, EdgeRelation::Extends);
1559    }
1560
1561    #[tokio::test]
1562    async fn list_edges_filters_by_source() {
1563        let rt = rt();
1564        let a = rt
1565            .create_entity(None, "concept", "A", None, None, vec![])
1566            .await
1567            .unwrap();
1568        let b = rt
1569            .create_entity(None, "concept", "B", None, None, vec![])
1570            .await
1571            .unwrap();
1572        let c = rt
1573            .create_entity(None, "concept", "C", None, None, vec![])
1574            .await
1575            .unwrap();
1576        let d = rt
1577            .create_entity(None, "concept", "D", None, None, vec![])
1578            .await
1579            .unwrap();
1580
1581        rt.link(None, a.id, b.id, EdgeRelation::Extends, 1.0)
1582            .await
1583            .unwrap();
1584        rt.link(None, c.id, d.id, EdgeRelation::Extends, 1.0)
1585            .await
1586            .unwrap();
1587
1588        let filter = EdgeListFilter {
1589            source_id: Some(a.id),
1590            ..Default::default()
1591        };
1592        let edges = rt.list_edges(None, filter, 100).await.unwrap();
1593        assert_eq!(edges.len(), 1);
1594        let src: Uuid = edges[0].source_id;
1595        assert_eq!(src, a.id);
1596    }
1597
1598    #[tokio::test]
1599    async fn delete_edge_removes_from_storage() {
1600        let rt = rt();
1601        let a = rt
1602            .create_entity(None, "concept", "A", None, None, vec![])
1603            .await
1604            .unwrap();
1605        let b = rt
1606            .create_entity(None, "concept", "B", None, None, vec![])
1607            .await
1608            .unwrap();
1609        let edge = rt
1610            .link(None, a.id, b.id, EdgeRelation::Extends, 1.0)
1611            .await
1612            .unwrap();
1613        let edge_id: Uuid = edge.id.into();
1614
1615        let deleted = rt.delete_edge(None, edge_id).await.unwrap();
1616        assert!(deleted);
1617
1618        let fetched = rt.get_edge(None, edge_id).await.unwrap();
1619        assert!(fetched.is_none(), "edge should be gone after delete");
1620    }
1621
1622    #[tokio::test]
1623    async fn count_edges_matches_filter() {
1624        let rt = rt();
1625        let a = rt
1626            .create_entity(None, "concept", "A", None, None, vec![])
1627            .await
1628            .unwrap();
1629        let b = rt
1630            .create_entity(None, "concept", "B", None, None, vec![])
1631            .await
1632            .unwrap();
1633        let c = rt
1634            .create_entity(None, "concept", "C", None, None, vec![])
1635            .await
1636            .unwrap();
1637
1638        rt.link(None, a.id, b.id, EdgeRelation::Extends, 1.0)
1639            .await
1640            .unwrap();
1641        rt.link(None, a.id, c.id, EdgeRelation::DependsOn, 1.0)
1642            .await
1643            .unwrap();
1644
1645        let all = rt
1646            .count_edges(None, EdgeListFilter::default())
1647            .await
1648            .unwrap();
1649        assert_eq!(all, 2);
1650
1651        let just_extends = rt
1652            .count_edges(
1653                None,
1654                EdgeListFilter {
1655                    relations: vec![EdgeRelation::Extends],
1656                    ..Default::default()
1657                },
1658            )
1659            .await
1660            .unwrap();
1661        assert_eq!(just_extends, 1);
1662    }
1663
1664    #[tokio::test]
1665    async fn get_entity_namespace_isolation() {
1666        let rt = rt();
1667        let entity = rt
1668            .create_entity(Some("ns-a"), "concept", "Alpha", None, None, vec![])
1669            .await
1670            .unwrap();
1671
1672        // Same namespace: visible.
1673        let found = rt.get_entity(Some("ns-a"), entity.id).await.unwrap();
1674        assert!(found.is_some(), "should be visible in its own namespace");
1675
1676        // Different namespace: invisible.
1677        let not_found = rt.get_entity(Some("ns-b"), entity.id).await.unwrap();
1678        assert!(
1679            not_found.is_none(),
1680            "should not be visible across namespaces"
1681        );
1682    }
1683
1684    #[tokio::test]
1685    async fn delete_entity_namespace_isolation() {
1686        let rt = rt();
1687        let entity = rt
1688            .create_entity(Some("ns-a"), "concept", "Beta", None, None, vec![])
1689            .await
1690            .unwrap();
1691
1692        // Delete from wrong namespace: no-op, returns false.
1693        let deleted = rt
1694            .delete_entity(Some("ns-b"), entity.id, true)
1695            .await
1696            .unwrap();
1697        assert!(!deleted, "cross-namespace delete must return false");
1698
1699        // Entity still present in its own namespace.
1700        let still_there = rt.get_entity(Some("ns-a"), entity.id).await.unwrap();
1701        assert!(
1702            still_there.is_some(),
1703            "entity must survive cross-ns delete attempt"
1704        );
1705
1706        // Delete from correct namespace: succeeds.
1707        let deleted_ok = rt
1708            .delete_entity(Some("ns-a"), entity.id, true)
1709            .await
1710            .unwrap();
1711        assert!(deleted_ok, "same-namespace delete must succeed");
1712    }
1713
1714    // ---- Note ADR-024 tests ----
1715
1716    #[tokio::test]
1717    async fn create_note_indexes_into_fts5() {
1718        let rt = rt();
1719        let note = rt
1720            .create_note(
1721                None,
1722                "observation",
1723                None,
1724                "FlashAttention reduces memory by using tiling",
1725                0.8,
1726                None,
1727                vec![],
1728            )
1729            .await
1730            .unwrap();
1731
1732        // FTS5 should have indexed the note content.
1733        let ns = rt.ns(None).to_string();
1734        let hits = rt
1735            .text_for_notes(None)
1736            .unwrap()
1737            .search(khive_storage::types::TextSearchRequest {
1738                query: "FlashAttention".to_string(),
1739                mode: khive_storage::types::TextQueryMode::Plain,
1740                filter: Some(khive_storage::types::TextFilter {
1741                    namespaces: vec![ns],
1742                    ..Default::default()
1743                }),
1744                top_k: 10,
1745                snippet_chars: 100,
1746            })
1747            .await
1748            .unwrap();
1749
1750        assert!(
1751            hits.iter().any(|h| h.subject_id == note.id),
1752            "note should be indexed in FTS5 after create"
1753        );
1754    }
1755
1756    #[tokio::test]
1757    async fn create_note_with_properties() {
1758        let rt = rt();
1759        let props = serde_json::json!({"source": "arxiv:2205.14135"});
1760        let note = rt
1761            .create_note(
1762                None,
1763                "insight",
1764                None,
1765                "FlashAttention is IO-aware",
1766                0.9,
1767                Some(props.clone()),
1768                vec![],
1769            )
1770            .await
1771            .unwrap();
1772
1773        assert_eq!(note.properties.as_ref().unwrap(), &props);
1774    }
1775
1776    #[tokio::test]
1777    async fn create_note_creates_annotates_edges() {
1778        let rt = rt();
1779        let entity = rt
1780            .create_entity(None, "concept", "FlashAttention", None, None, vec![])
1781            .await
1782            .unwrap();
1783
1784        let note = rt
1785            .create_note(
1786                None,
1787                "observation",
1788                None,
1789                "FlashAttention uses SRAM tiling for memory efficiency",
1790                0.9,
1791                None,
1792                vec![entity.id],
1793            )
1794            .await
1795            .unwrap();
1796
1797        // The note should have an outbound `annotates` edge to the entity.
1798        let out_neighbors = rt
1799            .neighbors(
1800                None,
1801                note.id,
1802                Direction::Out,
1803                None,
1804                Some(vec![EdgeRelation::Annotates]),
1805            )
1806            .await
1807            .unwrap();
1808        assert_eq!(out_neighbors.len(), 1);
1809        assert_eq!(out_neighbors[0].node_id, entity.id);
1810        assert_eq!(out_neighbors[0].relation, EdgeRelation::Annotates);
1811
1812        // The entity should have an inbound `annotates` edge from the note.
1813        let in_neighbors = rt
1814            .neighbors(
1815                None,
1816                entity.id,
1817                Direction::In,
1818                None,
1819                Some(vec![EdgeRelation::Annotates]),
1820            )
1821            .await
1822            .unwrap();
1823        assert_eq!(in_neighbors.len(), 1);
1824        assert_eq!(in_neighbors[0].node_id, note.id);
1825    }
1826
1827    #[tokio::test]
1828    async fn neighbors_without_relation_filter_returns_all() {
1829        let rt = rt();
1830        let a = rt
1831            .create_entity(None, "concept", "A", None, None, vec![])
1832            .await
1833            .unwrap();
1834        let b = rt
1835            .create_entity(None, "concept", "B", None, None, vec![])
1836            .await
1837            .unwrap();
1838        let c = rt
1839            .create_entity(None, "concept", "C", None, None, vec![])
1840            .await
1841            .unwrap();
1842
1843        rt.link(None, a.id, b.id, EdgeRelation::Extends, 1.0)
1844            .await
1845            .unwrap();
1846        rt.link(None, a.id, c.id, EdgeRelation::DependsOn, 1.0)
1847            .await
1848            .unwrap();
1849
1850        let all = rt
1851            .neighbors(None, a.id, Direction::Out, None, None)
1852            .await
1853            .unwrap();
1854        assert_eq!(all.len(), 2);
1855    }
1856
1857    #[tokio::test]
1858    async fn neighbors_with_relation_filter_returns_subset() {
1859        let rt = rt();
1860        let a = rt
1861            .create_entity(None, "concept", "A", None, None, vec![])
1862            .await
1863            .unwrap();
1864        let b = rt
1865            .create_entity(None, "concept", "B", None, None, vec![])
1866            .await
1867            .unwrap();
1868        let c = rt
1869            .create_entity(None, "concept", "C", None, None, vec![])
1870            .await
1871            .unwrap();
1872
1873        rt.link(None, a.id, b.id, EdgeRelation::Extends, 1.0)
1874            .await
1875            .unwrap();
1876        rt.link(None, a.id, c.id, EdgeRelation::DependsOn, 1.0)
1877            .await
1878            .unwrap();
1879
1880        let filtered = rt
1881            .neighbors(
1882                None,
1883                a.id,
1884                Direction::Out,
1885                None,
1886                Some(vec![EdgeRelation::Extends]),
1887            )
1888            .await
1889            .unwrap();
1890        assert_eq!(filtered.len(), 1);
1891        assert_eq!(filtered[0].node_id, b.id);
1892        assert_eq!(filtered[0].relation, EdgeRelation::Extends);
1893    }
1894
1895    #[tokio::test]
1896    async fn search_notes_returns_relevant_note() {
1897        let rt = rt();
1898        rt.create_note(
1899            None,
1900            "observation",
1901            None,
1902            "GQA reduces KV cache memory for large models",
1903            0.8,
1904            None,
1905            vec![],
1906        )
1907        .await
1908        .unwrap();
1909
1910        let results = rt
1911            .search_notes(None, "GQA KV cache", None, 10, None)
1912            .await
1913            .unwrap();
1914
1915        assert!(!results.is_empty(), "search should return the indexed note");
1916        let hit = &results[0];
1917        assert!(
1918            hit.title.is_some(),
1919            "note hit title should be populated (falls back to content)"
1920        );
1921        assert!(
1922            hit.snippet.is_some(),
1923            "note hit snippet should be populated"
1924        );
1925    }
1926
1927    #[tokio::test]
1928    async fn search_notes_excludes_soft_deleted() {
1929        let rt = rt();
1930        let note = rt
1931            .create_note(
1932                None,
1933                "observation",
1934                None,
1935                "RoPE positional encoding rotary embeddings",
1936                0.7,
1937                None,
1938                vec![],
1939            )
1940            .await
1941            .unwrap();
1942
1943        // Soft-delete the note.
1944        rt.notes(None)
1945            .unwrap()
1946            .delete_note(note.id, DeleteMode::Soft)
1947            .await
1948            .unwrap();
1949
1950        let results = rt
1951            .search_notes(None, "RoPE rotary positional", None, 10, None)
1952            .await
1953            .unwrap();
1954
1955        assert!(
1956            results.iter().all(|h| h.note_id != note.id),
1957            "soft-deleted note should be excluded from search"
1958        );
1959    }
1960
1961    #[tokio::test]
1962    async fn resolve_returns_entity() {
1963        let rt = rt();
1964        let entity = rt
1965            .create_entity(None, "concept", "LoRA", None, None, vec![])
1966            .await
1967            .unwrap();
1968
1969        let resolved = rt.resolve(None, entity.id).await.unwrap();
1970        match resolved {
1971            Some(Resolved::Entity(e)) => assert_eq!(e.id, entity.id),
1972            other => panic!("expected Resolved::Entity, got {:?}", other),
1973        }
1974    }
1975
1976    #[tokio::test]
1977    async fn resolve_returns_note() {
1978        let rt = rt();
1979        let note = rt
1980            .create_note(
1981                None,
1982                "observation",
1983                None,
1984                "LoRA fine-tunes LLMs with low-rank adapters",
1985                0.85,
1986                None,
1987                vec![],
1988            )
1989            .await
1990            .unwrap();
1991
1992        let resolved = rt.resolve(None, note.id).await.unwrap();
1993        match resolved {
1994            Some(Resolved::Note(n)) => assert_eq!(n.id, note.id),
1995            other => panic!("expected Resolved::Note, got {:?}", other),
1996        }
1997    }
1998
1999    #[tokio::test]
2000    async fn resolve_returns_none_for_unknown_uuid() {
2001        let rt = rt();
2002        let unknown = Uuid::new_v4();
2003        let resolved = rt.resolve(None, unknown).await.unwrap();
2004        assert!(resolved.is_none(), "unknown UUID should resolve to None");
2005    }
2006
2007    #[tokio::test]
2008    async fn resolve_prefix_finds_entity_in_own_namespace() {
2009        let rt = rt();
2010        let entity = rt
2011            .create_entity(None, "concept", "PrefixTest", None, None, vec![])
2012            .await
2013            .unwrap();
2014        let prefix = &entity.id.to_string()[..8];
2015
2016        let resolved = rt.resolve_prefix(None, prefix).await.unwrap();
2017        assert_eq!(resolved, Some(entity.id));
2018    }
2019
2020    #[tokio::test]
2021    async fn resolve_prefix_invisible_across_namespaces() {
2022        let rt = rt();
2023        let entity = rt
2024            .create_entity(Some("ns_a"), "concept", "Invisible", None, None, vec![])
2025            .await
2026            .unwrap();
2027        let prefix = &entity.id.to_string()[..8];
2028
2029        // From ns_b, the entity in ns_a should not be visible.
2030        let resolved = rt.resolve_prefix(Some("ns_b"), prefix).await.unwrap();
2031        assert_eq!(resolved, None);
2032    }
2033
2034    #[tokio::test]
2035    async fn resolve_prefix_ambiguous_same_namespace() {
2036        use khive_storage::entity::Entity;
2037
2038        let rt = rt();
2039        // Two entities with UUIDs sharing the same 8-char prefix "aabbccdd".
2040        let id_a = Uuid::parse_str("aabbccdd-1111-4000-8000-000000000001").unwrap();
2041        let id_b = Uuid::parse_str("aabbccdd-2222-4000-8000-000000000002").unwrap();
2042
2043        let mut entity_a = Entity::new("local", "concept", "AmbigA");
2044        entity_a.id = id_a;
2045        let mut entity_b = Entity::new("local", "concept", "AmbigB");
2046        entity_b.id = id_b;
2047
2048        let store = rt.entities(None).unwrap();
2049        store.upsert_entity(entity_a).await.unwrap();
2050        store.upsert_entity(entity_b).await.unwrap();
2051
2052        let result = rt.resolve_prefix(None, "aabbccdd").await;
2053        assert!(
2054            result.is_err(),
2055            "shared 8-char prefix must return Ambiguous error"
2056        );
2057    }
2058
2059    // ---- Event resolution tests (issue #30) ----
2060    //
2061    // resolve_prefix and handle_get already include events; these tests are
2062    // regression coverage confirming event UUIDs are resolvable and that get()
2063    // returns kind="event".
2064
2065    #[tokio::test]
2066    async fn resolve_finds_event_by_full_uuid() {
2067        use khive_storage::Event;
2068        use khive_types::SubstrateKind;
2069
2070        let rt = rt();
2071        let ns = rt.ns(None);
2072        let event = Event::new(ns, "test_verb", SubstrateKind::Entity, "actor");
2073        let event_id = event.id;
2074        rt.events(None).unwrap().append_event(event).await.unwrap();
2075
2076        let resolved = rt.resolve(None, event_id).await.unwrap();
2077        assert!(
2078            matches!(resolved, Some(Resolved::Event(_))),
2079            "event UUID must resolve to Resolved::Event, got {resolved:?}"
2080        );
2081    }
2082
2083    #[tokio::test]
2084    async fn resolve_prefix_finds_event() {
2085        use khive_storage::Event;
2086        use khive_types::SubstrateKind;
2087
2088        let rt = rt();
2089        let ns = rt.ns(None);
2090        let event = Event::new(ns, "test_verb", SubstrateKind::Entity, "actor");
2091        let event_id = event.id;
2092        rt.events(None).unwrap().append_event(event).await.unwrap();
2093
2094        let prefix = &event_id.to_string()[..8];
2095        let resolved = rt.resolve_prefix(None, prefix).await.unwrap();
2096        assert_eq!(
2097            resolved,
2098            Some(event_id),
2099            "resolve_prefix must return event UUID for 8-char prefix"
2100        );
2101    }
2102
2103    // ---- Referential integrity tests (fix/link-referential-integrity) ----
2104
2105    #[tokio::test]
2106    async fn link_phantom_source_returns_not_found() {
2107        let rt = rt();
2108        let b = rt
2109            .create_entity(None, "concept", "B", None, None, vec![])
2110            .await
2111            .unwrap();
2112        let phantom = Uuid::new_v4();
2113
2114        let result = rt
2115            .link(None, phantom, b.id, EdgeRelation::Extends, 1.0)
2116            .await;
2117        match result {
2118            Err(RuntimeError::NotFound(msg)) => {
2119                assert!(
2120                    msg.contains("source"),
2121                    "error message must name 'source': {msg}"
2122                );
2123            }
2124            other => panic!("expected NotFound for phantom source, got {other:?}"),
2125        }
2126    }
2127
2128    #[tokio::test]
2129    async fn link_phantom_target_returns_not_found() {
2130        let rt = rt();
2131        let a = rt
2132            .create_entity(None, "concept", "A", None, None, vec![])
2133            .await
2134            .unwrap();
2135        let phantom = Uuid::new_v4();
2136
2137        let result = rt
2138            .link(None, a.id, phantom, EdgeRelation::Extends, 1.0)
2139            .await;
2140        match result {
2141            Err(RuntimeError::NotFound(msg)) => {
2142                assert!(
2143                    msg.contains("target"),
2144                    "error message must name 'target': {msg}"
2145                );
2146            }
2147            other => panic!("expected NotFound for phantom target, got {other:?}"),
2148        }
2149    }
2150
2151    #[tokio::test]
2152    async fn link_real_entities_succeeds() {
2153        let rt = rt();
2154        let a = rt
2155            .create_entity(None, "concept", "A", None, None, vec![])
2156            .await
2157            .unwrap();
2158        let b = rt
2159            .create_entity(None, "concept", "B", None, None, vec![])
2160            .await
2161            .unwrap();
2162
2163        let edge = rt
2164            .link(None, a.id, b.id, EdgeRelation::Extends, 0.8)
2165            .await
2166            .unwrap();
2167        assert_eq!(edge.source_id, a.id);
2168        assert_eq!(edge.target_id, b.id);
2169        assert_eq!(edge.relation, EdgeRelation::Extends);
2170    }
2171
2172    #[tokio::test]
2173    async fn create_note_annotates_phantom_returns_not_found() {
2174        let rt = rt();
2175        let phantom = Uuid::new_v4();
2176
2177        let result = rt
2178            .create_note(
2179                None,
2180                "observation",
2181                None,
2182                "some content",
2183                0.5,
2184                None,
2185                vec![phantom],
2186            )
2187            .await;
2188        assert!(
2189            matches!(result, Err(RuntimeError::NotFound(_))),
2190            "annotates with phantom uuid must return NotFound, got {result:?}"
2191        );
2192    }
2193
2194    #[tokio::test]
2195    async fn create_note_annotates_real_entity_succeeds() {
2196        let rt = rt();
2197        let entity = rt
2198            .create_entity(None, "concept", "RealTarget", None, None, vec![])
2199            .await
2200            .unwrap();
2201
2202        let note = rt
2203            .create_note(
2204                None,
2205                "observation",
2206                None,
2207                "content",
2208                0.5,
2209                None,
2210                vec![entity.id],
2211            )
2212            .await
2213            .unwrap();
2214
2215        let neighbors = rt
2216            .neighbors(
2217                None,
2218                note.id,
2219                Direction::Out,
2220                None,
2221                Some(vec![EdgeRelation::Annotates]),
2222            )
2223            .await
2224            .unwrap();
2225        assert_eq!(neighbors.len(), 1);
2226        assert_eq!(neighbors[0].node_id, entity.id);
2227    }
2228
2229    // Atomicity: multi-target annotates golden path — all edges created, note present.
2230    #[tokio::test]
2231    async fn create_note_multi_annotates_creates_all_edges() {
2232        let rt = rt();
2233        let t1 = rt
2234            .create_entity(None, "concept", "Target1", None, None, vec![])
2235            .await
2236            .unwrap();
2237        let t2 = rt
2238            .create_entity(None, "concept", "Target2", None, None, vec![])
2239            .await
2240            .unwrap();
2241
2242        let note = rt
2243            .create_note(
2244                None,
2245                "observation",
2246                None,
2247                "content",
2248                0.5,
2249                None,
2250                vec![t1.id, t2.id],
2251            )
2252            .await
2253            .unwrap();
2254
2255        let neighbors = rt
2256            .neighbors(
2257                None,
2258                note.id,
2259                Direction::Out,
2260                None,
2261                Some(vec![EdgeRelation::Annotates]),
2262            )
2263            .await
2264            .unwrap();
2265        assert_eq!(
2266            neighbors.len(),
2267            2,
2268            "multi-annotates note must have exactly 2 outbound annotates edges"
2269        );
2270        let target_ids: Vec<Uuid> = neighbors.iter().map(|n| n.node_id).collect();
2271        assert!(target_ids.contains(&t1.id));
2272        assert!(target_ids.contains(&t2.id));
2273    }
2274
2275    #[tokio::test]
2276    async fn link_target_in_different_namespace_returns_not_found() {
2277        let rt = rt();
2278        let a = rt
2279            .create_entity(Some("ns-a"), "concept", "A", None, None, vec![])
2280            .await
2281            .unwrap();
2282        let b = rt
2283            .create_entity(Some("ns-b"), "concept", "B", None, None, vec![])
2284            .await
2285            .unwrap();
2286
2287        // Linking from ns-a: target b lives in ns-b — must be treated as not found.
2288        let result = rt
2289            .link(Some("ns-a"), a.id, b.id, EdgeRelation::Extends, 1.0)
2290            .await;
2291        assert!(
2292            matches!(result, Err(RuntimeError::NotFound(_))),
2293            "target in different namespace must return NotFound (fail-closed), got {result:?}"
2294        );
2295    }
2296
2297    #[tokio::test]
2298    async fn link_phantom_self_loop_returns_not_found() {
2299        let rt = rt();
2300        let phantom = Uuid::new_v4();
2301
2302        let result = rt
2303            .link(None, phantom, phantom, EdgeRelation::Extends, 1.0)
2304            .await;
2305        match result {
2306            Err(RuntimeError::NotFound(msg)) => {
2307                assert!(
2308                    msg.contains("source"),
2309                    "self-loop must fail on source first: {msg}"
2310                );
2311            }
2312            other => panic!("expected NotFound for phantom self-loop, got {other:?}"),
2313        }
2314    }
2315
2316    // ---- Round-2 tests: edge target coverage + atomicity ----
2317
2318    #[tokio::test]
2319    async fn link_note_to_edge_annotates_succeeds() {
2320        let rt = rt();
2321        let a = rt
2322            .create_entity(None, "concept", "A", None, None, vec![])
2323            .await
2324            .unwrap();
2325        let b = rt
2326            .create_entity(None, "concept", "B", None, None, vec![])
2327            .await
2328            .unwrap();
2329        // Create a real edge between a and b, capture its UUID.
2330        let edge = rt
2331            .link(None, a.id, b.id, EdgeRelation::Extends, 1.0)
2332            .await
2333            .unwrap();
2334        let edge_uuid: Uuid = edge.id.into();
2335
2336        // Create a note and annotate the edge itself (edge is a valid substrate target per ADR-024).
2337        let note = rt
2338            .create_note(None, "observation", None, "edge note", 0.5, None, vec![])
2339            .await
2340            .unwrap();
2341
2342        let result = rt
2343            .link(None, note.id, edge_uuid, EdgeRelation::Annotates, 1.0)
2344            .await;
2345        assert!(
2346            result.is_ok(),
2347            "note→edge Annotates must succeed, got {result:?}"
2348        );
2349    }
2350
2351    #[tokio::test]
2352    async fn create_note_annotates_real_edge_succeeds() {
2353        let rt = rt();
2354        let a = rt
2355            .create_entity(None, "concept", "A", None, None, vec![])
2356            .await
2357            .unwrap();
2358        let b = rt
2359            .create_entity(None, "concept", "B", None, None, vec![])
2360            .await
2361            .unwrap();
2362        let edge = rt
2363            .link(None, a.id, b.id, EdgeRelation::Extends, 1.0)
2364            .await
2365            .unwrap();
2366        let edge_uuid: Uuid = edge.id.into();
2367
2368        let note = rt
2369            .create_note(
2370                None,
2371                "observation",
2372                None,
2373                "annotating an edge",
2374                0.5,
2375                None,
2376                vec![edge_uuid],
2377            )
2378            .await
2379            .unwrap();
2380
2381        let neighbors = rt
2382            .neighbors(
2383                None,
2384                note.id,
2385                Direction::Out,
2386                None,
2387                Some(vec![EdgeRelation::Annotates]),
2388            )
2389            .await
2390            .unwrap();
2391        assert_eq!(neighbors.len(), 1);
2392        assert_eq!(neighbors[0].node_id, edge_uuid);
2393    }
2394
2395    #[tokio::test]
2396    async fn create_note_annotates_phantom_is_atomic_no_note_persisted() {
2397        let rt = rt();
2398        let phantom = Uuid::new_v4();
2399
2400        let before_count = rt.list_notes(None, None, 1000, 0).await.unwrap().len();
2401
2402        let result = rt
2403            .create_note(
2404                None,
2405                "observation",
2406                None,
2407                "should not persist",
2408                0.5,
2409                None,
2410                vec![phantom],
2411            )
2412            .await;
2413        assert!(
2414            matches!(result, Err(RuntimeError::NotFound(_))),
2415            "phantom annotates target must return NotFound, got {result:?}"
2416        );
2417
2418        // Atomicity: the note row must NOT have been written.
2419        let after_count = rt.list_notes(None, None, 1000, 0).await.unwrap().len();
2420        assert_eq!(
2421            before_count, after_count,
2422            "failed create_note must not persist any note row (atomicity)"
2423        );
2424
2425        // FTS must not contain the content either.
2426        let search_hits = rt
2427            .search_notes(None, "should not persist", None, 10, None)
2428            .await
2429            .unwrap();
2430        assert!(
2431            search_hits.is_empty(),
2432            "failed create_note must not index into FTS (atomicity)"
2433        );
2434        // Vector-store row: only written when an embedding model is configured; the rt()
2435        // harness has none, so no vector assertion is needed here.
2436    }
2437
2438    // ---- Round-3 tests: relation-aware endpoint contract (ADR-002) ----
2439
2440    // Test #2: entity→entity with non-annotates rejects an edge UUID as target.
2441    #[tokio::test]
2442    async fn link_entity_to_edge_uuid_non_annotates_returns_invalid_input() {
2443        let rt = rt();
2444        let a = rt
2445            .create_entity(None, "concept", "A", None, None, vec![])
2446            .await
2447            .unwrap();
2448        let b = rt
2449            .create_entity(None, "concept", "B", None, None, vec![])
2450            .await
2451            .unwrap();
2452        // Create a real edge; capture its UUID as the bad target.
2453        let edge = rt
2454            .link(None, a.id, b.id, EdgeRelation::Extends, 1.0)
2455            .await
2456            .unwrap();
2457        let edge_uuid: Uuid = edge.id.into();
2458
2459        let result = rt
2460            .link(None, a.id, edge_uuid, EdgeRelation::Extends, 1.0)
2461            .await;
2462        match result {
2463            Err(RuntimeError::InvalidInput(msg)) => {
2464                assert!(
2465                    msg.contains("target"),
2466                    "error message must name 'target': {msg}"
2467                );
2468            }
2469            other => {
2470                panic!("expected InvalidInput for edge-uuid target with Extends, got {other:?}")
2471            }
2472        }
2473    }
2474
2475    // Test #3: non-annotates rejects a note UUID as source.
2476    #[tokio::test]
2477    async fn link_note_as_source_non_annotates_returns_invalid_input() {
2478        let rt = rt();
2479        let note = rt
2480            .create_note(None, "observation", None, "a note", 0.5, None, vec![])
2481            .await
2482            .unwrap();
2483        let entity = rt
2484            .create_entity(None, "concept", "E", None, None, vec![])
2485            .await
2486            .unwrap();
2487
2488        let result = rt
2489            .link(None, note.id, entity.id, EdgeRelation::DependsOn, 1.0)
2490            .await;
2491        match result {
2492            Err(RuntimeError::InvalidInput(msg)) => {
2493                assert!(
2494                    msg.contains("source"),
2495                    "error message must name 'source': {msg}"
2496                );
2497            }
2498            other => panic!("expected InvalidInput for note source with DependsOn, got {other:?}"),
2499        }
2500    }
2501
2502    // Test #4: annotates rejects entity as source (source must be a note).
2503    #[tokio::test]
2504    async fn link_entity_as_annotates_source_returns_invalid_input() {
2505        let rt = rt();
2506        let a = rt
2507            .create_entity(None, "concept", "A", None, None, vec![])
2508            .await
2509            .unwrap();
2510        let b = rt
2511            .create_entity(None, "concept", "B", None, None, vec![])
2512            .await
2513            .unwrap();
2514
2515        let result = rt
2516            .link(None, a.id, b.id, EdgeRelation::Annotates, 1.0)
2517            .await;
2518        match result {
2519            Err(RuntimeError::InvalidInput(msg)) => {
2520                assert!(
2521                    msg.contains("source") && msg.contains("note"),
2522                    "error must say source must be a note: {msg}"
2523                );
2524            }
2525            other => {
2526                panic!("expected InvalidInput for entity source with Annotates, got {other:?}")
2527            }
2528        }
2529    }
2530
2531    #[tokio::test]
2532    async fn link_edge_as_annotates_source_returns_invalid_input() {
2533        let rt = rt();
2534        let a = rt
2535            .create_entity(None, "concept", "A", None, None, vec![])
2536            .await
2537            .unwrap();
2538        let b = rt
2539            .create_entity(None, "concept", "B", None, None, vec![])
2540            .await
2541            .unwrap();
2542        let edge = rt
2543            .link(None, a.id, b.id, EdgeRelation::Extends, 1.0)
2544            .await
2545            .unwrap();
2546        let edge_uuid: Uuid = edge.id.into();
2547
2548        // An existing edge used as an annotates source: wrong kind, not absent.
2549        let result = rt
2550            .link(None, edge_uuid, a.id, EdgeRelation::Annotates, 1.0)
2551            .await;
2552        match result {
2553            Err(RuntimeError::InvalidInput(msg)) => {
2554                assert!(
2555                    msg.contains("source") && msg.contains("note"),
2556                    "edge-as-annotates-source must report wrong kind, not NotFound: {msg}"
2557                );
2558            }
2559            other => panic!("expected InvalidInput for edge source with Annotates, got {other:?}"),
2560        }
2561    }
2562
2563    // Test #5: note→event with annotates succeeds (event is a valid annotates target).
2564    #[tokio::test]
2565    async fn link_note_to_event_annotates_succeeds() {
2566        use khive_storage::Event;
2567        use khive_types::SubstrateKind;
2568
2569        let rt = rt();
2570        let note = rt
2571            .create_note(
2572                None,
2573                "observation",
2574                None,
2575                "observing an event",
2576                0.6,
2577                None,
2578                vec![],
2579            )
2580            .await
2581            .unwrap();
2582
2583        // Build an event directly via the store (no runtime create_event exists).
2584        let ns = rt.ns(None);
2585        let event = Event::new(ns, "test_verb", SubstrateKind::Entity, "test_actor");
2586        let event_id = event.id;
2587        rt.events(None).unwrap().append_event(event).await.unwrap();
2588
2589        let result = rt
2590            .link(None, note.id, event_id, EdgeRelation::Annotates, 1.0)
2591            .await;
2592        assert!(
2593            result.is_ok(),
2594            "note→event Annotates must succeed, got {result:?}"
2595        );
2596    }
2597
2598    // Test #6: create_note with event as annotates target succeeds.
2599    #[tokio::test]
2600    async fn create_note_annotates_event_succeeds() {
2601        use khive_storage::Event;
2602        use khive_types::SubstrateKind;
2603
2604        let rt = rt();
2605        let ns = rt.ns(None);
2606        let event = Event::new(ns, "test_verb", SubstrateKind::Entity, "test_actor");
2607        let event_id = event.id;
2608        rt.events(None).unwrap().append_event(event).await.unwrap();
2609
2610        let result = rt
2611            .create_note(
2612                None,
2613                "observation",
2614                None,
2615                "note annotating an event",
2616                0.5,
2617                None,
2618                vec![event_id],
2619            )
2620            .await;
2621        assert!(
2622            result.is_ok(),
2623            "create_note with event annotates target must succeed, got {result:?}"
2624        );
2625        // Verify the annotates edge was created.
2626        let note = result.unwrap();
2627        let neighbors = rt
2628            .neighbors(
2629                None,
2630                note.id,
2631                Direction::Out,
2632                None,
2633                Some(vec![EdgeRelation::Annotates]),
2634            )
2635            .await
2636            .unwrap();
2637        assert_eq!(neighbors.len(), 1);
2638        assert_eq!(neighbors[0].node_id, event_id);
2639    }
2640
2641    // ---- Round-4 tests: supersedes same-substrate contract (ADR-019/ADR-024) ----
2642
2643    // Headline regression: note→note supersedes must succeed (was wrongly rejected before this fix).
2644    #[tokio::test]
2645    async fn link_supersedes_note_to_note_succeeds() {
2646        let rt = rt();
2647        let old_note = rt
2648            .create_note(
2649                None,
2650                "observation",
2651                None,
2652                "old observation",
2653                0.7,
2654                None,
2655                vec![],
2656            )
2657            .await
2658            .unwrap();
2659        let new_note = rt
2660            .create_note(
2661                None,
2662                "observation",
2663                None,
2664                "revised observation superseding the old one",
2665                0.9,
2666                None,
2667                vec![],
2668            )
2669            .await
2670            .unwrap();
2671
2672        let result = rt
2673            .link(
2674                None,
2675                new_note.id,
2676                old_note.id,
2677                EdgeRelation::Supersedes,
2678                1.0,
2679            )
2680            .await;
2681        assert!(
2682            result.is_ok(),
2683            "note→note Supersedes must succeed (ADR-019 note supersession), got {result:?}"
2684        );
2685    }
2686
2687    #[tokio::test]
2688    async fn link_supersedes_entity_to_entity_succeeds() {
2689        let rt = rt();
2690        let old_entity = rt
2691            .create_entity(None, "concept", "OldConcept", None, None, vec![])
2692            .await
2693            .unwrap();
2694        let new_entity = rt
2695            .create_entity(None, "concept", "NewConcept", None, None, vec![])
2696            .await
2697            .unwrap();
2698
2699        let result = rt
2700            .link(
2701                None,
2702                new_entity.id,
2703                old_entity.id,
2704                EdgeRelation::Supersedes,
2705                1.0,
2706            )
2707            .await;
2708        assert!(
2709            result.is_ok(),
2710            "entity→entity Supersedes must succeed, got {result:?}"
2711        );
2712    }
2713
2714    #[tokio::test]
2715    async fn link_supersedes_note_to_entity_returns_invalid_input() {
2716        let rt = rt();
2717        let note = rt
2718            .create_note(None, "observation", None, "a note", 0.5, None, vec![])
2719            .await
2720            .unwrap();
2721        let entity = rt
2722            .create_entity(None, "concept", "SomeEntity", None, None, vec![])
2723            .await
2724            .unwrap();
2725
2726        let result = rt
2727            .link(None, note.id, entity.id, EdgeRelation::Supersedes, 1.0)
2728            .await;
2729        match result {
2730            Err(RuntimeError::InvalidInput(msg)) => {
2731                assert!(
2732                    msg.contains("same substrate") || msg.contains("same-substrate"),
2733                    "error must name the same-substrate rule: {msg}"
2734                );
2735            }
2736            other => panic!(
2737                "expected InvalidInput for note→entity Supersedes (cross-substrate), got {other:?}"
2738            ),
2739        }
2740    }
2741
2742    #[tokio::test]
2743    async fn link_supersedes_entity_to_note_returns_invalid_input() {
2744        let rt = rt();
2745        let entity = rt
2746            .create_entity(None, "concept", "SomeEntity", None, None, vec![])
2747            .await
2748            .unwrap();
2749        let note = rt
2750            .create_note(None, "observation", None, "a note", 0.5, None, vec![])
2751            .await
2752            .unwrap();
2753
2754        let result = rt
2755            .link(None, entity.id, note.id, EdgeRelation::Supersedes, 1.0)
2756            .await;
2757        match result {
2758            Err(RuntimeError::InvalidInput(msg)) => {
2759                assert!(
2760                    msg.contains("same substrate") || msg.contains("same-substrate"),
2761                    "error must name the same-substrate rule: {msg}"
2762                );
2763            }
2764            other => panic!(
2765                "expected InvalidInput for entity→note Supersedes (cross-substrate), got {other:?}"
2766            ),
2767        }
2768    }
2769
2770    #[tokio::test]
2771    async fn link_supersedes_event_source_returns_invalid_input() {
2772        use khive_storage::Event;
2773        use khive_types::SubstrateKind;
2774
2775        let rt = rt();
2776        let ns = rt.ns(None);
2777        let event = Event::new(ns, "test_verb", SubstrateKind::Entity, "test_actor");
2778        let event_id = event.id;
2779        rt.events(None).unwrap().append_event(event).await.unwrap();
2780
2781        let entity = rt
2782            .create_entity(None, "concept", "SomeEntity", None, None, vec![])
2783            .await
2784            .unwrap();
2785
2786        let result = rt
2787            .link(None, event_id, entity.id, EdgeRelation::Supersedes, 1.0)
2788            .await;
2789        match result {
2790            Err(RuntimeError::InvalidInput(msg)) => {
2791                assert!(msg.contains("event"), "error must mention 'event': {msg}");
2792            }
2793            other => {
2794                panic!("expected InvalidInput for event source with Supersedes, got {other:?}")
2795            }
2796        }
2797    }
2798
2799    #[tokio::test]
2800    async fn link_supersedes_event_target_returns_invalid_input() {
2801        use khive_storage::Event;
2802        use khive_types::SubstrateKind;
2803
2804        let rt = rt();
2805        let ns = rt.ns(None);
2806        let event = Event::new(ns, "test_verb", SubstrateKind::Entity, "test_actor");
2807        let event_id = event.id;
2808        rt.events(None).unwrap().append_event(event).await.unwrap();
2809
2810        let entity = rt
2811            .create_entity(None, "concept", "SomeEntity", None, None, vec![])
2812            .await
2813            .unwrap();
2814
2815        let result = rt
2816            .link(None, entity.id, event_id, EdgeRelation::Supersedes, 1.0)
2817            .await;
2818        match result {
2819            Err(RuntimeError::InvalidInput(msg)) => {
2820                assert!(msg.contains("event"), "error must mention 'event': {msg}");
2821            }
2822            other => {
2823                panic!("expected InvalidInput for event target with Supersedes, got {other:?}")
2824            }
2825        }
2826    }
2827
2828    #[tokio::test]
2829    async fn link_supersedes_edge_source_returns_invalid_input() {
2830        let rt = rt();
2831        let a = rt
2832            .create_entity(None, "concept", "A", None, None, vec![])
2833            .await
2834            .unwrap();
2835        let b = rt
2836            .create_entity(None, "concept", "B", None, None, vec![])
2837            .await
2838            .unwrap();
2839        let edge = rt
2840            .link(None, a.id, b.id, EdgeRelation::Extends, 1.0)
2841            .await
2842            .unwrap();
2843        let edge_uuid: Uuid = edge.id.into();
2844
2845        let result = rt
2846            .link(None, edge_uuid, a.id, EdgeRelation::Supersedes, 1.0)
2847            .await;
2848        match result {
2849            Err(RuntimeError::InvalidInput(msg)) => {
2850                assert!(msg.contains("source"), "error must name 'source': {msg}");
2851            }
2852            other => {
2853                panic!("expected InvalidInput for edge-uuid source with Supersedes, got {other:?}")
2854            }
2855        }
2856    }
2857
2858    #[tokio::test]
2859    async fn link_supersedes_edge_target_returns_invalid_input() {
2860        let rt = rt();
2861        let a = rt
2862            .create_entity(None, "concept", "A", None, None, vec![])
2863            .await
2864            .unwrap();
2865        let b = rt
2866            .create_entity(None, "concept", "B", None, None, vec![])
2867            .await
2868            .unwrap();
2869        let edge = rt
2870            .link(None, a.id, b.id, EdgeRelation::Extends, 1.0)
2871            .await
2872            .unwrap();
2873        let edge_uuid: Uuid = edge.id.into();
2874
2875        let result = rt
2876            .link(None, a.id, edge_uuid, EdgeRelation::Supersedes, 1.0)
2877            .await;
2878        match result {
2879            Err(RuntimeError::InvalidInput(msg)) => {
2880                assert!(msg.contains("target"), "error must name 'target': {msg}");
2881            }
2882            other => {
2883                panic!("expected InvalidInput for edge-uuid target with Supersedes, got {other:?}")
2884            }
2885        }
2886    }
2887
2888    #[tokio::test]
2889    async fn link_supersedes_phantom_source_returns_not_found() {
2890        let rt = rt();
2891        let note = rt
2892            .create_note(
2893                None,
2894                "observation",
2895                None,
2896                "existing note",
2897                0.5,
2898                None,
2899                vec![],
2900            )
2901            .await
2902            .unwrap();
2903        let phantom = Uuid::new_v4();
2904
2905        let result = rt
2906            .link(None, phantom, note.id, EdgeRelation::Supersedes, 1.0)
2907            .await;
2908        match result {
2909            Err(RuntimeError::NotFound(msg)) => {
2910                assert!(msg.contains("source"), "error must name 'source': {msg}");
2911            }
2912            other => panic!("expected NotFound for phantom source with Supersedes, got {other:?}"),
2913        }
2914    }
2915
2916    #[tokio::test]
2917    async fn link_supersedes_phantom_target_returns_not_found() {
2918        let rt = rt();
2919        let note = rt
2920            .create_note(
2921                None,
2922                "observation",
2923                None,
2924                "existing note",
2925                0.5,
2926                None,
2927                vec![],
2928            )
2929            .await
2930            .unwrap();
2931        let phantom = Uuid::new_v4();
2932
2933        let result = rt
2934            .link(None, note.id, phantom, EdgeRelation::Supersedes, 1.0)
2935            .await;
2936        match result {
2937            Err(RuntimeError::NotFound(msg)) => {
2938                assert!(msg.contains("target"), "error must name 'target': {msg}");
2939            }
2940            other => panic!("expected NotFound for phantom target with Supersedes, got {other:?}"),
2941        }
2942    }
2943
2944    #[tokio::test]
2945    async fn link_supersedes_cross_namespace_source_returns_not_found() {
2946        let rt = rt();
2947        let note_a = rt
2948            .create_note(
2949                Some("ns-a"),
2950                "observation",
2951                None,
2952                "note in ns-a",
2953                0.5,
2954                None,
2955                vec![],
2956            )
2957            .await
2958            .unwrap();
2959        let note_b = rt
2960            .create_note(
2961                Some("ns-b"),
2962                "observation",
2963                None,
2964                "note in ns-b",
2965                0.5,
2966                None,
2967                vec![],
2968            )
2969            .await
2970            .unwrap();
2971
2972        // From ns-a perspective, note_b is in a different namespace — treated as not found.
2973        let result = rt
2974            .link(
2975                Some("ns-a"),
2976                note_b.id,
2977                note_a.id,
2978                EdgeRelation::Supersedes,
2979                1.0,
2980            )
2981            .await;
2982        assert!(
2983            matches!(result, Err(RuntimeError::NotFound(_))),
2984            "cross-namespace source with Supersedes must return NotFound (fail-closed), got {result:?}"
2985        );
2986    }
2987
2988    // Sanity: extends (non-annotates, non-supersedes) still requires entity→entity.
2989    #[tokio::test]
2990    async fn link_extends_note_source_still_returns_invalid_input() {
2991        let rt = rt();
2992        let note = rt
2993            .create_note(
2994                None,
2995                "observation",
2996                None,
2997                "a note that cannot be an extends source",
2998                0.5,
2999                None,
3000                vec![],
3001            )
3002            .await
3003            .unwrap();
3004        let entity = rt
3005            .create_entity(None, "concept", "E", None, None, vec![])
3006            .await
3007            .unwrap();
3008
3009        let result = rt
3010            .link(None, note.id, entity.id, EdgeRelation::Extends, 1.0)
3011            .await;
3012        assert!(
3013            matches!(result, Err(RuntimeError::InvalidInput(_))),
3014            "note source with Extends must still return InvalidInput after this fix, got {result:?}"
3015        );
3016    }
3017
3018    // Sanity: annotates note→edge still succeeds (unchanged path not broken by this fix).
3019    #[tokio::test]
3020    async fn link_annotates_note_to_edge_still_succeeds_after_fix() {
3021        let rt = rt();
3022        let a = rt
3023            .create_entity(None, "concept", "A", None, None, vec![])
3024            .await
3025            .unwrap();
3026        let b = rt
3027            .create_entity(None, "concept", "B", None, None, vec![])
3028            .await
3029            .unwrap();
3030        let edge = rt
3031            .link(None, a.id, b.id, EdgeRelation::Extends, 1.0)
3032            .await
3033            .unwrap();
3034        let edge_uuid: Uuid = edge.id.into();
3035
3036        let note = rt
3037            .create_note(
3038                None,
3039                "observation",
3040                None,
3041                "annotating an edge",
3042                0.5,
3043                None,
3044                vec![],
3045            )
3046            .await
3047            .unwrap();
3048
3049        let result = rt
3050            .link(None, note.id, edge_uuid, EdgeRelation::Annotates, 1.0)
3051            .await;
3052        assert!(
3053            result.is_ok(),
3054            "note→edge Annotates must still succeed after supersedes fix, got {result:?}"
3055        );
3056    }
3057
3058    // ---- Compensation-path rollback (fix/annotates) ----
3059
3060    // The compensation branch in `create_note_inner` (operations.rs) rolls back
3061    // a partial write — note row + first edge + FTS + vector — when a subsequent
3062    // link call fails. The failure trigger is a storage error (e.g. I/O failure)
3063    // that cannot occur in the in-memory runtime; this test instead exercises the
3064    // exact cleanup operations that the compensation branch performs, starting from
3065    // a manually-constructed partial state, and verifies the post-cleanup invariants.
3066    //
3067    // What this covers: the cleanup sequence (delete_edge, delete_note hard, FTS
3068    // index clean) is correct and leaves the DB in a pristine state. What it does
3069    // not cover: the trigger condition (second link failure). Storage-error injection
3070    // would require a mock GraphStore, which is beyond the current test infrastructure.
3071    #[tokio::test]
3072    async fn create_note_multi_annotates_compensation_cleanup_restores_pristine_state() {
3073        let rt = rt();
3074        let t1 = rt
3075            .create_entity(None, "concept", "T1", None, None, vec![])
3076            .await
3077            .unwrap();
3078
3079        // Construct the partial state that the compensation branch would encounter:
3080        // note persisted + first annotates edge created.
3081        let note = rt
3082            .create_note(
3083                None,
3084                "observation",
3085                None,
3086                "partial note",
3087                0.5,
3088                None,
3089                vec![t1.id],
3090            )
3091            .await
3092            .unwrap();
3093
3094        // Confirm the partial state exists before compensation.
3095        let before_notes = rt.list_notes(None, None, 1000, 0).await.unwrap();
3096        assert_eq!(before_notes.len(), 1, "note must be present before cleanup");
3097        let before_edges = rt
3098            .neighbors(
3099                None,
3100                note.id,
3101                Direction::Out,
3102                None,
3103                Some(vec![EdgeRelation::Annotates]),
3104            )
3105            .await
3106            .unwrap();
3107        assert_eq!(
3108            before_edges.len(),
3109            1,
3110            "one annotates edge must exist before cleanup"
3111        );
3112        let edge_id: Uuid = before_edges[0].edge_id;
3113
3114        // Execute the same cleanup sequence that `create_note_inner`'s Err branch runs.
3115        rt.delete_edge(None, edge_id).await.unwrap();
3116        rt.delete_note(None, note.id, true /* hard */)
3117            .await
3118            .unwrap();
3119
3120        // Post-compensation invariants:
3121        let after_notes = rt.list_notes(None, None, 1000, 0).await.unwrap();
3122        assert!(
3123            after_notes.is_empty(),
3124            "compensation must remove the note row; got {after_notes:?}"
3125        );
3126        let search_hits = rt
3127            .search_notes(None, "partial note", None, 10, None)
3128            .await
3129            .unwrap();
3130        assert!(
3131            search_hits.is_empty(),
3132            "compensation must clean the FTS index; got {search_hits:?}"
3133        );
3134        let after_edges = rt
3135            .neighbors(None, note.id, Direction::Out, None, None)
3136            .await
3137            .unwrap();
3138        assert!(
3139            after_edges.is_empty(),
3140            "compensation must remove all partial edges; got {after_edges:?}"
3141        );
3142    }
3143
3144    // ---- Hard-delete cascade for note and edge annotation targets (fix/annotates) ----
3145
3146    // ADR-002:73 — annotates is note → ANYTHING (entity, note, edge, event).
3147    // ADR-024:103 — targets may be entity, edge, event, or note.
3148    // Hard-deleting any of those targets must cascade incident annotates edges.
3149    // Soft deletes leave edges (data-vs-view rule).
3150
3151    #[tokio::test]
3152    async fn annotated_entity_hard_delete_cascades_annotate_edge() {
3153        let rt = rt();
3154        let entity = rt
3155            .create_entity(None, "concept", "E", None, None, vec![])
3156            .await
3157            .unwrap();
3158        let note = rt
3159            .create_note(
3160                None,
3161                "observation",
3162                None,
3163                "note about entity",
3164                0.5,
3165                None,
3166                vec![entity.id],
3167            )
3168            .await
3169            .unwrap();
3170
3171        // Confirm edge exists before delete.
3172        let before = rt
3173            .neighbors(
3174                None,
3175                note.id,
3176                Direction::Out,
3177                None,
3178                Some(vec![EdgeRelation::Annotates]),
3179            )
3180            .await
3181            .unwrap();
3182        assert_eq!(
3183            before.len(),
3184            1,
3185            "annotates edge must exist before entity delete"
3186        );
3187
3188        // Hard delete the entity.
3189        let deleted = rt.delete_entity(None, entity.id, true).await.unwrap();
3190        assert!(deleted, "entity hard delete must return true");
3191
3192        // Annotates edge must be gone.
3193        let after = rt
3194            .neighbors(
3195                None,
3196                note.id,
3197                Direction::Out,
3198                None,
3199                Some(vec![EdgeRelation::Annotates]),
3200            )
3201            .await
3202            .unwrap();
3203        assert!(
3204            after.is_empty(),
3205            "annotates edge must be cascaded on entity hard delete; got {after:?}"
3206        );
3207    }
3208
3209    #[tokio::test]
3210    async fn annotated_note_hard_delete_cascades_annotate_edge() {
3211        let rt = rt();
3212        // note_target is the thing being annotated (a note itself).
3213        let note_target = rt
3214            .create_note(None, "observation", None, "target note", 0.5, None, vec![])
3215            .await
3216            .unwrap();
3217        // note_source annotates note_target.
3218        let note_source = rt
3219            .create_note(
3220                None,
3221                "insight",
3222                None,
3223                "annotation",
3224                0.5,
3225                None,
3226                vec![note_target.id],
3227            )
3228            .await
3229            .unwrap();
3230
3231        let before = rt
3232            .neighbors(
3233                None,
3234                note_source.id,
3235                Direction::Out,
3236                None,
3237                Some(vec![EdgeRelation::Annotates]),
3238            )
3239            .await
3240            .unwrap();
3241        assert_eq!(
3242            before.len(),
3243            1,
3244            "annotates edge must exist before note delete"
3245        );
3246
3247        // Hard delete the annotation TARGET note.
3248        let deleted = rt.delete_note(None, note_target.id, true).await.unwrap();
3249        assert!(deleted, "note hard delete must return true");
3250
3251        // The annotates edge targeting note_target must be gone.
3252        let after = rt
3253            .neighbors(
3254                None,
3255                note_source.id,
3256                Direction::Out,
3257                None,
3258                Some(vec![EdgeRelation::Annotates]),
3259            )
3260            .await
3261            .unwrap();
3262        assert!(
3263            after.is_empty(),
3264            "annotates edge must be cascaded on note-target hard delete; got {after:?}"
3265        );
3266    }
3267
3268    #[tokio::test]
3269    async fn annotated_edge_delete_cascades_annotate_edge() {
3270        let rt = rt();
3271        let a = rt
3272            .create_entity(None, "concept", "A", None, None, vec![])
3273            .await
3274            .unwrap();
3275        let b = rt
3276            .create_entity(None, "concept", "B", None, None, vec![])
3277            .await
3278            .unwrap();
3279        // Create an edge to annotate.
3280        let base_edge = rt
3281            .link(None, a.id, b.id, EdgeRelation::Extends, 1.0)
3282            .await
3283            .unwrap();
3284        let base_edge_uuid: Uuid = base_edge.id.into();
3285
3286        // Create a note that annotates the edge.
3287        let note = rt
3288            .create_note(
3289                None,
3290                "observation",
3291                None,
3292                "note about edge",
3293                0.5,
3294                None,
3295                vec![base_edge_uuid],
3296            )
3297            .await
3298            .unwrap();
3299
3300        let before = rt
3301            .neighbors(
3302                None,
3303                note.id,
3304                Direction::Out,
3305                None,
3306                Some(vec![EdgeRelation::Annotates]),
3307            )
3308            .await
3309            .unwrap();
3310        assert_eq!(
3311            before.len(),
3312            1,
3313            "annotates edge must exist before base edge delete"
3314        );
3315
3316        // Delete the base edge.
3317        let deleted = rt.delete_edge(None, base_edge_uuid).await.unwrap();
3318        assert!(deleted, "edge delete must return true");
3319
3320        // The annotates edge targeting base_edge must be gone.
3321        let after = rt
3322            .neighbors(
3323                None,
3324                note.id,
3325                Direction::Out,
3326                None,
3327                Some(vec![EdgeRelation::Annotates]),
3328            )
3329            .await
3330            .unwrap();
3331        assert!(
3332            after.is_empty(),
3333            "annotates edge must be cascaded on base edge delete; got {after:?}"
3334        );
3335    }
3336
3337    #[tokio::test]
3338    async fn mixed_multi_annotates_partial_target_hard_delete_leaves_remaining_edges() {
3339        let rt = rt();
3340        let t1 = rt
3341            .create_entity(None, "concept", "T1", None, None, vec![])
3342            .await
3343            .unwrap();
3344        let t2 = rt
3345            .create_entity(None, "concept", "T2", None, None, vec![])
3346            .await
3347            .unwrap();
3348
3349        // Note annotates both t1 and t2.
3350        let note = rt
3351            .create_note(
3352                None,
3353                "observation",
3354                None,
3355                "multi-target note",
3356                0.5,
3357                None,
3358                vec![t1.id, t2.id],
3359            )
3360            .await
3361            .unwrap();
3362
3363        let before = rt
3364            .neighbors(
3365                None,
3366                note.id,
3367                Direction::Out,
3368                None,
3369                Some(vec![EdgeRelation::Annotates]),
3370            )
3371            .await
3372            .unwrap();
3373        assert_eq!(
3374            before.len(),
3375            2,
3376            "must have 2 annotates edges before any delete"
3377        );
3378
3379        // Hard delete only t1.
3380        rt.delete_entity(None, t1.id, true).await.unwrap();
3381
3382        // Edge to t1 must be gone, edge to t2 must remain.
3383        let after = rt
3384            .neighbors(
3385                None,
3386                note.id,
3387                Direction::Out,
3388                None,
3389                Some(vec![EdgeRelation::Annotates]),
3390            )
3391            .await
3392            .unwrap();
3393        assert_eq!(
3394            after.len(),
3395            1,
3396            "only the edge to t1 must be cascaded; t2 edge must remain"
3397        );
3398        assert_eq!(
3399            after[0].node_id, t2.id,
3400            "remaining annotates edge must point to t2"
3401        );
3402    }
3403
3404    #[tokio::test]
3405    async fn annotated_note_soft_delete_preserves_annotate_edge() {
3406        let rt = rt();
3407        let note_target = rt
3408            .create_note(None, "observation", None, "target", 0.5, None, vec![])
3409            .await
3410            .unwrap();
3411        let note_source = rt
3412            .create_note(
3413                None,
3414                "insight",
3415                None,
3416                "annotation",
3417                0.5,
3418                None,
3419                vec![note_target.id],
3420            )
3421            .await
3422            .unwrap();
3423
3424        let before = rt
3425            .neighbors(
3426                None,
3427                note_source.id,
3428                Direction::Out,
3429                None,
3430                Some(vec![EdgeRelation::Annotates]),
3431            )
3432            .await
3433            .unwrap();
3434        assert_eq!(before.len(), 1);
3435
3436        // Soft delete must NOT cascade edges (data-vs-view principle).
3437        let deleted = rt.delete_note(None, note_target.id, false).await.unwrap();
3438        assert!(deleted, "soft delete must return true");
3439
3440        let after = rt
3441            .neighbors(
3442                None,
3443                note_source.id,
3444                Direction::Out,
3445                None,
3446                Some(vec![EdgeRelation::Annotates]),
3447            )
3448            .await
3449            .unwrap();
3450        assert_eq!(
3451            after.len(),
3452            1,
3453            "soft delete must NOT cascade edges; got {after:?}"
3454        );
3455    }
3456
3457    // ---- delete_edge public-API safety (fix/annotates round-3) ----
3458
3459    // Passing an entity/note UUID to `delete_edge` must return Ok(false) with no
3460    // side effects — it must NOT delete inbound annotates edges targeting that record.
3461    // Without the get_edge guard, the old code would cascade inbound edges before
3462    // returning false.
3463    #[tokio::test]
3464    async fn delete_edge_non_edge_uuid_has_no_side_effects() {
3465        let rt = rt();
3466
3467        // Create an entity that has an inbound annotates edge.
3468        let entity = rt
3469            .create_entity(None, "concept", "Target", None, None, vec![])
3470            .await
3471            .unwrap();
3472        let note = rt
3473            .create_note(
3474                None,
3475                "observation",
3476                None,
3477                "annotates the entity",
3478                0.5,
3479                None,
3480                vec![entity.id],
3481            )
3482            .await
3483            .unwrap();
3484
3485        // Confirm the annotates edge exists.
3486        let before = rt
3487            .neighbors(
3488                None,
3489                note.id,
3490                Direction::Out,
3491                None,
3492                Some(vec![EdgeRelation::Annotates]),
3493            )
3494            .await
3495            .unwrap();
3496        assert_eq!(before.len(), 1, "annotates edge must exist before test");
3497        let annotates_edge_id: Uuid = before[0].edge_id;
3498
3499        // Call delete_edge with the entity UUID (NOT an edge UUID).
3500        let result = rt.delete_edge(None, entity.id).await;
3501        assert!(
3502            result.is_ok(),
3503            "delete_edge must not error on a non-edge UUID"
3504        );
3505        assert!(
3506            !result.unwrap(),
3507            "delete_edge must return false for a non-edge UUID"
3508        );
3509
3510        // The inbound annotates edge to the entity must still exist — no side effects.
3511        let after = rt
3512            .neighbors(
3513                None,
3514                note.id,
3515                Direction::Out,
3516                None,
3517                Some(vec![EdgeRelation::Annotates]),
3518            )
3519            .await
3520            .unwrap();
3521        assert_eq!(
3522            after.len(),
3523            1,
3524            "delete_edge with a non-edge UUID must not touch inbound annotates edges"
3525        );
3526        assert_eq!(
3527            after[0].edge_id, annotates_edge_id,
3528            "the original annotates edge must be unchanged"
3529        );
3530    }
3531
3532    // ---- create_note compensation branch (fix/annotates round-3) ----
3533
3534    // This test injects a deterministic failure on the second `link` call inside
3535    // `create_note_inner` (the one that would create the second annotates edge).
3536    // It verifies that the compensation branch is wired — i.e. this test would
3537    // fail if the `Err(e)` rollback arm at operations.rs were deleted.
3538    //
3539    // Injection mechanism: LINK_FAIL_AFTER thread-local (ops.rs, cfg(test) only).
3540    // Setting it to 2 forces the 2nd link call to return an error.  The counter is
3541    // reset to 0 once triggered, so no other test is affected.
3542    #[tokio::test]
3543    async fn create_note_multi_annotates_second_link_failure_rolls_back_partial_write() {
3544        let rt = rt();
3545        let t1 = rt
3546            .create_entity(None, "concept", "T1", None, None, vec![])
3547            .await
3548            .unwrap();
3549        let t2 = rt
3550            .create_entity(None, "concept", "T2", None, None, vec![])
3551            .await
3552            .unwrap();
3553
3554        // Arm the injection: fail on the 2nd link (link_idx+1 == 2).
3555        LINK_FAIL_AFTER.with(|cell| cell.set(2));
3556
3557        let result = rt
3558            .create_note(
3559                None,
3560                "observation",
3561                None,
3562                "rollback target",
3563                0.5,
3564                None,
3565                vec![t1.id, t2.id],
3566            )
3567            .await;
3568
3569        // The call must fail with the injected error.
3570        assert!(
3571            result.is_err(),
3572            "create_note must propagate the injected link failure"
3573        );
3574        let err_msg = result.unwrap_err().to_string();
3575        assert!(
3576            err_msg.contains("injected link failure"),
3577            "error must carry injection message; got: {err_msg}"
3578        );
3579
3580        // Compensation must have removed the note row.
3581        let notes = rt.list_notes(None, None, 1000, 0).await.unwrap();
3582        assert!(
3583            notes.is_empty(),
3584            "compensation must remove the note row; got {notes:?}"
3585        );
3586
3587        // FTS must have no hit for the content.
3588        let hits = rt
3589            .search_notes(None, "rollback target", None, 10, None)
3590            .await
3591            .unwrap();
3592        assert!(
3593            hits.is_empty(),
3594            "compensation must clean FTS index; got {hits:?}"
3595        );
3596
3597        // No partial annotates edges must remain (first edge must have been deleted).
3598        let edges_from_t1 = rt
3599            .neighbors(
3600                None,
3601                t1.id,
3602                Direction::In,
3603                None,
3604                Some(vec![EdgeRelation::Annotates]),
3605            )
3606            .await
3607            .unwrap();
3608        let edges_from_t2 = rt
3609            .neighbors(
3610                None,
3611                t2.id,
3612                Direction::In,
3613                None,
3614                Some(vec![EdgeRelation::Annotates]),
3615            )
3616            .await
3617            .unwrap();
3618        assert!(
3619            edges_from_t1.is_empty(),
3620            "compensation must delete the first annotates edge; got {edges_from_t1:?}"
3621        );
3622        assert!(
3623            edges_from_t2.is_empty(),
3624            "no second annotates edge must exist; got {edges_from_t2:?}"
3625        );
3626    }
3627
3628    // ---- #232 soft-delete index cleanup tests ----
3629
3630    #[tokio::test]
3631    async fn soft_delete_entity_removes_indexes() {
3632        let rt = rt();
3633        let entity = rt
3634            .create_entity(
3635                None,
3636                "concept",
3637                "QuantumEntanglement",
3638                Some("unique FTS term xzqjwv for soft delete test"),
3639                None,
3640                vec![],
3641            )
3642            .await
3643            .unwrap();
3644
3645        let ns = rt.ns(None).to_string();
3646
3647        let before = rt
3648            .text(None)
3649            .unwrap()
3650            .search(TextSearchRequest {
3651                query: "xzqjwv".to_string(),
3652                mode: TextQueryMode::Plain,
3653                filter: Some(TextFilter {
3654                    namespaces: vec![ns.clone()],
3655                    ..Default::default()
3656                }),
3657                top_k: 10,
3658                snippet_chars: 100,
3659            })
3660            .await
3661            .unwrap();
3662        assert!(
3663            before.iter().any(|h| h.subject_id == entity.id),
3664            "entity must be in FTS before soft-delete"
3665        );
3666
3667        let deleted = rt.delete_entity(None, entity.id, false).await.unwrap();
3668        assert!(deleted, "soft delete must return true");
3669
3670        let after = rt
3671            .text(None)
3672            .unwrap()
3673            .search(TextSearchRequest {
3674                query: "xzqjwv".to_string(),
3675                mode: TextQueryMode::Plain,
3676                filter: Some(TextFilter {
3677                    namespaces: vec![ns],
3678                    ..Default::default()
3679                }),
3680                top_k: 10,
3681                snippet_chars: 100,
3682            })
3683            .await
3684            .unwrap();
3685        assert!(
3686            after.iter().all(|h| h.subject_id != entity.id),
3687            "soft-deleted entity must be removed from FTS index"
3688        );
3689    }
3690
3691    #[tokio::test]
3692    async fn soft_delete_note_removes_indexes() {
3693        let rt = rt();
3694        let note = rt
3695            .create_note(
3696                None,
3697                "observation",
3698                None,
3699                "SpectralDecomposition unique term yvwkqz for soft delete test",
3700                0.7,
3701                None,
3702                vec![],
3703            )
3704            .await
3705            .unwrap();
3706
3707        let before = rt
3708            .search_notes(None, "yvwkqz", None, 10, None)
3709            .await
3710            .unwrap();
3711        assert!(
3712            before.iter().any(|h| h.note_id == note.id),
3713            "note must be in FTS before soft-delete"
3714        );
3715
3716        let deleted = rt.delete_note(None, note.id, false).await.unwrap();
3717        assert!(deleted, "soft delete must return true");
3718
3719        let after = rt
3720            .search_notes(None, "yvwkqz", None, 10, None)
3721            .await
3722            .unwrap();
3723        assert!(
3724            after.iter().all(|h| h.note_id != note.id),
3725            "soft-deleted note must be removed from FTS index"
3726        );
3727    }
3728}