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::DeterministicScore;
10use khive_storage::note::Note;
11use khive_storage::types::{
12    DeleteMode, Direction, EdgeSortField, GraphPath, LinkId, NeighborHit, NeighborQuery, Page,
13    PageRequest, SortOrder, SqlRow, SqlStatement, SqlValue, TextDocument, TextFilter,
14    TextQueryMode, TextSearchRequest, TraversalRequest,
15};
16use khive_storage::{Edge, EdgeRelation, Entity, EntityFilter, Event, EventFilter};
17use khive_types::{EdgeEndpointRule, EndpointKind, EventKind, SubstrateKind};
18
19use khive_db::SqliteError;
20use rusqlite::OptionalExtension;
21
22use crate::error::{RuntimeError, RuntimeResult};
23use crate::runtime::{KhiveRuntime, NamespaceToken};
24
25// Test-only failure injection for `create_note_inner`.
26//
27// A test sets `LINK_FAIL_AFTER` to N > 0 before calling `create_note`.  The
28// Nth `link` call inside the loop returns `RuntimeError::Internal("injected
29// link failure")` instead of calling the real implementation.  The counter is
30// reset to 0 after each call regardless of whether it triggered, so tests are
31// isolated from one another.
32#[cfg(test)]
33std::thread_local! {
34    static LINK_FAIL_AFTER: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
35}
36
37/// A note search result with UUID, salience-weighted RRF score, and display text.
38#[derive(Clone, Debug)]
39pub struct NoteSearchHit {
40    pub note_id: Uuid,
41    pub score: DeterministicScore,
42    pub title: Option<String>,
43    pub snippet: Option<String>,
44}
45
46fn text_preview(text: &str, max_chars: usize) -> Option<String> {
47    let trimmed = text.trim();
48    if trimmed.is_empty() {
49        None
50    } else {
51        Some(trimmed.chars().take(max_chars).collect())
52    }
53}
54
55/// ADR-002: symmetric relations (`competes_with`, `composed_with`) are stored
56/// with a canonical source (lower UUID wins), so a directed `Out` or `In` query
57/// may miss results. When the relations filter is non-empty and contains **only**
58/// symmetric relations, override direction to `Both` so callers always see all
59/// edges for these relations regardless of storage canonicalization.
60fn normalize_symmetric_direction(
61    direction: Direction,
62    relations: Option<&[EdgeRelation]>,
63) -> Direction {
64    let Some(rels) = relations else {
65        return direction;
66    };
67    if rels.is_empty() {
68        return direction;
69    }
70    let all_symmetric = rels
71        .iter()
72        .all(|r| matches!(r, EdgeRelation::CompetesWith | EdgeRelation::ComposedWith));
73    if all_symmetric {
74        Direction::Both
75    } else {
76        direction
77    }
78}
79
80fn note_title(note: &Note) -> Option<String> {
81    note.name
82        .clone()
83        .filter(|s| !s.trim().is_empty())
84        .or_else(|| Some(format!("[{}]", note.kind.as_str())))
85}
86
87fn note_snippet(note: &Note) -> Option<String> {
88    text_preview(&note.content, 200)
89}
90
91/// Result of resolving a UUID to its substrate kind.
92#[derive(Clone, Debug)]
93pub enum Resolved {
94    Entity(Entity),
95    Note(Note),
96    Event(Event),
97}
98
99/// Map a resolved endpoint to its `(substrate, kind)` pair, or `None` if
100/// the substrate is not a valid edge endpoint (events, edges).
101fn resolved_pair(r: Option<&Resolved>) -> Option<(&'static str, &str)> {
102    match r? {
103        Resolved::Entity(e) => Some(("entity", e.kind.as_str())),
104        Resolved::Note(n) => Some(("note", n.kind.as_str())),
105        Resolved::Event(_) => None,
106    }
107}
108
109/// `true` if `spec` matches the given substrate + kind pair.
110fn endpoint_matches(spec: &EndpointKind, substrate: &str, kind: &str) -> bool {
111    match spec {
112        EndpointKind::EntityOfKind(k) => substrate == "entity" && *k == kind,
113        EndpointKind::NoteOfKind(k) => substrate == "note" && *k == kind,
114    }
115}
116
117/// `true` if any pack-declared edge endpoint rule allows the
118/// `(source, relation, target)` triple. ADR-031: rules are additive only.
119fn pack_rule_allows(
120    rules: &[EdgeEndpointRule],
121    relation: EdgeRelation,
122    src: Option<&Resolved>,
123    tgt: Option<&Resolved>,
124) -> bool {
125    let Some((src_sub, src_kind)) = resolved_pair(src) else {
126        return false;
127    };
128    let Some((tgt_sub, tgt_kind)) = resolved_pair(tgt) else {
129        return false;
130    };
131    rules.iter().any(|r| {
132        r.relation == relation
133            && endpoint_matches(&r.source, src_sub, src_kind)
134            && endpoint_matches(&r.target, tgt_sub, tgt_kind)
135    })
136}
137
138/// ADR-002 base endpoint allowlist for entity→entity relations.
139///
140/// Returns `true` if `(src_kind, relation, tgt_kind)` is an explicitly listed
141/// triple in the ADR-002 base contract. `"*"` as `src_kind` means "any entity
142/// kind" (used for `instance_of` whose source is unrestricted).
143///
144/// Pack rules (via `EDGE_RULES`) are additive — they cannot remove rows here.
145fn base_entity_rule_allows(src_kind: &str, relation: EdgeRelation, tgt_kind: &str) -> bool {
146    const RULES: &[(&str, EdgeRelation, &str)] = &[
147        // Structure
148        ("concept", EdgeRelation::Contains, "concept"),
149        ("project", EdgeRelation::Contains, "project"),
150        ("project", EdgeRelation::Contains, "artifact"),
151        ("org", EdgeRelation::Contains, "project"),
152        ("org", EdgeRelation::Contains, "service"),
153        ("concept", EdgeRelation::PartOf, "concept"),
154        ("project", EdgeRelation::PartOf, "project"),
155        ("project", EdgeRelation::PartOf, "org"),
156        ("*", EdgeRelation::InstanceOf, "concept"),
157        ("service", EdgeRelation::InstanceOf, "project"),
158        // Derivation
159        ("concept", EdgeRelation::Extends, "concept"),
160        ("concept", EdgeRelation::VariantOf, "concept"),
161        ("artifact", EdgeRelation::VariantOf, "artifact"),
162        ("concept", EdgeRelation::IntroducedBy, "document"),
163        ("concept", EdgeRelation::IntroducedBy, "person"),
164        ("artifact", EdgeRelation::IntroducedBy, "document"),
165        // Provenance
166        ("artifact", EdgeRelation::DerivedFrom, "dataset"),
167        ("artifact", EdgeRelation::DerivedFrom, "document"),
168        ("artifact", EdgeRelation::DerivedFrom, "project"),
169        ("artifact", EdgeRelation::DerivedFrom, "artifact"),
170        // Temporal
171        ("document", EdgeRelation::Precedes, "document"),
172        ("dataset", EdgeRelation::Precedes, "dataset"),
173        ("artifact", EdgeRelation::Precedes, "artifact"),
174        ("service", EdgeRelation::Precedes, "service"),
175        ("project", EdgeRelation::Precedes, "project"),
176        // Dependency
177        ("project", EdgeRelation::DependsOn, "project"),
178        ("service", EdgeRelation::DependsOn, "project"),
179        ("service", EdgeRelation::DependsOn, "service"),
180        ("service", EdgeRelation::DependsOn, "artifact"),
181        ("service", EdgeRelation::DependsOn, "dataset"),
182        ("artifact", EdgeRelation::DependsOn, "project"),
183        ("artifact", EdgeRelation::DependsOn, "service"),
184        ("concept", EdgeRelation::Enables, "concept"),
185        ("service", EdgeRelation::Enables, "concept"),
186        ("dataset", EdgeRelation::Enables, "concept"),
187        // Implementation
188        ("project", EdgeRelation::Implements, "concept"),
189        ("service", EdgeRelation::Implements, "concept"),
190        // Lateral
191        ("concept", EdgeRelation::CompetesWith, "concept"),
192        ("project", EdgeRelation::CompetesWith, "project"),
193        ("service", EdgeRelation::CompetesWith, "service"),
194        ("concept", EdgeRelation::ComposedWith, "concept"),
195        ("project", EdgeRelation::ComposedWith, "project"),
196        // Versioning (Supersedes — ADR-002:190-194: Concept/Document/Artifact/Service/Dataset only)
197        ("concept", EdgeRelation::Supersedes, "concept"),
198        ("document", EdgeRelation::Supersedes, "document"),
199        ("artifact", EdgeRelation::Supersedes, "artifact"),
200        ("service", EdgeRelation::Supersedes, "service"),
201        ("dataset", EdgeRelation::Supersedes, "dataset"),
202    ];
203    RULES.iter().any(|(src, rel, tgt)| {
204        *rel == relation && (*src == "*" || *src == src_kind) && *tgt == tgt_kind
205    })
206}
207
208/// Canonical endpoint order for symmetric relations (F012).
209///
210/// For `competes_with` and `composed_with`, normalises direction so that
211/// `source_uuid < target_uuid` (lexicographic on the UUID bytes). This
212/// collapses A→B and B→A into a single canonical row, preventing duplicates.
213pub(crate) fn canonical_edge_endpoints(
214    relation: EdgeRelation,
215    source_id: Uuid,
216    target_id: Uuid,
217) -> (Uuid, Uuid) {
218    if relation.is_symmetric() && target_id < source_id {
219        (target_id, source_id)
220    } else {
221        (source_id, target_id)
222    }
223}
224
225/// Infer the default `dependency_kind` from endpoint entity kinds (ADR-002).
226fn infer_dependency_kind(src_kind: &str, tgt_kind: &str) -> Option<&'static str> {
227    match (src_kind, tgt_kind) {
228        ("project", "project") => Some("build"),
229        ("service", "service") => Some("runtime"),
230        ("service", "dataset") => Some("data"),
231        ("service", "artifact") => Some("artifact"),
232        ("artifact", "project") | ("artifact", "service") => Some("tooling"),
233        _ => None,
234    }
235}
236
237/// Merge an inferred `dependency_kind` into `depends_on` edge metadata.
238///
239/// If `metadata` already carries a `dependency_kind` key the existing value is
240/// preserved. If the key is absent and the endpoint pair has a known default,
241/// the inferred value is added. Returns `metadata` unchanged for all other
242/// cases (no matching default, or metadata already has the key).
243fn merge_dependency_kind(
244    src_kind: &str,
245    tgt_kind: &str,
246    metadata: Option<serde_json::Value>,
247) -> Option<serde_json::Value> {
248    if let Some(ref m) = metadata {
249        if m.get("dependency_kind").is_some() {
250            return metadata;
251        }
252    }
253    let inferred = infer_dependency_kind(src_kind, tgt_kind)?;
254    let mut obj = metadata.unwrap_or_else(|| serde_json::json!({}));
255    if let Some(o) = obj.as_object_mut() {
256        o.insert("dependency_kind".to_string(), serde_json::json!(inferred));
257    }
258    Some(obj)
259}
260
261/// Valid `dependency_kind` values for `depends_on` edges (ADR-002).
262const VALID_DEPENDENCY_KINDS: &[&str] = &["build", "runtime", "data", "artifact", "tooling"];
263
264/// Validate governed edge metadata keys (ADR-002 §Edge Metadata).
265///
266/// Currently enforces:
267/// - `dependency_kind` is only valid on `depends_on` edges.
268/// - `dependency_kind`, when present, must be one of the five governed values.
269fn validate_edge_metadata(
270    relation: EdgeRelation,
271    metadata: Option<&serde_json::Value>,
272) -> RuntimeResult<()> {
273    let Some(meta) = metadata else {
274        return Ok(());
275    };
276    if let Some(dk) = meta.get("dependency_kind") {
277        if relation != EdgeRelation::DependsOn {
278            return Err(RuntimeError::InvalidInput(format!(
279                "dependency_kind is only valid on depends_on edges (got {})",
280                relation.as_str()
281            )));
282        }
283        let dk_str = dk
284            .as_str()
285            .ok_or_else(|| RuntimeError::InvalidInput("dependency_kind must be a string".into()))?;
286        if !VALID_DEPENDENCY_KINDS.contains(&dk_str) {
287            return Err(RuntimeError::InvalidInput(format!(
288                "unknown dependency_kind {dk_str:?}; valid: {}",
289                VALID_DEPENDENCY_KINDS.join(" | ")
290            )));
291        }
292    }
293    Ok(())
294}
295
296impl KhiveRuntime {
297    // ---- Entity operations ----
298
299    /// Create and persist a new entity.
300    #[allow(clippy::too_many_arguments)]
301    pub async fn create_entity(
302        &self,
303        token: &NamespaceToken,
304        kind: &str,
305        entity_type: Option<&str>,
306        name: &str,
307        description: Option<&str>,
308        properties: Option<serde_json::Value>,
309        tags: Vec<String>,
310    ) -> RuntimeResult<Entity> {
311        let ns = token.namespace().as_str();
312        let mut entity = Entity::new(ns, kind, name).with_entity_type(entity_type);
313        if let Some(d) = description {
314            entity = entity.with_description(d);
315        }
316        if let Some(p) = properties {
317            entity = entity.with_properties(p);
318        }
319        if !tags.is_empty() {
320            entity = entity.with_tags(tags);
321        }
322        self.entities(token)?.upsert_entity(entity.clone()).await?;
323
324        let body = match &entity.description {
325            Some(d) if !d.is_empty() => format!("{} {}", entity.name, d),
326            _ => entity.name.clone(),
327        };
328        self.text(token)?
329            .upsert_document(TextDocument {
330                subject_id: entity.id,
331                kind: SubstrateKind::Entity,
332                title: Some(entity.name.clone()),
333                body: body.clone(),
334                tags: entity.tags.clone(),
335                namespace: ns.to_string(),
336                metadata: entity.properties.clone(),
337                updated_at: chrono::Utc::now(),
338            })
339            .await?;
340
341        if self.config().embedding_model.is_some() {
342            let vector = self.embed(&body).await?;
343            self.vectors(token)?
344                .insert(
345                    entity.id,
346                    SubstrateKind::Entity,
347                    ns,
348                    "entity.body",
349                    vec![vector],
350                )
351                .await?;
352        }
353
354        Ok(entity)
355    }
356
357    /// Retrieve an entity by ID, enforcing namespace isolation (ADR-007).
358    ///
359    /// Returns `Err(NotFound)` if the entity does not exist or belongs to a
360    /// different namespace (indistinguishable — no cross-namespace existence oracle).
361    pub async fn get_entity(&self, token: &NamespaceToken, id: Uuid) -> RuntimeResult<Entity> {
362        let entity = self
363            .entities(token)?
364            .get_entity(id)
365            .await?
366            .ok_or_else(|| RuntimeError::NotFound("not found in this namespace".into()))?;
367        Self::ensure_namespace(&entity.namespace, token.namespace().as_str())?;
368        Ok(entity)
369    }
370
371    /// Fetch multiple entities by ID, returning only those that exist in the
372    /// caller's namespace.  Missing or namespace-mismatched IDs are silently
373    /// omitted so that batch lookups don't abort on a single stale reference.
374    pub async fn get_entities_by_ids(
375        &self,
376        token: &NamespaceToken,
377        ids: &[Uuid],
378    ) -> RuntimeResult<Vec<Entity>> {
379        if ids.is_empty() {
380            return Ok(vec![]);
381        }
382        let filter = EntityFilter {
383            ids: ids.to_vec(),
384            ..Default::default()
385        };
386        let page = self
387            .entities(token)?
388            .query_entities(
389                token.namespace().as_str(),
390                filter,
391                PageRequest {
392                    offset: 0,
393                    limit: ids.len() as u32,
394                },
395            )
396            .await?;
397        Ok(page.items)
398    }
399
400    /// Enforce that `record_ns` matches `caller_ns`.
401    ///
402    /// Returns `Err(NotFound)` when they differ — ADR-007 requires wrong-namespace
403    /// and absent UUIDs to be indistinguishable externally (no existence oracle).
404    pub(crate) fn ensure_namespace(record_ns: &str, caller_ns: &str) -> RuntimeResult<()> {
405        if record_ns == caller_ns {
406            return Ok(());
407        }
408        Err(RuntimeError::NotFound("not found in this namespace".into()))
409    }
410
411    /// List entities in a namespace, optionally filtered by kind and entity_type.
412    pub async fn list_entities(
413        &self,
414        token: &NamespaceToken,
415        kind: Option<&str>,
416        entity_type: Option<&str>,
417        limit: u32,
418        offset: u32,
419    ) -> RuntimeResult<Vec<Entity>> {
420        let filter = EntityFilter {
421            kinds: match kind {
422                Some(k) => vec![k.to_string()],
423                None => vec![],
424            },
425            entity_types: match entity_type {
426                Some(t) => vec![t.to_string()],
427                None => vec![],
428            },
429            ..Default::default()
430        };
431        let page = self
432            .entities(token)?
433            .query_entities(
434                token.namespace().as_str(),
435                filter,
436                PageRequest {
437                    offset: offset.into(),
438                    limit,
439                },
440            )
441            .await?;
442        Ok(page.items)
443    }
444
445    /// List entities filtered by kind, optional domain tag, limit, and offset.
446    ///
447    /// When `domain_tag` is Some, the query is restricted at the storage layer via
448    /// `EntityFilter::tags_any` so the page result already reflects the domain
449    /// constraint.  This avoids the silent truncation that occurs when filtering
450    /// post-page (K-3).
451    pub async fn list_entities_tagged(
452        &self,
453        token: &NamespaceToken,
454        kind: Option<&str>,
455        domain_tag: Option<&str>,
456        limit: u32,
457        offset: u32,
458    ) -> RuntimeResult<Vec<Entity>> {
459        let filter = EntityFilter {
460            kinds: match kind {
461                Some(k) => vec![k.to_string()],
462                None => vec![],
463            },
464            tags_any: match domain_tag {
465                Some(t) if !t.is_empty() => vec![t.to_string()],
466                _ => vec![],
467            },
468            ..Default::default()
469        };
470        let page = self
471            .entities(token)?
472            .query_entities(
473                token.namespace().as_str(),
474                filter,
475                PageRequest {
476                    offset: offset.into(),
477                    limit,
478                },
479            )
480            .await?;
481        Ok(page.items)
482    }
483
484    /// Count entities filtered by kind and optional domain tag.
485    ///
486    /// Used to report a meaningful `total` alongside a paginated listing (K-6).
487    pub async fn count_entities_tagged(
488        &self,
489        token: &NamespaceToken,
490        kind: Option<&str>,
491        domain_tag: Option<&str>,
492    ) -> RuntimeResult<u64> {
493        let filter = EntityFilter {
494            kinds: match kind {
495                Some(k) => vec![k.to_string()],
496                None => vec![],
497            },
498            tags_any: match domain_tag {
499                Some(t) if !t.is_empty() => vec![t.to_string()],
500                _ => vec![],
501            },
502            ..Default::default()
503        };
504        Ok(self
505            .entities(token)?
506            .count_entities(token.namespace().as_str(), filter)
507            .await?)
508    }
509
510    /// List events in the namespace proven by the caller token.
511    pub async fn list_events(
512        &self,
513        token: &NamespaceToken,
514        filter: EventFilter,
515        page: PageRequest,
516    ) -> RuntimeResult<Page<Event>> {
517        self.events(token)?
518            .query_events(filter, page)
519            .await
520            .map_err(Into::into)
521    }
522
523    // ---- Edge operations ----
524
525    /// Validate that `source_id` and `target_id` are legal endpoints for `relation`.
526    ///
527    /// Centralises the ADR-002/ADR-019/ADR-024 three-case contract so that both
528    /// `link()` and `update_edge()` share identical enforcement:
529    ///
530    /// - `annotates`: source MUST be a note; target may be any substrate.
531    /// - `supersedes`: same-substrate only (note→note or entity→entity).
532    /// - All other 11 relations: both endpoints MUST be entities.
533    ///
534    /// Returns `Ok(())` when valid; otherwise `InvalidInput` or `NotFound` with
535    /// the same messages as the previous inline block (byte-identical behaviour).
536    async fn validate_edge_relation_endpoints(
537        &self,
538        token: &NamespaceToken,
539        source_id: Uuid,
540        target_id: Uuid,
541        relation: EdgeRelation,
542    ) -> RuntimeResult<()> {
543        if relation == EdgeRelation::Annotates {
544            // Source must be a note in namespace.
545            match self.resolve(token, source_id).await? {
546                Some(Resolved::Note(_)) => {}
547                Some(_) => {
548                    return Err(RuntimeError::InvalidInput(format!(
549                        "annotates source {source_id} must be a note"
550                    )));
551                }
552                None => {
553                    // Existing edge used as annotates source: wrong kind, not absent.
554                    if self.get_edge(token, source_id).await?.is_some() {
555                        return Err(RuntimeError::InvalidInput(format!(
556                            "annotates source {source_id} must be a note"
557                        )));
558                    }
559                    return Err(RuntimeError::NotFound(format!(
560                        "link source {source_id} not found in namespace"
561                    )));
562                }
563            }
564            // Target may be any substrate (entity, note, event, or edge).
565            if !self.substrate_exists_in_ns(token, target_id).await? {
566                return Err(RuntimeError::NotFound(format!(
567                    "link target {target_id} not found in namespace"
568                )));
569            }
570        } else if relation == EdgeRelation::Supersedes {
571            // supersedes: same-substrate only (note→note or entity→entity).
572            // Event and edge endpoints are invalid regardless of the other endpoint.
573            let src = match self.resolve(token, source_id).await? {
574                Some(r) => r,
575                None => {
576                    if self.get_edge(token, source_id).await?.is_some() {
577                        return Err(RuntimeError::InvalidInput(format!(
578                            "supersedes source {source_id} must be a note or entity (got edge)"
579                        )));
580                    }
581                    return Err(RuntimeError::NotFound(format!(
582                        "link source {source_id} not found in namespace"
583                    )));
584                }
585            };
586            let tgt = match self.resolve(token, target_id).await? {
587                Some(r) => r,
588                None => {
589                    if self.get_edge(token, target_id).await?.is_some() {
590                        return Err(RuntimeError::InvalidInput(format!(
591                            "supersedes target {target_id} must be a note or entity (got edge)"
592                        )));
593                    }
594                    return Err(RuntimeError::NotFound(format!(
595                        "link target {target_id} not found in namespace"
596                    )));
597                }
598            };
599            match (&src, &tgt) {
600                (Resolved::Entity(src_e), Resolved::Entity(tgt_e)) => {
601                    if !base_entity_rule_allows(&src_e.kind, EdgeRelation::Supersedes, &tgt_e.kind)
602                    {
603                        return Err(RuntimeError::InvalidInput(format!(
604                            "({}) -[supersedes]-> ({}) is not in the ADR-002 base endpoint \
605                             allowlist; supersedes requires same-kind entity endpoints",
606                            src_e.kind, tgt_e.kind
607                        )));
608                    }
609                }
610                (Resolved::Note(_), Resolved::Note(_)) => {}
611                (Resolved::Event(_), _) => {
612                    return Err(RuntimeError::InvalidInput(format!(
613                        "supersedes does not apply to events; source {source_id} is an event"
614                    )));
615                }
616                (_, Resolved::Event(_)) => {
617                    return Err(RuntimeError::InvalidInput(format!(
618                        "supersedes does not apply to events; target {target_id} is an event"
619                    )));
620                }
621                (Resolved::Entity(_), Resolved::Note(_)) => {
622                    return Err(RuntimeError::InvalidInput(format!(
623                        "supersedes endpoints must be the same substrate (note→note or entity→entity); \
624                         got source={source_id} (entity) target={target_id} (note)"
625                    )));
626                }
627                (Resolved::Note(_), Resolved::Entity(_)) => {
628                    return Err(RuntimeError::InvalidInput(format!(
629                        "supersedes endpoints must be the same substrate (note→note or entity→entity); \
630                         got source={source_id} (note) target={target_id} (entity)"
631                    )));
632                }
633            }
634        } else {
635            // All 13 base relations: ADR-002 contract is entity→entity with
636            // kind-level restrictions (see base allowlist). ADR-031 allows packs
637            // to extend the allowlist additively via EDGE_RULES.
638            //
639            // Strategy: resolve both endpoints once, consult pack rules; on
640            // miss, fall through to the original base-rule error messages.
641            let src_res = self.resolve(token, source_id).await?;
642            let tgt_res = self.resolve(token, target_id).await?;
643
644            if pack_rule_allows(
645                &self.pack_edge_rules(),
646                relation,
647                src_res.as_ref(),
648                tgt_res.as_ref(),
649            ) {
650                return Ok(());
651            }
652
653            // Substrate check: both endpoints must be entities.
654            let src_kind = match src_res {
655                Some(Resolved::Entity(e)) => e.kind,
656                Some(_) => {
657                    return Err(RuntimeError::InvalidInput(format!(
658                        "link source {source_id} must be an entity for relation {relation:?} \
659                         (ADR-002: only `annotates` crosses substrates)"
660                    )));
661                }
662                None => {
663                    if self.get_edge(token, source_id).await?.is_some() {
664                        return Err(RuntimeError::InvalidInput(format!(
665                            "link source {source_id} must be an entity for relation {relation:?} \
666                             (ADR-002: only `annotates` crosses substrates)"
667                        )));
668                    }
669                    return Err(RuntimeError::NotFound(format!(
670                        "link source {source_id} not found in namespace"
671                    )));
672                }
673            };
674            let tgt_kind = match tgt_res {
675                Some(Resolved::Entity(e)) => e.kind,
676                Some(_) => {
677                    return Err(RuntimeError::InvalidInput(format!(
678                        "link target {target_id} must be an entity for relation {relation:?} \
679                         (ADR-002: only `annotates` crosses substrates)"
680                    )));
681                }
682                None => {
683                    if self.get_edge(token, target_id).await?.is_some() {
684                        return Err(RuntimeError::InvalidInput(format!(
685                            "link target {target_id} must be an entity for relation {relation:?} \
686                             (ADR-002: only `annotates` crosses substrates)"
687                        )));
688                    }
689                    return Err(RuntimeError::NotFound(format!(
690                        "link target {target_id} not found in namespace"
691                    )));
692                }
693            };
694            if !base_entity_rule_allows(&src_kind, relation, &tgt_kind) {
695                return Err(RuntimeError::InvalidInput(format!(
696                    "({src_kind}) -[{}]-> ({tgt_kind}) is not in the ADR-002 base endpoint \
697                     allowlist; use pack EDGE_RULES to extend the allowlist",
698                    relation.as_str()
699                )));
700            }
701        }
702        Ok(())
703    }
704
705    /// Create a directed edge between two substrates.
706    ///
707    /// Enforces the ADR-002/ADR-019/ADR-024 three-case relation contract via
708    /// `validate_edge_relation_endpoints`. See that method for the full contract.
709    ///
710    /// For symmetric relations (`competes_with`, `composed_with`) the endpoint
711    /// pair is canonicalised to `source_uuid < target_uuid` so that A→B and B→A
712    /// deduplicate to one row (F012).
713    ///
714    /// `metadata` is validated against governed keys (ADR-002 §Edge Metadata);
715    /// `dependency_kind` is inferred for `depends_on` edges when absent (F013).
716    ///
717    /// ADR-009 invariant: `target_backend` is always `None` for locally-routed
718    /// edges written through this path. The `validate_edge_relation_endpoints`
719    /// call above already ensures both endpoints exist in the local namespace,
720    /// so setting `target_backend = None` is the only valid choice (F161).
721    ///
722    /// A record that exists but belongs to a different namespace is treated as not found
723    /// (fail-closed; no cross-namespace existence leak).
724    pub async fn link(
725        &self,
726        token: &NamespaceToken,
727        source_id: Uuid,
728        target_id: Uuid,
729        relation: EdgeRelation,
730        weight: f64,
731        metadata: Option<serde_json::Value>,
732    ) -> RuntimeResult<Edge> {
733        self.validate_edge_relation_endpoints(token, source_id, target_id, relation)
734            .await?;
735        let (source_id, target_id) = canonical_edge_endpoints(relation, source_id, target_id);
736        let metadata = if relation == EdgeRelation::DependsOn {
737            match (
738                self.resolve(token, source_id).await?,
739                self.resolve(token, target_id).await?,
740            ) {
741                (Some(Resolved::Entity(src_e)), Some(Resolved::Entity(tgt_e))) => {
742                    merge_dependency_kind(&src_e.kind, &tgt_e.kind, metadata)
743                }
744                _ => metadata,
745            }
746        } else {
747            metadata
748        };
749        validate_edge_metadata(relation, metadata.as_ref())?;
750        let now = chrono::Utc::now();
751        let ns = token.namespace().as_str();
752        let edge = Edge {
753            id: LinkId::from(Uuid::new_v4()),
754            namespace: ns.to_string(),
755            source_id,
756            target_id,
757            relation,
758            weight,
759            created_at: now,
760            updated_at: now,
761            deleted_at: None,
762            metadata,
763            target_backend: None,
764        };
765        self.graph(token)?.upsert_edge(edge).await?;
766
767        // H1 fix: read back the persisted row by natural key so the returned
768        // edge ID is always the one stored in the database, not the locally
769        // generated UUID that was displaced by an ON CONFLICT DO UPDATE.
770        // Under parallel calls for the same triple, every caller now returns
771        // the same persisted edge ID — the winner's insert or the updated row.
772        let persisted = self
773            .list_edges(
774                token,
775                crate::curation::EdgeListFilter {
776                    source_id: Some(source_id),
777                    target_id: Some(target_id),
778                    relations: vec![relation],
779                    ..Default::default()
780                },
781                1,
782            )
783            .await?
784            .into_iter()
785            .next()
786            .ok_or_else(|| {
787                crate::RuntimeError::Internal(format!(
788                    "upsert_edge succeeded but natural-key lookup for ({source_id}, {target_id}, {relation}) returned nothing"
789                ))
790            })?;
791        Ok(persisted)
792    }
793
794    /// Returns `true` if `id` resolves to a live substrate record in `namespace`.
795    ///
796    /// Covers entity, note, event (via `resolve`) and edge (via `get_edge`).
797    /// A record that exists in a different namespace returns `false` (fail-closed).
798    pub(crate) async fn substrate_exists_in_ns(
799        &self,
800        token: &NamespaceToken,
801        id: Uuid,
802    ) -> RuntimeResult<bool> {
803        if self.resolve(token, id).await?.is_some() {
804            return Ok(true);
805        }
806        match self.get_edge(token, id).await {
807            Ok(Some(_)) => Ok(true),
808            Ok(None) | Err(RuntimeError::NotFound(_)) => Ok(false),
809            Err(err) => Err(err),
810        }
811    }
812
813    /// Get immediate neighbors of a node, optionally filtered by relation type.
814    ///
815    /// Pass `relations: Some(vec![EdgeRelation::Annotates])` to retrieve only
816    /// annotation edges, enabling cross-substrate navigation as described in ADR-024.
817    ///
818    /// ADR-002: symmetric relations (`competes_with`, `composed_with`) are stored
819    /// with the canonical source as the lower UUID. Direction normalization is
820    /// applied in `neighbors_with_query` so both callers see correct results.
821    pub async fn neighbors(
822        &self,
823        token: &NamespaceToken,
824        node_id: Uuid,
825        direction: Direction,
826        limit: Option<u32>,
827        relations: Option<Vec<EdgeRelation>>,
828    ) -> RuntimeResult<Vec<NeighborHit>> {
829        self.neighbors_with_query(
830            token,
831            node_id,
832            NeighborQuery {
833                direction,
834                relations,
835                limit,
836                min_weight: None,
837            },
838        )
839        .await
840    }
841
842    /// Get neighbors with full query control (includes `min_weight`).
843    ///
844    /// Applies symmetric-relation direction normalization (ADR-002): if the
845    /// relations filter contains only symmetric relations the direction is
846    /// overridden to `Both` so edges stored in canonical order are always found.
847    ///
848    /// Soft-deleted entity nodes are excluded from results unless the caller
849    /// explicitly requested them (future: `include_deleted` flag; currently
850    /// always false per Fix 2).
851    pub async fn neighbors_with_query(
852        &self,
853        token: &NamespaceToken,
854        node_id: Uuid,
855        mut query: NeighborQuery,
856    ) -> RuntimeResult<Vec<NeighborHit>> {
857        if !self.substrate_exists_in_ns(token, node_id).await? {
858            return Ok(Vec::new());
859        }
860
861        query.direction =
862            normalize_symmetric_direction(query.direction, query.relations.as_deref());
863        let mut hits = self.graph(token)?.neighbors(node_id, query).await?;
864        self.enrich_neighbor_hits(token, &mut hits).await;
865        // Filter out soft-deleted entity nodes (Fix 2).
866        let candidate_ids: Vec<Uuid> = hits.iter().map(|h| h.node_id).collect();
867        let deleted = self.deleted_entity_ids(candidate_ids).await;
868        if !deleted.is_empty() {
869            hits.retain(|h| !deleted.contains(&h.node_id));
870        }
871        Ok(hits)
872    }
873
874    /// Traverse the graph from a set of root nodes.
875    ///
876    /// Roots in a foreign namespace are silently filtered before storage expansion.
877    /// Soft-deleted entity nodes are excluded from results (Fix 2).
878    pub async fn traverse(
879        &self,
880        token: &NamespaceToken,
881        request: TraversalRequest,
882    ) -> RuntimeResult<Vec<GraphPath>> {
883        let mut request = request;
884        let mut visible_roots = Vec::with_capacity(request.roots.len());
885        for root in request.roots.drain(..) {
886            if self.substrate_exists_in_ns(token, root).await? {
887                visible_roots.push(root);
888            }
889        }
890        request.roots = visible_roots;
891        if request.roots.is_empty() {
892            return Ok(Vec::new());
893        }
894
895        let mut paths = self.graph(token)?.traverse(request).await?;
896        self.enrich_path_nodes(token, &mut paths).await;
897        // Filter out soft-deleted entity nodes from all path nodes (Fix 2).
898        let all_node_ids: Vec<Uuid> = paths
899            .iter()
900            .flat_map(|p| p.nodes.iter().map(|n| n.node_id))
901            .collect();
902        let deleted = self.deleted_entity_ids(all_node_ids).await;
903        if !deleted.is_empty() {
904            for path in paths.iter_mut() {
905                path.nodes.retain(|n| !deleted.contains(&n.node_id));
906            }
907            paths.retain(|p| !p.nodes.is_empty());
908        }
909        Ok(paths)
910    }
911
912    /// Batch-query for soft-deleted entity UUIDs in `ids`.
913    ///
914    /// Returns the subset of `ids` that have `deleted_at IS NOT NULL` in the
915    /// entities table. Takes `Vec<Uuid>` (not an iterator) so the async
916    /// state machine holds only owned data — no iterator borrow across yields.
917    async fn deleted_entity_ids(&self, ids: Vec<Uuid>) -> std::collections::HashSet<Uuid> {
918        if ids.is_empty() {
919            return std::collections::HashSet::new();
920        }
921        let id_strs: Vec<String> = ids.iter().map(|u| u.to_string()).collect();
922        let placeholders = id_strs
923            .iter()
924            .enumerate()
925            .map(|(i, _)| format!("?{}", i + 1))
926            .collect::<Vec<_>>()
927            .join(",");
928        let sql_str = format!(
929            "SELECT id FROM entities WHERE id IN ({placeholders}) AND deleted_at IS NOT NULL"
930        );
931        let params: Vec<SqlValue> = id_strs.into_iter().map(SqlValue::Text).collect();
932        let stmt = SqlStatement {
933            sql: sql_str,
934            params,
935            label: Some("deleted_entity_ids".into()),
936        };
937        let mut out = std::collections::HashSet::new();
938        let sql = self.sql();
939        if let Ok(mut reader) = sql.reader().await {
940            if let Ok(rows) = reader.query_all(stmt).await {
941                for row in rows {
942                    if let Some(col) = row.columns.first() {
943                        if let SqlValue::Text(s) = &col.value {
944                            if let Ok(u) = s.parse::<Uuid>() {
945                                out.insert(u);
946                            }
947                        }
948                    }
949                }
950            }
951            // best-effort: on reader or query error, treat none as deleted
952        }
953        out
954    }
955
956    /// Populate `name` and `kind` on each `NeighborHit` from the corresponding
957    /// entity or note record. Best-effort: unresolved IDs leave the fields `None`.
958    async fn enrich_neighbor_hits(&self, token: &NamespaceToken, hits: &mut [NeighborHit]) {
959        if hits.is_empty() {
960            return;
961        }
962
963        let entity_store = self.entities(token).ok();
964        let note_store = self.notes(token).ok();
965
966        for hit in hits.iter_mut() {
967            if let Some(store) = &entity_store {
968                if let Ok(Some(entity)) = store.get_entity(hit.node_id).await {
969                    hit.name = Some(entity.name);
970                    hit.kind = Some(entity.kind);
971                    continue;
972                }
973            }
974
975            if let Some(store) = &note_store {
976                if let Ok(Some(note)) = store.get_note(hit.node_id).await {
977                    let kind = note.kind;
978                    let name = note
979                        .name
980                        .filter(|s| !s.trim().is_empty())
981                        .unwrap_or_else(|| format!("[{kind}]"));
982                    hit.name = Some(name);
983                    hit.kind = Some(kind);
984                }
985            }
986        }
987    }
988
989    /// Populate `name` and `kind` on each `PathNode` from the corresponding
990    /// entity record (#162). Same best-effort policy as `enrich_neighbor_hits`.
991    async fn enrich_path_nodes(&self, token: &NamespaceToken, paths: &mut [GraphPath]) {
992        if paths.is_empty() {
993            return;
994        }
995        let store = match self.entities(token) {
996            Ok(s) => s,
997            Err(_) => return,
998        };
999        for path in paths.iter_mut() {
1000            for node in path.nodes.iter_mut() {
1001                if let Ok(Some(entity)) = store.get_entity(node.node_id).await {
1002                    node.name = Some(entity.name);
1003                    node.kind = Some(entity.kind);
1004                }
1005            }
1006        }
1007    }
1008
1009    // ---- Note operations ----
1010
1011    /// Create and persist a note, optionally with properties and annotation targets.
1012    ///
1013    /// After creating the note:
1014    /// - Always indexes into FTS5 at the `notes_<namespace>` key.
1015    /// - If an embedding model is configured, indexes into the vector store with
1016    ///   `SubstrateKind::Note`.
1017    /// - For each UUID in `annotates`, creates an `EdgeRelation::Annotates` edge from
1018    ///   the note to that target.
1019    #[allow(clippy::too_many_arguments)]
1020    pub async fn create_note(
1021        &self,
1022        token: &NamespaceToken,
1023        kind: &str,
1024        name: Option<&str>,
1025        content: &str,
1026        salience: Option<f64>,
1027        properties: Option<serde_json::Value>,
1028        annotates: Vec<Uuid>,
1029    ) -> RuntimeResult<Note> {
1030        self.create_note_inner(
1031            token, kind, name, content, salience, None, properties, annotates, None,
1032        )
1033        .await
1034    }
1035
1036    /// Like [`create_note`] but also sets a non-zero decay factor on the note.
1037    #[allow(clippy::too_many_arguments)]
1038    pub async fn create_note_with_decay(
1039        &self,
1040        token: &NamespaceToken,
1041        kind: &str,
1042        name: Option<&str>,
1043        content: &str,
1044        salience: Option<f64>,
1045        decay_factor: f64,
1046        properties: Option<serde_json::Value>,
1047        annotates: Vec<Uuid>,
1048    ) -> RuntimeResult<Note> {
1049        self.create_note_with_decay_for_embedding_model(
1050            token,
1051            kind,
1052            name,
1053            content,
1054            salience,
1055            decay_factor,
1056            properties,
1057            annotates,
1058            None,
1059        )
1060        .await
1061    }
1062
1063    /// Like [`create_note_with_decay`] but targets a specific embedding model.
1064    #[allow(clippy::too_many_arguments)]
1065    pub async fn create_note_with_decay_for_embedding_model(
1066        &self,
1067        token: &NamespaceToken,
1068        kind: &str,
1069        name: Option<&str>,
1070        content: &str,
1071        salience: Option<f64>,
1072        decay_factor: f64,
1073        properties: Option<serde_json::Value>,
1074        annotates: Vec<Uuid>,
1075        embedding_model: Option<&str>,
1076    ) -> RuntimeResult<Note> {
1077        self.create_note_inner(
1078            token,
1079            kind,
1080            name,
1081            content,
1082            salience,
1083            Some(decay_factor),
1084            properties,
1085            annotates,
1086            embedding_model,
1087        )
1088        .await
1089    }
1090
1091    #[allow(clippy::too_many_arguments)]
1092    async fn create_note_inner(
1093        &self,
1094        token: &NamespaceToken,
1095        kind: &str,
1096        name: Option<&str>,
1097        content: &str,
1098        salience: Option<f64>,
1099        decay_factor: Option<f64>,
1100        properties: Option<serde_json::Value>,
1101        annotates: Vec<Uuid>,
1102        embedding_model: Option<&str>,
1103    ) -> RuntimeResult<Note> {
1104        let ns = token.namespace().as_str();
1105
1106        // Validate all annotates targets before any write (ADR-024:295 atomicity).
1107        for &target_id in &annotates {
1108            if !self.substrate_exists_in_ns(token, target_id).await? {
1109                return Err(RuntimeError::NotFound(format!(
1110                    "create_note annotates target {target_id} not found in namespace"
1111                )));
1112            }
1113        }
1114
1115        // Codex round 2 Medium (PR #407): resolve embedding_model BEFORE any
1116        // note/FTS/vector write so unknown-model errors are atomic at the
1117        // runtime layer, not just at one pack handler. Direct Rust callers
1118        // (other packs, integration tests) get the same guarantee.
1119        if let Some(model_name) = embedding_model {
1120            self.resolve_embedding_model(Some(model_name))?;
1121        }
1122
1123        let mut note = Note::new(ns, kind, content);
1124        if let Some(s) = salience {
1125            note = note.with_salience(s);
1126        }
1127        if let Some(df) = decay_factor {
1128            note = note.with_decay(df);
1129        }
1130        if let Some(n) = name {
1131            note = note.with_name(n);
1132        }
1133        if let Some(p) = properties {
1134            note = note.with_properties(p);
1135        }
1136        self.notes(token)?.upsert_note(note.clone()).await?;
1137
1138        let body = match &note.name {
1139            Some(n) => format!("{n} {}", note.content),
1140            None => note.content.clone(),
1141        };
1142
1143        self.text_for_notes(token)?
1144            .upsert_document(TextDocument {
1145                subject_id: note.id,
1146                kind: SubstrateKind::Note,
1147                title: note.name.clone(),
1148                body,
1149                tags: vec![],
1150                namespace: ns.to_string(),
1151                metadata: note.properties.clone(),
1152                updated_at: chrono::Utc::now(),
1153            })
1154            .await?;
1155
1156        // Multi-model vector embedding:
1157        //   - explicit embedding_model → single model (existing behaviour)
1158        //   - None + any models registered → ALL registered models in parallel
1159        //   - None + no models configured → skip (text-only)
1160        let embed_model_names: Vec<String> = if let Some(m) = embedding_model {
1161            vec![m.to_string()]
1162        } else {
1163            // Fan out to ALL registered models — includes both lattice models
1164            // from RuntimeConfig and any custom providers added via
1165            // register_embedder() (codex High #1, PR #444).
1166            // Gate on the registry, not config().embedding_model, so that
1167            // custom-only runtimes (no lattice model in config) also fan out.
1168            let names = self.registered_embedding_model_names();
1169            if names.is_empty() {
1170                // No models configured at all — skip vector embedding.
1171                vec![]
1172            } else {
1173                names
1174            }
1175        };
1176
1177        if embed_model_names.len() == 1 {
1178            // Single-model path: preserves original sequential behaviour.
1179            let model_name = &embed_model_names[0];
1180            let vector = self.embed_with_model(model_name, &note.content).await?;
1181            self.vectors_for_model(token, model_name)?
1182                .insert(
1183                    note.id,
1184                    SubstrateKind::Note,
1185                    ns,
1186                    "note.content",
1187                    vec![vector],
1188                )
1189                .await?;
1190        } else if !embed_model_names.is_empty() {
1191            // Multi-model path: embed with each model in parallel via spawned tasks,
1192            // then insert one VectorRecord per model.
1193            let rt_clone = self.clone();
1194            let content_owned = note.content.clone();
1195            let mut handles = Vec::with_capacity(embed_model_names.len());
1196            for model_name in &embed_model_names {
1197                let rt = rt_clone.clone();
1198                let text = content_owned.clone();
1199                let name = model_name.clone();
1200                handles.push(tokio::spawn(async move {
1201                    rt.embed_with_model(&name, &text).await
1202                }));
1203            }
1204            let mut vectors: Vec<Vec<f32>> = Vec::with_capacity(embed_model_names.len());
1205            for handle in handles {
1206                let vec = handle
1207                    .await
1208                    .map_err(|e| RuntimeError::Internal(format!("embed task panicked: {e}")))??;
1209                vectors.push(vec);
1210            }
1211            // TODO(P2): parallelize vector inserts (codex review #444)
1212            for (model_name, vector) in embed_model_names.iter().zip(vectors.into_iter()) {
1213                self.vectors_for_model(token, model_name)?
1214                    .insert(
1215                        note.id,
1216                        SubstrateKind::Note,
1217                        ns,
1218                        "note.content",
1219                        vec![vector],
1220                    )
1221                    .await?;
1222            }
1223        }
1224
1225        // Create annotates edges, compensating on failure to preserve atomicity.
1226        //
1227        // Pre-validation (above) ensures all targets exist, so link failures are
1228        // unexpected. If one occurs: delete any edges already created, then remove
1229        // the note, its FTS document, and its vector entry.
1230        let mut created_edges: Vec<Uuid> = Vec::with_capacity(annotates.len());
1231
1232        // In test builds, iterate with an index so the failure-injection hook can
1233        // target a specific call.  In release builds, skip the enumerate overhead.
1234        #[cfg(test)]
1235        let annotates_iter: Vec<(usize, Uuid)> = annotates
1236            .iter()
1237            .enumerate()
1238            .map(|(i, &id)| (i, id))
1239            .collect();
1240        #[cfg(test)]
1241        macro_rules! next_target {
1242            ($pair:expr) => {
1243                $pair.1
1244            };
1245        }
1246        #[cfg(not(test))]
1247        let annotates_iter: Vec<Uuid> = annotates.to_vec();
1248        #[cfg(not(test))]
1249        macro_rules! next_target {
1250            ($pair:expr) => {
1251                $pair
1252            };
1253        }
1254
1255        for pair in annotates_iter {
1256            let target_id = next_target!(pair);
1257
1258            // Test-only: inject a failure on the configured call index (1-based).
1259            #[cfg(test)]
1260            let injected_err: Option<RuntimeError> = {
1261                let call_idx = pair.0;
1262                LINK_FAIL_AFTER.with(|cell| {
1263                    let n = cell.get();
1264                    if n > 0 && call_idx + 1 == n {
1265                        cell.set(0); // reset so subsequent calls are unaffected
1266                        Some(RuntimeError::Internal("injected link failure".to_string()))
1267                    } else {
1268                        None
1269                    }
1270                })
1271            };
1272            #[cfg(not(test))]
1273            let injected_err: Option<RuntimeError> = None;
1274
1275            let link_result = if let Some(e) = injected_err {
1276                Err(e)
1277            } else {
1278                self.link(
1279                    token,
1280                    note.id,
1281                    target_id,
1282                    EdgeRelation::Annotates,
1283                    1.0,
1284                    None,
1285                )
1286                .await
1287            };
1288
1289            match link_result {
1290                Ok(edge) => created_edges.push(edge.id.into()),
1291                Err(e) => {
1292                    // Best-effort compensation — ignore cleanup errors.
1293                    for edge_id in created_edges {
1294                        let _ = self.delete_edge(token, edge_id, true).await;
1295                    }
1296                    if let Ok(store) = self.notes(token) {
1297                        let _ = store.delete_note(note.id, DeleteMode::Hard).await;
1298                    }
1299                    if let Ok(fts) = self.text_for_notes(token) {
1300                        let _ = fts.delete_document(ns, note.id).await;
1301                    }
1302                    for model_name in &embed_model_names {
1303                        if let Ok(vs) = self.vectors_for_model(token, model_name) {
1304                            let _ = vs.delete(note.id).await;
1305                        }
1306                    }
1307                    return Err(e);
1308                }
1309            }
1310        }
1311
1312        Ok(note)
1313    }
1314
1315    /// List notes, optionally filtered by kind.
1316    pub async fn list_notes(
1317        &self,
1318        token: &NamespaceToken,
1319        kind: Option<&str>,
1320        limit: u32,
1321        offset: u32,
1322    ) -> RuntimeResult<Vec<Note>> {
1323        let page = self
1324            .notes(token)?
1325            .query_notes(
1326                token.namespace().as_str(),
1327                kind,
1328                PageRequest {
1329                    offset: offset.into(),
1330                    limit,
1331                },
1332            )
1333            .await?;
1334        Ok(page.items)
1335    }
1336
1337    /// Search notes using a hybrid FTS5 + vector pipeline with salience weighting.
1338    ///
1339    /// Pipeline (per ADR-024):
1340    /// 1. FTS5 query against `notes_<namespace>`.
1341    /// 2. If embedding model is configured: vector search filtered to `kind="note"`.
1342    /// 3. RRF fusion (k=60).
1343    /// 4. Salience-weighted rerank: `score *= (0.5 + 0.5 * note.salience)`.
1344    /// 5. Filter soft-deleted notes (`deleted_at IS NOT NULL`).
1345    /// 6. Truncate to `limit`.
1346    pub async fn search_notes(
1347        &self,
1348        token: &NamespaceToken,
1349        query_text: &str,
1350        query_vector: Option<Vec<f32>>,
1351        limit: u32,
1352        note_kind: Option<&str>,
1353        include_superseded: bool,
1354    ) -> RuntimeResult<Vec<NoteSearchHit>> {
1355        const RRF_K: usize = 60;
1356        let candidates = limit.saturating_mul(4).max(limit);
1357        let ns = token.namespace().as_str().to_owned();
1358
1359        // FTS5 over the notes index.
1360        let text_hits = self
1361            .text_for_notes(token)?
1362            .search(TextSearchRequest {
1363                query: query_text.to_string(),
1364                mode: TextQueryMode::Plain,
1365                filter: Some(TextFilter {
1366                    namespaces: vec![ns.clone()],
1367                    ..TextFilter::default()
1368                }),
1369                top_k: candidates,
1370                snippet_chars: 200,
1371            })
1372            .await?;
1373
1374        // Vector search filtered to notes.
1375        let vector_hits = if query_vector.is_some() || self.config().embedding_model.is_some() {
1376            self.vector_search(
1377                token,
1378                query_vector,
1379                Some(query_text),
1380                candidates,
1381                Some(SubstrateKind::Note),
1382            )
1383            .await?
1384        } else {
1385            vec![]
1386        };
1387
1388        // Keep the full text∪vector union through RRF — salience weighting and
1389        // soft-delete/kind filtering happen *after* this, and the final
1390        // `hits.truncate(limit)` is the only result-limiting cut. Truncating to
1391        // `candidates` here would drop a high-salience note ranked just outside
1392        // the raw RRF cutoff before salience ever applied (codex #526).
1393        let fuse_k = text_hits.len() + vector_hits.len();
1394        let fused = crate::fusion::rrf_fuse_k(text_hits, vector_hits, RRF_K, fuse_k);
1395
1396        let candidate_ids: Vec<Uuid> = fused.iter().map(|hit| hit.entity_id).collect();
1397        if candidate_ids.is_empty() {
1398            return Ok(vec![]);
1399        }
1400
1401        // Fetch each candidate note individually to get salience and apply
1402        // soft-delete + (optional) kind filtering. Notes whose `kind` doesn't
1403        // match `note_kind` are dropped post-fetch — they're a small set
1404        // bounded by the text∪vector union (≤ 2×candidates), so the read is cheap.
1405        let note_store = self.notes(token)?;
1406        let mut alive_notes: HashMap<Uuid, Note> = HashMap::new();
1407        for id in &candidate_ids {
1408            if let Some(note) = note_store.get_note(*id).await? {
1409                if note.deleted_at.is_some() {
1410                    continue;
1411                }
1412                if let Some(want_kind) = note_kind {
1413                    if note.kind != want_kind {
1414                        continue;
1415                    }
1416                }
1417                alive_notes.insert(*id, note);
1418            }
1419        }
1420
1421        // Drop superseded notes unless include_superseded is true: any note targeted
1422        // by a `supersedes` edge is obsolete and excluded from default search
1423        // (ADR-013, ADR-024).
1424        if !include_superseded && !alive_notes.is_empty() {
1425            let graph = self.graph(token)?;
1426            let mut superseded: std::collections::HashSet<Uuid> = std::collections::HashSet::new();
1427            for &note_id in alive_notes.keys() {
1428                let inbound = graph
1429                    .neighbors(
1430                        note_id,
1431                        NeighborQuery {
1432                            direction: Direction::In,
1433                            relations: Some(vec![EdgeRelation::Supersedes]),
1434                            limit: Some(1),
1435                            min_weight: None,
1436                        },
1437                    )
1438                    .await?;
1439                if !inbound.is_empty() {
1440                    superseded.insert(note_id);
1441                }
1442            }
1443            alive_notes.retain(|id, _| !superseded.contains(id));
1444        }
1445
1446        // Apply salience weighting and collect final hits.
1447        let mut hits: Vec<NoteSearchHit> = fused
1448            .into_iter()
1449            .filter_map(|hit| {
1450                let note = alive_notes.get(&hit.entity_id)?;
1451                let salience = note.salience.unwrap_or(0.5);
1452                let weight = 0.5 + 0.5 * salience;
1453                let weighted = DeterministicScore::from_f64(hit.score.to_f64() * weight);
1454                Some(NoteSearchHit {
1455                    note_id: hit.entity_id,
1456                    score: weighted,
1457                    title: hit.title.or_else(|| note_title(note)),
1458                    snippet: hit.snippet.or_else(|| note_snippet(note)),
1459                })
1460            })
1461            .collect();
1462
1463        hits.sort_by(|a, b| b.score.cmp(&a.score).then(a.note_id.cmp(&b.note_id)));
1464        hits.truncate(limit as usize);
1465        Ok(hits)
1466    }
1467
1468    /// Resolve a short UUID prefix (8+ hex chars) to a full UUID.
1469    ///
1470    /// Searches entities, notes, and edges tables for a UUID starting with the
1471    /// given prefix, scoped to the caller's namespace. Returns `Ok(Some(uuid))`
1472    /// if exactly one match is found, `Ok(None)` if no matches, or an error if
1473    /// ambiguous (multiple matches).
1474    pub async fn resolve_prefix(
1475        &self,
1476        token: &NamespaceToken,
1477        prefix: &str,
1478    ) -> RuntimeResult<Option<Uuid>> {
1479        use khive_storage::types::{SqlStatement, SqlValue};
1480
1481        let ns = token.namespace().as_str().to_owned();
1482        let pattern = format!("{}%", prefix);
1483
1484        let tables = [
1485            ("entities", true),
1486            ("notes", true),
1487            ("events", false),
1488            ("graph_edges", false),
1489        ];
1490
1491        let mut matches: Vec<String> = Vec::new();
1492        let mut reader = self.sql().reader().await.map_err(RuntimeError::Storage)?;
1493
1494        for (table, has_deleted_at) in tables {
1495            let deleted_filter = if has_deleted_at {
1496                " AND deleted_at IS NULL"
1497            } else {
1498                ""
1499            };
1500            let sql = SqlStatement {
1501                sql: format!(
1502                    "SELECT id FROM {table} WHERE id LIKE ?1 AND namespace = ?2{deleted_filter} LIMIT 2"
1503                ),
1504                params: vec![
1505                    SqlValue::Text(pattern.clone()),
1506                    SqlValue::Text(ns.clone()),
1507                ],
1508                label: Some("resolve_prefix".into()),
1509            };
1510            match reader.query_all(sql).await {
1511                Ok(rows) => {
1512                    for row in rows {
1513                        if let Some(col) = row.columns.first() {
1514                            if let SqlValue::Text(s) = &col.value {
1515                                matches.push(s.clone());
1516                            }
1517                        }
1518                    }
1519                }
1520                Err(e) => {
1521                    let msg = e.to_string();
1522                    if msg.contains("no such table") {
1523                        continue;
1524                    }
1525                    return Err(RuntimeError::Storage(e));
1526                }
1527            }
1528            if matches.len() > 1 {
1529                break;
1530            }
1531        }
1532
1533        match matches.len() {
1534            0 => Ok(None),
1535            1 => {
1536                let uuid = Uuid::from_str(&matches[0])
1537                    .map_err(|e| RuntimeError::Internal(format!("stored UUID is invalid: {e}")))?;
1538                Ok(Some(uuid))
1539            }
1540            _ => {
1541                let uuids: Vec<uuid::Uuid> = matches
1542                    .iter()
1543                    .filter_map(|s| Uuid::from_str(s).ok())
1544                    .collect();
1545                Err(RuntimeError::AmbiguousPrefix {
1546                    prefix: prefix.to_string(),
1547                    matches: uuids,
1548                })
1549            }
1550        }
1551    }
1552
1553    /// Resolve a UUID to its substrate kind by trying entity, then note, then event stores.
1554    ///
1555    /// Returns `None` if the UUID is not found in any substrate.
1556    /// Cost: at most 3 store lookups per call (cheap for v0.1).
1557    pub async fn resolve(
1558        &self,
1559        token: &NamespaceToken,
1560        id: Uuid,
1561    ) -> RuntimeResult<Option<Resolved>> {
1562        let ns = token.namespace().as_str();
1563
1564        // Entity: use the namespace-checked getter (errors on mismatch/absent).
1565        match self.get_entity(token, id).await {
1566            Ok(entity) => return Ok(Some(Resolved::Entity(entity))),
1567            Err(RuntimeError::NotFound(_) | RuntimeError::NamespaceMismatch { .. }) => {}
1568            Err(e) => return Err(e),
1569        }
1570
1571        // Note: storage get_note is ID-only — verify namespace after fetch.
1572        if let Some(note) = self.notes(token)?.get_note(id).await? {
1573            if Self::ensure_namespace(&note.namespace, ns).is_ok() {
1574                return Ok(Some(Resolved::Note(note)));
1575            }
1576        }
1577
1578        // Event: storage get_event is ID-only — verify namespace after fetch.
1579        if let Some(event) = self.events(token)?.get_event(id).await? {
1580            if Self::ensure_namespace(&event.namespace, ns).is_ok() {
1581                return Ok(Some(Resolved::Event(event)));
1582            }
1583        }
1584
1585        Ok(None)
1586    }
1587
1588    /// Delete a note by ID, enforcing namespace isolation.
1589    ///
1590    /// On hard delete, cascades to remove all incident edges (both inbound and
1591    /// outbound) and cleans up FTS and vector indexes, preventing dangling
1592    /// references for `annotates` edges that target this note (ADR-002, ADR-024).
1593    /// Soft delete also cleans FTS and vector indexes; edges are left in place.
1594    ///
1595    /// Returns `Ok(false)` if the note does not exist or belongs to a different
1596    /// namespace (ADR-007 — wrong-namespace is indistinguishable from absent).
1597    pub async fn delete_note(
1598        &self,
1599        token: &NamespaceToken,
1600        id: Uuid,
1601        hard: bool,
1602    ) -> RuntimeResult<bool> {
1603        let ns = token.namespace().as_str();
1604        let note_store = self.notes(token)?;
1605        let note = match note_store.get_note(id).await? {
1606            Some(n) => n,
1607            None => return Ok(false),
1608        };
1609        if Self::ensure_namespace(&note.namespace, ns).is_err() {
1610            return Ok(false);
1611        }
1612        let mode = if hard {
1613            DeleteMode::Hard
1614        } else {
1615            DeleteMode::Soft
1616        };
1617
1618        // On hard delete, cascade-remove incident edges and clean up indexes.
1619        if hard {
1620            let graph = self.graph(token)?;
1621            for direction in [Direction::Out, Direction::In] {
1622                let hits = graph
1623                    .neighbors(
1624                        id,
1625                        NeighborQuery {
1626                            direction,
1627                            relations: None,
1628                            limit: None,
1629                            min_weight: None,
1630                        },
1631                    )
1632                    .await?;
1633                for hit in hits {
1634                    graph
1635                        .delete_edge(LinkId::from(hit.edge_id), DeleteMode::Hard)
1636                        .await?;
1637                }
1638            }
1639            let ns_str = ns.to_string();
1640            self.text_for_notes(token)?
1641                .delete_document(&ns_str, id)
1642                .await?;
1643            // Codex High 2 (PR #407): scoped delete — iterate over EVERY
1644            // registered embedding model's vector store so non-default vectors
1645            // don't orphan when the note is deleted.
1646            for model_name in self.registered_embedding_model_names() {
1647                self.vectors_for_model(token, &model_name)?
1648                    .delete(id)
1649                    .await?;
1650            }
1651        }
1652
1653        let deleted = note_store.delete_note(id, mode).await?;
1654        if !hard && deleted {
1655            let ns_str = ns.to_string();
1656            self.text_for_notes(token)?
1657                .delete_document(&ns_str, id)
1658                .await?;
1659            for model_name in self.registered_embedding_model_names() {
1660                self.vectors_for_model(token, &model_name)?
1661                    .delete(id)
1662                    .await?;
1663            }
1664        }
1665        if deleted {
1666            let event_store = self.events(token)?;
1667            let ns_str = ns.to_string();
1668            let event = khive_storage::event::Event::new(
1669                ns_str.clone(),
1670                "delete",
1671                EventKind::NoteDeleted,
1672                SubstrateKind::Note,
1673                "",
1674            )
1675            .with_target(id)
1676            .with_payload(serde_json::json!({"id": id, "namespace": ns_str, "hard": hard}));
1677            event_store.append_event(event).await.map_err(|e| {
1678                RuntimeError::Internal(format!("delete_note: event store write failed: {e}"))
1679            })?;
1680        }
1681        Ok(deleted)
1682    }
1683}
1684
1685/// Result of a GQL/SPARQL query with optional validation warnings.
1686#[derive(Clone, Debug, Serialize)]
1687pub struct QueryResult {
1688    pub rows: Vec<SqlRow>,
1689    #[serde(skip_serializing_if = "Vec::is_empty")]
1690    pub warnings: Vec<String>,
1691}
1692
1693impl KhiveRuntime {
1694    // ---- Query operations ----
1695
1696    /// Execute a GQL or SPARQL query string, returning raw SQL rows.
1697    ///
1698    /// The query is compiled to SQL with the namespace scope applied.
1699    /// GQL syntax: `MATCH (a:concept)-[e:extends]->(b) RETURN a, b LIMIT 10`
1700    /// SPARQL syntax: `SELECT ?a WHERE { ?a :kind "concept" . }`
1701    pub async fn query(&self, token: &NamespaceToken, query: &str) -> RuntimeResult<Vec<SqlRow>> {
1702        Ok(self
1703            .query_with_metadata(token, query, khive_query::CompileOptions::default())
1704            .await?
1705            .rows)
1706    }
1707
1708    /// Execute a GQL/SPARQL query, returning rows and any validation warnings.
1709    pub async fn query_with_metadata(
1710        &self,
1711        token: &NamespaceToken,
1712        query: &str,
1713        mut opts: khive_query::CompileOptions,
1714    ) -> RuntimeResult<QueryResult> {
1715        use khive_query::QueryValue;
1716        use khive_storage::types::SqlValue;
1717
1718        let ns = token.namespace().as_str();
1719        let ast = khive_query::parse_auto(query)?;
1720        opts.scopes = vec![ns.to_string()];
1721        let compiled = khive_query::compile(&ast, &opts)?;
1722        let warnings = compiled.warnings;
1723
1724        // Convert QueryValue params (query-layer type) to SqlValue (storage-layer type)
1725        // at the query–storage boundary (ADR-008 §"Query crate compiles against khive-types only").
1726        let params: Vec<SqlValue> = compiled
1727            .params
1728            .into_iter()
1729            .map(|qv| match qv {
1730                QueryValue::Null => SqlValue::Null,
1731                QueryValue::Integer(n) => SqlValue::Integer(n),
1732                QueryValue::Float(f) => SqlValue::Float(f),
1733                QueryValue::Text(s) => SqlValue::Text(s),
1734                QueryValue::Blob(b) => SqlValue::Blob(b),
1735            })
1736            .collect();
1737
1738        let mut reader = self.sql().reader().await?;
1739        let stmt = SqlStatement {
1740            sql: compiled.sql,
1741            params,
1742            label: None,
1743        };
1744        let rows = reader.query_all(stmt).await?;
1745        Ok(QueryResult { rows, warnings })
1746    }
1747
1748    /// Delete an entity by ID (soft delete by default).
1749    ///
1750    /// On hard delete, cascades to remove all incident edges (both inbound and
1751    /// outbound) to prevent dangling references. Soft delete also cleans FTS
1752    /// and vector indexes; edges are left in place.
1753    ///
1754    /// Returns `Err(NotFound)` if the entity does not exist or belongs to a
1755    /// different namespace (ADR-007 — indistinguishable, no existence oracle).
1756    pub async fn delete_entity(
1757        &self,
1758        token: &NamespaceToken,
1759        id: Uuid,
1760        hard: bool,
1761    ) -> RuntimeResult<bool> {
1762        let entity = match self.entities(token)?.get_entity(id).await? {
1763            Some(e) => e,
1764            None => return Ok(false),
1765        };
1766        Self::ensure_namespace(&entity.namespace, token.namespace().as_str())?;
1767        let mode = if hard {
1768            DeleteMode::Hard
1769        } else {
1770            DeleteMode::Soft
1771        };
1772
1773        // On hard delete, cascade-remove incident edges to prevent dangling refs.
1774        if hard {
1775            let graph = self.graph(token)?;
1776            for direction in [Direction::Out, Direction::In] {
1777                let hits = graph
1778                    .neighbors(
1779                        id,
1780                        NeighborQuery {
1781                            direction,
1782                            relations: None,
1783                            limit: None,
1784                            min_weight: None,
1785                        },
1786                    )
1787                    .await?;
1788                for hit in hits {
1789                    graph
1790                        .delete_edge(LinkId::from(hit.edge_id), DeleteMode::Hard)
1791                        .await?;
1792                }
1793            }
1794            self.remove_from_indexes(token, id).await?;
1795        }
1796
1797        let deleted = self.entities(token)?.delete_entity(id, mode).await?;
1798        if !hard && deleted {
1799            self.remove_from_indexes(token, id).await?;
1800        }
1801        if deleted {
1802            let event_store = self.events(token)?;
1803            let ns = entity.namespace.clone();
1804            let event = khive_storage::event::Event::new(
1805                ns.clone(),
1806                "delete",
1807                EventKind::EntityDeleted,
1808                SubstrateKind::Entity,
1809                "",
1810            )
1811            .with_target(id)
1812            .with_payload(serde_json::json!({"id": id, "namespace": ns, "hard": hard}));
1813            event_store.append_event(event).await.map_err(|e| {
1814                RuntimeError::Internal(format!("delete_entity: event store write failed: {e}"))
1815            })?;
1816        }
1817        Ok(deleted)
1818    }
1819
1820    /// Count entities in a namespace, optionally filtered.
1821    pub async fn count_entities(
1822        &self,
1823        token: &NamespaceToken,
1824        kind: Option<&str>,
1825    ) -> RuntimeResult<u64> {
1826        let filter = EntityFilter {
1827            kinds: match kind {
1828                Some(k) => vec![k.to_string()],
1829                None => vec![],
1830            },
1831            ..Default::default()
1832        };
1833        Ok(self
1834            .entities(token)?
1835            .count_entities(token.namespace().as_str(), filter)
1836            .await?)
1837    }
1838
1839    // ---- Edge CRUD operations ----
1840
1841    /// Fetch a single edge by id, enforcing namespace isolation (ADR-007).
1842    ///
1843    /// Returns `Err(NotFound)` if the edge exists in a different namespace,
1844    /// `Ok(None)` if no edge with that id exists at all.
1845    pub async fn get_edge(
1846        &self,
1847        token: &NamespaceToken,
1848        edge_id: Uuid,
1849    ) -> RuntimeResult<Option<Edge>> {
1850        let mut reader = self.sql().reader().await?;
1851        let record_ns = reader
1852            .query_scalar(SqlStatement {
1853                sql: "SELECT namespace FROM graph_edges \
1854                      WHERE id = ?1 AND deleted_at IS NULL LIMIT 1"
1855                    .into(),
1856                params: vec![SqlValue::Text(edge_id.to_string())],
1857                label: Some("get_edge_namespace".into()),
1858            })
1859            .await?;
1860
1861        let Some(SqlValue::Text(record_ns)) = record_ns else {
1862            return Ok(None);
1863        };
1864        // ADR-007: absent and foreign-namespace IDs must be indistinguishable.
1865        if Self::ensure_namespace(&record_ns, token.namespace().as_str()).is_err() {
1866            return Ok(None);
1867        }
1868
1869        Ok(self.graph(token)?.get_edge(LinkId::from(edge_id)).await?)
1870    }
1871
1872    /// List edges matching `filter`. `limit` is capped at 1000; defaults to 100.
1873    pub async fn list_edges(
1874        &self,
1875        token: &NamespaceToken,
1876        filter: crate::curation::EdgeListFilter,
1877        limit: u32,
1878    ) -> RuntimeResult<Vec<Edge>> {
1879        let limit = limit.clamp(1, 1000);
1880        let page = self
1881            .graph(token)?
1882            .query_edges(
1883                filter.into(),
1884                vec![SortOrder {
1885                    field: EdgeSortField::CreatedAt,
1886                    direction: khive_storage::types::SortDirection::Asc,
1887                }],
1888                PageRequest { offset: 0, limit },
1889            )
1890            .await?;
1891        Ok(page.items)
1892    }
1893
1894    /// Patch-style edge update. Only `Some(_)` fields are applied.
1895    ///
1896    /// When `relation` is `Some(new_rel)`, validates that the edge's existing endpoints
1897    /// are legal for `new_rel` before persisting. Weight-only updates (`relation = None`)
1898    /// skip validation. Returns `InvalidInput` if the new relation would violate the
1899    /// ADR-002/ADR-019/ADR-024 three-case contract; the edge is NOT mutated on error.
1900    ///
1901    /// ADR-002 §134: when the resulting relation is symmetric (`competes_with`,
1902    /// `composed_with`), endpoint order is canonicalised to `source_uuid < target_uuid`
1903    /// after validation. If a canonical row already exists at the target triple, the
1904    /// non-canonical edge is deleted and the existing canonical row is refreshed
1905    /// (DELETE + UPDATE pattern, mirroring `merge_entity_sql`).
1906    pub async fn update_edge(
1907        &self,
1908        token: &NamespaceToken,
1909        edge_id: Uuid,
1910        patch: crate::curation::EdgePatch,
1911    ) -> RuntimeResult<Edge> {
1912        let graph = self.graph(token)?;
1913        let mut edge = graph
1914            .get_edge(LinkId::from(edge_id))
1915            .await?
1916            .ok_or_else(|| crate::RuntimeError::NotFound(format!("edge {edge_id}")))?;
1917
1918        let mut changed_fields: Vec<&'static str> = Vec::new();
1919        if let Some(r) = patch.relation {
1920            // Validate before mutating — use the existing endpoints with the new relation.
1921            self.validate_edge_relation_endpoints(token, edge.source_id, edge.target_id, r)
1922                .await?;
1923            edge.relation = r;
1924            changed_fields.push("relation");
1925        }
1926        if let Some(w) = patch.weight {
1927            edge.weight = w.clamp(0.0, 1.0);
1928            changed_fields.push("weight");
1929        }
1930        if let Some(props) = patch.properties {
1931            edge.metadata = Some(props);
1932        }
1933
1934        // ADR-002 §134: for symmetric relations, canonicalise endpoint order and check
1935        // for natural-key conflicts regardless of whether endpoints were flipped.
1936        //
1937        // The raw-SQL path is used for ALL symmetric relations because `upsert_edge`
1938        // resolves ON CONFLICT(namespace,id) first and cannot detect a duplicate at
1939        // the natural key (namespace, source_id, target_id, relation) with a different
1940        // id. Bug-fix: this path must also run when endpoints are already canonical
1941        // (endpoints_flipped=false) to catch conflicts arising from a relation change
1942        // that collides with an existing canonical row.
1943        let (canon_src, canon_tgt) =
1944            canonical_edge_endpoints(edge.relation, edge.source_id, edge.target_id);
1945
1946        if edge.relation.is_symmetric() {
1947            // Raw-SQL path (mirrors merge_entity_sql).
1948            let ns = token.namespace().as_str().to_string();
1949            let edge_id_str = edge_id.to_string();
1950            let relation_str = edge.relation.to_string();
1951            let canon_src_str = canon_src.to_string();
1952            let canon_tgt_str = canon_tgt.to_string();
1953            let weight = edge.weight;
1954            let metadata = edge
1955                .metadata
1956                .as_ref()
1957                .map(|v| serde_json::to_string(v).unwrap_or_default());
1958            let target_backend = edge.target_backend.clone();
1959
1960            let pool = self.backend().pool_arc();
1961
1962            // spawn_blocking returns Some(surviving_id) when a canonical conflict was
1963            // absorbed (the requested edge was deleted, existing canonical row refreshed),
1964            // or None when the requested edge was updated in-place.
1965            let surviving_id: Option<String> = tokio::task::spawn_blocking(move || {
1966                let guard = pool.writer()?;
1967                guard.transaction(|conn| {
1968                    let now_ts = chrono::Utc::now().timestamp();
1969
1970                    // Check for a conflicting canonical row (same namespace + natural key,
1971                    // different id). This catches conflicts whether or not endpoints were
1972                    // flipped — Bug 2 fix.
1973                    let conflict_id: Option<String> = conn
1974                        .query_row(
1975                            "SELECT id FROM graph_edges \
1976                             WHERE namespace = ?1 AND source_id = ?2 AND target_id = ?3 \
1977                             AND relation = ?4 AND id != ?5",
1978                            rusqlite::params![
1979                                &ns,
1980                                &canon_src_str,
1981                                &canon_tgt_str,
1982                                &relation_str,
1983                                &edge_id_str,
1984                            ],
1985                            |row| row.get(0),
1986                        )
1987                        .optional()
1988                        .map_err(SqliteError::Rusqlite)?;
1989
1990                    if let Some(existing_id) = conflict_id {
1991                        // Case (b): canonical row already exists — delete the non-canonical
1992                        // edge and refresh the existing canonical row. Return the surviving
1993                        // id so the caller can re-fetch it (Bug 1 fix: do not return the
1994                        // deleted edge's id).
1995                        conn.execute(
1996                            "DELETE FROM graph_edges WHERE namespace = ?1 AND id = ?2",
1997                            rusqlite::params![&ns, &edge_id_str],
1998                        )
1999                        .map_err(SqliteError::Rusqlite)?;
2000                        conn.execute(
2001                            "UPDATE graph_edges SET \
2002                             weight = ?1, updated_at = ?2, deleted_at = NULL, \
2003                             target_backend = ?3, metadata = ?4 \
2004                             WHERE namespace = ?5 AND id = ?6",
2005                            rusqlite::params![
2006                                weight,
2007                                now_ts,
2008                                target_backend,
2009                                metadata,
2010                                &ns,
2011                                &existing_id,
2012                            ],
2013                        )
2014                        .map_err(SqliteError::Rusqlite)?;
2015                        Ok(Some(existing_id))
2016                    } else {
2017                        // Case (a): no conflict — update source_id/target_id in-place,
2018                        // preserving the original edge UUID.
2019                        conn.execute(
2020                            "UPDATE graph_edges SET \
2021                             source_id = ?1, target_id = ?2, relation = ?3, \
2022                             weight = ?4, updated_at = ?5, metadata = ?6 \
2023                             WHERE namespace = ?7 AND id = ?8",
2024                            rusqlite::params![
2025                                &canon_src_str,
2026                                &canon_tgt_str,
2027                                &relation_str,
2028                                weight,
2029                                now_ts,
2030                                metadata,
2031                                &ns,
2032                                &edge_id_str,
2033                            ],
2034                        )
2035                        .map_err(SqliteError::Rusqlite)?;
2036                        Ok(None)
2037                    }
2038                })
2039            })
2040            .await
2041            .map_err(|e| RuntimeError::Internal(format!("update_edge: spawn_blocking join: {e}")))?
2042            .map_err(RuntimeError::Sqlite)?;
2043
2044            if let Some(sid) = surviving_id {
2045                // A conflict was absorbed: re-fetch the surviving canonical row so the
2046                // caller receives its real id (Bug 1 fix).
2047                let surviving_uuid = Uuid::parse_str(&sid).map_err(|e| {
2048                    RuntimeError::Internal(format!("update_edge: surviving id parse failed: {e}"))
2049                })?;
2050                edge = self
2051                    .get_edge(token, surviving_uuid)
2052                    .await?
2053                    .ok_or_else(|| {
2054                        RuntimeError::Internal(format!(
2055                            "update_edge: surviving canonical row {surviving_uuid} vanished after update"
2056                        ))
2057                    })?;
2058            } else {
2059                // Reflect canonical endpoints in the returned edge (no conflict absorbed).
2060                edge.source_id = canon_src;
2061                edge.target_id = canon_tgt;
2062            }
2063        } else {
2064            graph.upsert_edge(edge.clone()).await?;
2065        }
2066
2067        let event_store = self.events(token)?;
2068        let ns = token.namespace().as_str().to_string();
2069        let event = khive_storage::event::Event::new(
2070            ns.clone(),
2071            "update",
2072            EventKind::EdgeUpdated,
2073            SubstrateKind::Entity,
2074            "",
2075        )
2076        .with_target(edge_id)
2077        .with_payload(
2078            serde_json::json!({"id": edge_id, "namespace": ns, "changed_fields": changed_fields}),
2079        );
2080        event_store.append_event(event).await.map_err(|e| {
2081            RuntimeError::Internal(format!("update_edge: event store write failed: {e}"))
2082        })?;
2083
2084        Ok(edge)
2085    }
2086
2087    /// Hard-delete an edge by id.
2088    ///
2089    /// Cascades to remove any `annotates` edges whose target is the deleted edge
2090    /// (ADR-002: `annotates` is note → anything; deleting an edge target leaves
2091    /// annotation edges dangling if not cleaned up). Returns `true` if the primary
2092    /// edge was removed.
2093    ///
2094    /// If `edge_id` does not refer to an edge (e.g. the caller passes an entity or
2095    /// note UUID by mistake), this method returns `Ok(false)` immediately with no
2096    /// side effects — it does **not** cascade inbound edges of the non-edge record.
2097    pub async fn delete_edge(
2098        &self,
2099        token: &NamespaceToken,
2100        edge_id: Uuid,
2101        hard: bool,
2102    ) -> RuntimeResult<bool> {
2103        let graph = self.graph(token)?;
2104        let mode = if hard {
2105            DeleteMode::Hard
2106        } else {
2107            DeleteMode::Soft
2108        };
2109
2110        // Guard: verify `edge_id` is actually an edge before touching anything.
2111        // Without this check, passing an entity/note UUID would delete all inbound
2112        // annotates edges targeting that record and then return false — a destructive
2113        // side effect on an invalid call.
2114        if graph.get_edge(LinkId::from(edge_id)).await?.is_none() {
2115            return Ok(false);
2116        }
2117
2118        // Cascade: remove annotate edges that target this edge (inbound from note sources).
2119        let inbound = graph
2120            .neighbors(
2121                edge_id,
2122                NeighborQuery {
2123                    direction: Direction::In,
2124                    relations: None,
2125                    limit: None,
2126                    min_weight: None,
2127                },
2128            )
2129            .await?;
2130        for hit in inbound {
2131            graph
2132                .delete_edge(LinkId::from(hit.edge_id), DeleteMode::Hard)
2133                .await?;
2134        }
2135
2136        let deleted = graph.delete_edge(LinkId::from(edge_id), mode).await?;
2137        if deleted {
2138            let event_store = self.events(token)?;
2139            let ns = token.namespace().as_str().to_string();
2140            let event = khive_storage::event::Event::new(
2141                ns.clone(),
2142                "delete",
2143                EventKind::EdgeDeleted,
2144                SubstrateKind::Entity,
2145                "",
2146            )
2147            .with_target(edge_id)
2148            .with_payload(serde_json::json!({"id": edge_id, "namespace": ns, "hard": hard}));
2149            event_store.append_event(event).await.map_err(|e| {
2150                RuntimeError::Internal(format!("delete_edge: event store write failed: {e}"))
2151            })?;
2152        }
2153        Ok(deleted)
2154    }
2155
2156    /// Count edges matching `filter`.
2157    pub async fn count_edges(
2158        &self,
2159        token: &NamespaceToken,
2160        filter: crate::curation::EdgeListFilter,
2161    ) -> RuntimeResult<u64> {
2162        Ok(self.graph(token)?.count_edges(filter.into()).await?)
2163    }
2164
2165    /// Validate and construct an edge from a [`LinkSpec`] without writing to storage.
2166    ///
2167    /// Applies the full ADR-002 contract (endpoint validation, symmetric
2168    /// canonicalization, `dependency_kind` inference and metadata validation).
2169    /// Returns the constructed `Edge` on success; the caller is responsible for
2170    /// persisting it (e.g. via `upsert_edge` or `link_many`).
2171    ///
2172    /// The `token` must be a pre-authorized namespace token from the dispatch
2173    /// layer. If `spec.namespace` is set it must match `token.namespace()`;
2174    /// a mismatch returns `RuntimeError::InvalidInput` (ADR-007).
2175    pub async fn build_edge(&self, token: &NamespaceToken, spec: &LinkSpec) -> RuntimeResult<Edge> {
2176        let ns_str = match &spec.namespace {
2177            Some(s) => {
2178                let spec_ns = crate::Namespace::parse(s)
2179                    .map_err(|e| RuntimeError::InvalidInput(format!("invalid namespace: {e}")))?;
2180                if &spec_ns != token.namespace() {
2181                    return Err(RuntimeError::InvalidInput(
2182                        "LinkSpec namespace does not match token namespace".into(),
2183                    ));
2184                }
2185                s.as_str()
2186            }
2187            None => token.namespace().as_str(),
2188        };
2189        self.validate_edge_relation_endpoints(token, spec.source_id, spec.target_id, spec.relation)
2190            .await?;
2191        let (source_id, target_id) =
2192            canonical_edge_endpoints(spec.relation, spec.source_id, spec.target_id);
2193        let metadata = if spec.relation == EdgeRelation::DependsOn {
2194            match (
2195                self.resolve(token, source_id).await?,
2196                self.resolve(token, target_id).await?,
2197            ) {
2198                (Some(Resolved::Entity(src_e)), Some(Resolved::Entity(tgt_e))) => {
2199                    merge_dependency_kind(&src_e.kind, &tgt_e.kind, spec.metadata.clone())
2200                }
2201                _ => spec.metadata.clone(),
2202            }
2203        } else {
2204            spec.metadata.clone()
2205        };
2206        validate_edge_metadata(spec.relation, metadata.as_ref())?;
2207        let now = chrono::Utc::now();
2208        Ok(Edge {
2209            id: LinkId::from(Uuid::new_v4()),
2210            namespace: ns_str.to_string(),
2211            source_id,
2212            target_id,
2213            relation: spec.relation,
2214            weight: spec.weight,
2215            created_at: now,
2216            updated_at: now,
2217            deleted_at: None,
2218            metadata,
2219            target_backend: None,
2220        })
2221    }
2222
2223    /// Validate and atomically upsert a batch of edges.
2224    ///
2225    /// All edges are validated and constructed with `build_edge` before any
2226    /// write. If validation fails for any entry the entire batch is rejected
2227    /// (no writes occur). On success, all edges are persisted in a single
2228    /// atomic transaction via `upsert_edges`.
2229    ///
2230    /// After the bulk upsert, each edge is read back by its natural key
2231    /// (namespace, source_id, target_id, relation) so that the returned IDs
2232    /// are always the persisted row IDs, not the locally-generated UUIDs that
2233    /// may have been displaced by an ON CONFLICT DO UPDATE. This mirrors the
2234    /// H1 fix applied to singleton `link()` and prevents phantom-ID exposure
2235    /// when callers upsert overlapping triples with `verbose=true`.
2236    ///
2237    /// All specs must share the same namespace; the namespace is taken from
2238    /// `token` (or validated against it if `spec.namespace` is set).
2239    pub async fn link_many(
2240        &self,
2241        token: &NamespaceToken,
2242        specs: Vec<LinkSpec>,
2243    ) -> RuntimeResult<Vec<Edge>> {
2244        if specs.is_empty() {
2245            return Ok(vec![]);
2246        }
2247        let mut edges = Vec::with_capacity(specs.len());
2248        for spec in &specs {
2249            edges.push(self.build_edge(token, spec).await?);
2250        }
2251        self.graph(token)?.upsert_edges(edges.clone()).await?;
2252
2253        // H1-bulk fix: read back each persisted edge by natural key so callers
2254        // always receive the stored row ID, not the pre-upsert generated UUID.
2255        let mut persisted = Vec::with_capacity(edges.len());
2256        for edge in &edges {
2257            let row = self
2258                .list_edges(
2259                    token,
2260                    crate::curation::EdgeListFilter {
2261                        source_id: Some(edge.source_id),
2262                        target_id: Some(edge.target_id),
2263                        relations: vec![edge.relation],
2264                        ..Default::default()
2265                    },
2266                    1,
2267                )
2268                .await?
2269                .into_iter()
2270                .next()
2271                .ok_or_else(|| {
2272                    crate::RuntimeError::Internal(format!(
2273                        "upsert_edges succeeded but natural-key lookup for ({}, {}, {}) returned nothing",
2274                        edge.source_id, edge.target_id, edge.relation.as_str()
2275                    ))
2276                })?;
2277            persisted.push(row);
2278        }
2279        Ok(persisted)
2280    }
2281}
2282
2283/// Fully specified edge creation request — input to [`KhiveRuntime::build_edge`]
2284/// and [`KhiveRuntime::link_many`].
2285#[derive(Clone, Debug)]
2286pub struct LinkSpec {
2287    pub namespace: Option<String>,
2288    pub source_id: Uuid,
2289    pub target_id: Uuid,
2290    pub relation: EdgeRelation,
2291    pub weight: f64,
2292    pub metadata: Option<serde_json::Value>,
2293}
2294
2295#[cfg(test)]
2296mod tests {
2297    use super::*;
2298    use crate::curation::EdgeListFilter;
2299    use crate::embedder_registry::EmbedderProvider;
2300    use crate::error::RuntimeError;
2301    use crate::runtime::{KhiveRuntime, NamespaceToken};
2302    use crate::Namespace;
2303    use async_trait::async_trait;
2304    use lattice_embed::{EmbedError, EmbeddingModel, EmbeddingService};
2305    use std::sync::atomic::{AtomicUsize, Ordering};
2306    use std::sync::Arc;
2307
2308    fn rt() -> KhiveRuntime {
2309        KhiveRuntime::memory().unwrap()
2310    }
2311
2312    // ── Fix-1 regression (codex High #1, PR #444) ────────────────────────────
2313    // A runtime with no `config.embedding_model` but a custom registered
2314    // embedder must fan out create_note through that embedder and store a
2315    // vector so recall can find the note.
2316
2317    /// Trivial constant-vector embedding service.  The model argument is ignored;
2318    /// the service always returns a synthetic `dims × 1.0f32` vector.
2319    struct ConstVecService {
2320        dims: usize,
2321    }
2322
2323    #[async_trait]
2324    impl EmbeddingService for ConstVecService {
2325        async fn embed(
2326            &self,
2327            texts: &[String],
2328            _model: EmbeddingModel,
2329        ) -> std::result::Result<Vec<Vec<f32>>, EmbedError> {
2330            Ok(texts.iter().map(|_| vec![1.0_f32; self.dims]).collect())
2331        }
2332
2333        fn supports_model(&self, _model: EmbeddingModel) -> bool {
2334            true
2335        }
2336
2337        fn name(&self) -> &'static str {
2338            "const-vec"
2339        }
2340    }
2341
2342    struct ConstVecProvider {
2343        provider_name: String,
2344        dims: usize,
2345        pub build_count: Arc<AtomicUsize>,
2346    }
2347
2348    impl ConstVecProvider {
2349        fn new(name: &str, dims: usize) -> (Self, Arc<AtomicUsize>) {
2350            let counter = Arc::new(AtomicUsize::new(0));
2351            let provider = Self {
2352                provider_name: name.to_owned(),
2353                dims,
2354                build_count: Arc::clone(&counter),
2355            };
2356            (provider, counter)
2357        }
2358    }
2359
2360    #[async_trait]
2361    impl EmbedderProvider for ConstVecProvider {
2362        fn name(&self) -> &str {
2363            &self.provider_name
2364        }
2365
2366        fn dimensions(&self) -> usize {
2367            self.dims
2368        }
2369
2370        async fn build(&self) -> crate::error::RuntimeResult<Arc<dyn EmbeddingService>> {
2371            self.build_count.fetch_add(1, Ordering::SeqCst);
2372            Ok(Arc::new(ConstVecService { dims: self.dims }))
2373        }
2374    }
2375
2376    /// Fix 1 regression: custom embedder with no lattice model in config must
2377    /// participate in fan-out.
2378    ///
2379    /// This test was previously broken because the fan-out gate checked
2380    /// `config().embedding_model.is_some()`.  With only a custom provider
2381    /// registered and `embedding_model = None` in config, the gate fell through
2382    /// to `vec![]` and no vector was written.  After the fix the gate checks
2383    /// `registered_embedding_model_names()` instead.
2384    #[tokio::test]
2385    async fn custom_embedder_only_runtime_fanout_stores_vector() {
2386        const MODEL_NAME: &str = "test-custom-encoder";
2387        const DIMS: usize = 8;
2388
2389        // Build a runtime with no lattice embedding_model.
2390        let rt = KhiveRuntime::memory().unwrap();
2391
2392        // Register the custom provider — this is the only embedder configured.
2393        let (provider, _counter) = ConstVecProvider::new(MODEL_NAME, DIMS);
2394        rt.register_embedder(provider);
2395
2396        // Sanity: config.embedding_model is None, but the registry has one entry.
2397        assert!(rt.config().embedding_model.is_none());
2398        assert_eq!(rt.registered_embedding_model_names(), vec![MODEL_NAME]);
2399
2400        let tok = NamespaceToken::local();
2401
2402        // create_note should fan out to the custom embedder and store a vector.
2403        let note = rt
2404            .create_note(
2405                &tok,
2406                "memory",
2407                None,
2408                "custom embedder integration test content",
2409                Some(0.7),
2410                None,
2411                vec![],
2412            )
2413            .await
2414            .expect("create_note with custom-only embedder must succeed");
2415
2416        // Verify: a vector was written in the custom model's store.
2417        use khive_storage::types::VectorSearchRequest;
2418        let query_vec = vec![1.0_f32; DIMS];
2419        let hits = rt
2420            .vectors_for_model(&tok, MODEL_NAME)
2421            .expect("vector store for custom model must be accessible")
2422            .search(VectorSearchRequest {
2423                query_vectors: vec![query_vec],
2424                top_k: 5,
2425                namespace: Some(tok.namespace().as_str().to_string()),
2426                kind: Some(khive_types::SubstrateKind::Note),
2427                embedding_model: Some(MODEL_NAME.to_string()),
2428                filter: None,
2429                backend_hints: None,
2430            })
2431            .await
2432            .expect("vector search succeeds");
2433
2434        assert!(
2435            hits.iter().any(|h| h.subject_id == note.id),
2436            "custom embedder must have written a vector for note {}: hits={hits:?}",
2437            note.id
2438        );
2439    }
2440
2441    /// Fix 1 regression (recall path): custom-only embedder participates in
2442    /// embed_with_model so recall fan-out also works.
2443    ///
2444    /// Previously `embed_with_model` called `resolve_embedding_model` which
2445    /// required a lattice alias; custom provider names were rejected with
2446    /// `UnknownModel`.  After the fix, the lattice alias parse is optional
2447    /// and the embedder registry is consulted directly.
2448    #[tokio::test]
2449    async fn embed_with_model_accepts_custom_provider_name() {
2450        const MODEL_NAME: &str = "my-custom-enc";
2451        const DIMS: usize = 4;
2452
2453        let rt = KhiveRuntime::memory().unwrap();
2454        let (provider, _counter) = ConstVecProvider::new(MODEL_NAME, DIMS);
2455        rt.register_embedder(provider);
2456
2457        let result = rt
2458            .embed_with_model(MODEL_NAME, "hello world")
2459            .await
2460            .expect("embed_with_model must accept custom provider names");
2461
2462        assert_eq!(
2463            result.len(),
2464            DIMS,
2465            "embedding dimension must match provider"
2466        );
2467        assert!(
2468            result.iter().all(|&v| (v - 1.0_f32).abs() < 1e-6),
2469            "ConstVecService must produce all-ones vector; got: {result:?}"
2470        );
2471    }
2472
2473    /// Fix 1 regression: embed_with_model must still reject names that are not
2474    /// in the registry (neither lattice aliases nor custom providers).
2475    #[tokio::test]
2476    async fn embed_with_model_rejects_unregistered_name() {
2477        let rt = KhiveRuntime::memory().unwrap();
2478        let result = rt.embed_with_model("nonexistent-model", "hello").await;
2479        assert!(
2480            matches!(result.unwrap_err(), RuntimeError::UnknownModel(ref n) if n == "nonexistent-model"),
2481            "unregistered model name must return UnknownModel"
2482        );
2483    }
2484
2485    #[tokio::test]
2486    async fn update_edge_changes_weight() {
2487        let rt = rt();
2488        let tok = NamespaceToken::local();
2489        let a = rt
2490            .create_entity(&tok, "concept", None, "A", None, None, vec![])
2491            .await
2492            .unwrap();
2493        let b = rt
2494            .create_entity(&tok, "concept", None, "B", None, None, vec![])
2495            .await
2496            .unwrap();
2497        let edge = rt
2498            .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
2499            .await
2500            .unwrap();
2501        let edge_id: Uuid = edge.id.into();
2502
2503        let updated = rt
2504            .update_edge(
2505                &tok,
2506                edge_id,
2507                crate::curation::EdgePatch {
2508                    weight: Some(0.5),
2509                    ..Default::default()
2510                },
2511            )
2512            .await
2513            .unwrap();
2514        assert!((updated.weight - 0.5).abs() < 0.001);
2515    }
2516
2517    #[tokio::test]
2518    async fn update_edge_changes_relation() {
2519        let rt = rt();
2520        let tok = NamespaceToken::local();
2521        let a = rt
2522            .create_entity(&tok, "concept", None, "A", None, None, vec![])
2523            .await
2524            .unwrap();
2525        let b = rt
2526            .create_entity(&tok, "concept", None, "B", None, None, vec![])
2527            .await
2528            .unwrap();
2529        let edge = rt
2530            .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
2531            .await
2532            .unwrap();
2533        let edge_id: Uuid = edge.id.into();
2534
2535        let updated = rt
2536            .update_edge(
2537                &tok,
2538                edge_id,
2539                crate::curation::EdgePatch {
2540                    relation: Some(EdgeRelation::VariantOf),
2541                    ..Default::default()
2542                },
2543            )
2544            .await
2545            .unwrap();
2546        assert_eq!(updated.relation, EdgeRelation::VariantOf);
2547    }
2548
2549    // ---- Round-5 tests: update_edge endpoint validation (ADR-002 bypass fix) ----
2550
2551    // update_edge: note→entity annotates → set relation=Supersedes → InvalidInput (crossing).
2552    // Edge must NOT be mutated in the store.
2553    #[tokio::test]
2554    async fn update_edge_annotates_note_to_entity_set_supersedes_returns_invalid_input() {
2555        let rt = rt();
2556        let tok = NamespaceToken::local();
2557        let note = rt
2558            .create_note(&tok, "observation", None, "a note", Some(0.5), None, vec![])
2559            .await
2560            .unwrap();
2561        let entity = rt
2562            .create_entity(&tok, "concept", None, "E", None, None, vec![])
2563            .await
2564            .unwrap();
2565        // Create a valid note→entity annotates edge.
2566        let edge = rt
2567            .link(&tok, note.id, entity.id, EdgeRelation::Annotates, 1.0, None)
2568            .await
2569            .unwrap();
2570        let edge_id: Uuid = edge.id.into();
2571
2572        // Attempt to change relation to Supersedes (crossing substrates → invalid).
2573        let result = rt
2574            .update_edge(
2575                &tok,
2576                edge_id,
2577                crate::curation::EdgePatch {
2578                    relation: Some(EdgeRelation::Supersedes),
2579                    ..Default::default()
2580                },
2581            )
2582            .await;
2583        assert!(
2584            matches!(result, Err(RuntimeError::InvalidInput(_))),
2585            "update to Supersedes on note→entity edge must return InvalidInput, got {result:?}"
2586        );
2587
2588        // Edge must NOT be mutated — re-fetch and verify relation unchanged.
2589        let fetched = rt.get_edge(&tok, edge_id).await.unwrap().unwrap();
2590        assert_eq!(
2591            fetched.relation,
2592            EdgeRelation::Annotates,
2593            "edge relation must be unchanged after failed update"
2594        );
2595    }
2596
2597    // update_edge: entity→entity extends → set relation=Annotates → InvalidInput
2598    // (annotates source must be a note).
2599    #[tokio::test]
2600    async fn update_edge_entity_to_entity_set_annotates_returns_invalid_input() {
2601        let rt = rt();
2602        let tok = NamespaceToken::local();
2603        let a = rt
2604            .create_entity(&tok, "concept", None, "A", None, None, vec![])
2605            .await
2606            .unwrap();
2607        let b = rt
2608            .create_entity(&tok, "concept", None, "B", None, None, vec![])
2609            .await
2610            .unwrap();
2611        let edge = rt
2612            .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
2613            .await
2614            .unwrap();
2615        let edge_id: Uuid = edge.id.into();
2616
2617        let result = rt
2618            .update_edge(
2619                &tok,
2620                edge_id,
2621                crate::curation::EdgePatch {
2622                    relation: Some(EdgeRelation::Annotates),
2623                    ..Default::default()
2624                },
2625            )
2626            .await;
2627        assert!(
2628            matches!(result, Err(RuntimeError::InvalidInput(_))),
2629            "update to Annotates on entity→entity edge must return InvalidInput, got {result:?}"
2630        );
2631    }
2632
2633    // update_edge: entity→entity extends → set relation=Supersedes → Ok
2634    // (entity→entity is valid for supersedes).
2635    #[tokio::test]
2636    async fn update_edge_entity_to_entity_set_supersedes_succeeds() {
2637        let rt = rt();
2638        let tok = NamespaceToken::local();
2639        let a = rt
2640            .create_entity(&tok, "concept", None, "A", None, None, vec![])
2641            .await
2642            .unwrap();
2643        let b = rt
2644            .create_entity(&tok, "concept", None, "B", None, None, vec![])
2645            .await
2646            .unwrap();
2647        let edge = rt
2648            .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
2649            .await
2650            .unwrap();
2651        let edge_id: Uuid = edge.id.into();
2652
2653        let updated = rt
2654            .update_edge(
2655                &tok,
2656                edge_id,
2657                crate::curation::EdgePatch {
2658                    relation: Some(EdgeRelation::Supersedes),
2659                    ..Default::default()
2660                },
2661            )
2662            .await
2663            .unwrap();
2664        assert_eq!(updated.relation, EdgeRelation::Supersedes);
2665
2666        // Verify persisted.
2667        let fetched = rt.get_edge(&tok, edge_id).await.unwrap().unwrap();
2668        assert_eq!(fetched.relation, EdgeRelation::Supersedes);
2669    }
2670
2671    // update_edge: weight-only (relation = None) → Ok, no validation, unchanged relation.
2672    #[tokio::test]
2673    async fn update_edge_weight_only_skips_validation() {
2674        let rt = rt();
2675        let tok = NamespaceToken::local();
2676        let a = rt
2677            .create_entity(&tok, "concept", None, "A", None, None, vec![])
2678            .await
2679            .unwrap();
2680        let b = rt
2681            .create_entity(&tok, "concept", None, "B", None, None, vec![])
2682            .await
2683            .unwrap();
2684        let edge = rt
2685            .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
2686            .await
2687            .unwrap();
2688        let edge_id: Uuid = edge.id.into();
2689
2690        let updated = rt
2691            .update_edge(
2692                &tok,
2693                edge_id,
2694                crate::curation::EdgePatch {
2695                    weight: Some(0.3),
2696                    ..Default::default()
2697                },
2698            )
2699            .await
2700            .unwrap();
2701        assert_eq!(updated.relation, EdgeRelation::Extends);
2702        assert!((updated.weight - 0.3).abs() < 0.001);
2703    }
2704
2705    // update_edge: entity→entity extends → set relation=VariantOf (same class) → Ok.
2706    #[tokio::test]
2707    async fn update_edge_same_class_relation_change_succeeds() {
2708        let rt = rt();
2709        let tok = NamespaceToken::local();
2710        let a = rt
2711            .create_entity(&tok, "concept", None, "A", None, None, vec![])
2712            .await
2713            .unwrap();
2714        let b = rt
2715            .create_entity(&tok, "concept", None, "B", None, None, vec![])
2716            .await
2717            .unwrap();
2718        let edge = rt
2719            .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
2720            .await
2721            .unwrap();
2722        let edge_id: Uuid = edge.id.into();
2723
2724        let updated = rt
2725            .update_edge(
2726                &tok,
2727                edge_id,
2728                crate::curation::EdgePatch {
2729                    relation: Some(EdgeRelation::VariantOf),
2730                    ..Default::default()
2731                },
2732            )
2733            .await
2734            .unwrap();
2735        assert_eq!(updated.relation, EdgeRelation::VariantOf);
2736    }
2737
2738    #[tokio::test]
2739    async fn list_edges_filters_by_relation() {
2740        let rt = rt();
2741        let tok = NamespaceToken::local();
2742        let a = rt
2743            .create_entity(&tok, "concept", None, "A", None, None, vec![])
2744            .await
2745            .unwrap();
2746        let b = rt
2747            .create_entity(&tok, "concept", None, "B", None, None, vec![])
2748            .await
2749            .unwrap();
2750        let c = rt
2751            .create_entity(&tok, "concept", None, "C", None, None, vec![])
2752            .await
2753            .unwrap();
2754
2755        rt.link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
2756            .await
2757            .unwrap();
2758        rt.link(&tok, a.id, c.id, EdgeRelation::Enables, 1.0, None)
2759            .await
2760            .unwrap();
2761
2762        let filter = EdgeListFilter {
2763            relations: vec![EdgeRelation::Extends],
2764            ..Default::default()
2765        };
2766        let edges = rt.list_edges(&tok, filter, 100).await.unwrap();
2767        assert_eq!(edges.len(), 1);
2768        assert_eq!(edges[0].relation, EdgeRelation::Extends);
2769    }
2770
2771    #[tokio::test]
2772    async fn list_edges_filters_by_source() {
2773        let rt = rt();
2774        let tok = NamespaceToken::local();
2775        let a = rt
2776            .create_entity(&tok, "concept", None, "A", None, None, vec![])
2777            .await
2778            .unwrap();
2779        let b = rt
2780            .create_entity(&tok, "concept", None, "B", None, None, vec![])
2781            .await
2782            .unwrap();
2783        let c = rt
2784            .create_entity(&tok, "concept", None, "C", None, None, vec![])
2785            .await
2786            .unwrap();
2787        let d = rt
2788            .create_entity(&tok, "concept", None, "D", None, None, vec![])
2789            .await
2790            .unwrap();
2791
2792        rt.link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
2793            .await
2794            .unwrap();
2795        rt.link(&tok, c.id, d.id, EdgeRelation::Extends, 1.0, None)
2796            .await
2797            .unwrap();
2798
2799        let filter = EdgeListFilter {
2800            source_id: Some(a.id),
2801            ..Default::default()
2802        };
2803        let edges = rt.list_edges(&tok, filter, 100).await.unwrap();
2804        assert_eq!(edges.len(), 1);
2805        let src: Uuid = edges[0].source_id;
2806        assert_eq!(src, a.id);
2807    }
2808
2809    #[tokio::test]
2810    async fn delete_edge_removes_from_storage() {
2811        let rt = rt();
2812        let tok = NamespaceToken::local();
2813        let a = rt
2814            .create_entity(&tok, "concept", None, "A", None, None, vec![])
2815            .await
2816            .unwrap();
2817        let b = rt
2818            .create_entity(&tok, "concept", None, "B", None, None, vec![])
2819            .await
2820            .unwrap();
2821        let edge = rt
2822            .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
2823            .await
2824            .unwrap();
2825        let edge_id: Uuid = edge.id.into();
2826
2827        let deleted = rt.delete_edge(&tok, edge_id, true).await.unwrap();
2828        assert!(deleted);
2829
2830        let fetched = rt.get_edge(&tok, edge_id).await.unwrap();
2831        assert!(fetched.is_none(), "edge should be gone after delete");
2832    }
2833
2834    #[tokio::test]
2835    async fn count_edges_matches_filter() {
2836        let rt = rt();
2837        let tok = NamespaceToken::local();
2838        let a = rt
2839            .create_entity(&tok, "concept", None, "A", None, None, vec![])
2840            .await
2841            .unwrap();
2842        let b = rt
2843            .create_entity(&tok, "concept", None, "B", None, None, vec![])
2844            .await
2845            .unwrap();
2846        let c = rt
2847            .create_entity(&tok, "concept", None, "C", None, None, vec![])
2848            .await
2849            .unwrap();
2850
2851        rt.link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
2852            .await
2853            .unwrap();
2854        rt.link(&tok, a.id, c.id, EdgeRelation::Enables, 1.0, None)
2855            .await
2856            .unwrap();
2857
2858        let all = rt
2859            .count_edges(&tok, EdgeListFilter::default())
2860            .await
2861            .unwrap();
2862        assert_eq!(all, 2);
2863
2864        let just_extends = rt
2865            .count_edges(
2866                &tok,
2867                EdgeListFilter {
2868                    relations: vec![EdgeRelation::Extends],
2869                    ..Default::default()
2870                },
2871            )
2872            .await
2873            .unwrap();
2874        assert_eq!(just_extends, 1);
2875    }
2876
2877    #[tokio::test]
2878    async fn get_entity_namespace_isolation() {
2879        let rt = rt();
2880        let ns_a = NamespaceToken::for_namespace(Namespace::parse("ns-a").unwrap());
2881        let ns_b = NamespaceToken::for_namespace(Namespace::parse("ns-b").unwrap());
2882        let entity = rt
2883            .create_entity(&ns_a, "concept", None, "Alpha", None, None, vec![])
2884            .await
2885            .unwrap();
2886
2887        // Same namespace: visible.
2888        let found = rt.get_entity(&ns_a, entity.id).await;
2889        assert!(found.is_ok(), "should be visible in its own namespace");
2890
2891        // Different namespace: NotFound error (ADR-007 — no cross-namespace existence oracle).
2892        let not_found = rt.get_entity(&ns_b, entity.id).await;
2893        assert!(
2894            not_found.is_err(),
2895            "should not be visible across namespaces"
2896        );
2897        assert!(
2898            matches!(not_found.unwrap_err(), crate::RuntimeError::NotFound(_)),
2899            "cross-namespace get must return NotFound, not NamespaceMismatch"
2900        );
2901    }
2902
2903    #[tokio::test]
2904    async fn namespace_mismatch_error_message_is_opaque() {
2905        // ADR-007 timing-oracle mitigation: the external error message must not
2906        // reveal which namespace the record actually lives in.
2907        let rt = rt();
2908        let ns_a = NamespaceToken::for_namespace(Namespace::parse("secret-ns").unwrap());
2909        let ns_b = NamespaceToken::for_namespace(Namespace::parse("other-ns").unwrap());
2910        let entity = rt
2911            .create_entity(&ns_a, "concept", None, "Hidden", None, None, vec![])
2912            .await
2913            .unwrap();
2914
2915        let err = rt.get_entity(&ns_b, entity.id).await.unwrap_err();
2916        let msg = err.to_string();
2917        assert!(
2918            !msg.contains("secret-ns"),
2919            "error message must not leak the actual namespace; got: {msg}"
2920        );
2921        assert!(
2922            !msg.contains("other-ns"),
2923            "error message must not leak the requested namespace; got: {msg}"
2924        );
2925    }
2926
2927    #[tokio::test]
2928    async fn delete_entity_namespace_isolation() {
2929        let rt = rt();
2930        let ns_a = NamespaceToken::for_namespace(Namespace::parse("ns-a").unwrap());
2931        let ns_b = NamespaceToken::for_namespace(Namespace::parse("ns-b").unwrap());
2932        let entity = rt
2933            .create_entity(&ns_a, "concept", None, "Beta", None, None, vec![])
2934            .await
2935            .unwrap();
2936
2937        // Delete from wrong namespace: NotFound (ADR-007 — no existence oracle).
2938        let cross_ns_result = rt.delete_entity(&ns_b, entity.id, true).await;
2939        assert!(
2940            cross_ns_result.is_err(),
2941            "cross-namespace delete must error"
2942        );
2943        assert!(
2944            matches!(
2945                cross_ns_result.unwrap_err(),
2946                crate::RuntimeError::NotFound(_)
2947            ),
2948            "cross-namespace delete must return NotFound, not NamespaceMismatch"
2949        );
2950
2951        // Entity still present in its own namespace.
2952        let still_there = rt.get_entity(&ns_a, entity.id).await;
2953        assert!(
2954            still_there.is_ok(),
2955            "entity must survive cross-ns delete attempt"
2956        );
2957
2958        // Delete from correct namespace: succeeds.
2959        let deleted_ok = rt.delete_entity(&ns_a, entity.id, true).await.unwrap();
2960        assert!(deleted_ok, "same-namespace delete must succeed");
2961    }
2962
2963    // ---- Note ADR-024 tests ----
2964
2965    #[tokio::test]
2966    async fn create_note_indexes_into_fts5() {
2967        let rt = rt();
2968        let tok = NamespaceToken::local();
2969        let note = rt
2970            .create_note(
2971                &tok,
2972                "observation",
2973                None,
2974                "FlashAttention reduces memory by using tiling",
2975                Some(0.8),
2976                None,
2977                vec![],
2978            )
2979            .await
2980            .unwrap();
2981
2982        // FTS5 should have indexed the note content.
2983        let ns = tok.namespace().as_str().to_string();
2984        let hits = rt
2985            .text_for_notes(&tok)
2986            .unwrap()
2987            .search(khive_storage::types::TextSearchRequest {
2988                query: "FlashAttention".to_string(),
2989                mode: khive_storage::types::TextQueryMode::Plain,
2990                filter: Some(khive_storage::types::TextFilter {
2991                    namespaces: vec![ns],
2992                    ..Default::default()
2993                }),
2994                top_k: 10,
2995                snippet_chars: 100,
2996            })
2997            .await
2998            .unwrap();
2999
3000        assert!(
3001            hits.iter().any(|h| h.subject_id == note.id),
3002            "note should be indexed in FTS5 after create"
3003        );
3004    }
3005
3006    #[tokio::test]
3007    async fn create_note_with_properties() {
3008        let rt = rt();
3009        let tok = NamespaceToken::local();
3010        let props = serde_json::json!({"source": "arxiv:2205.14135"});
3011        let note = rt
3012            .create_note(
3013                &tok,
3014                "insight",
3015                None,
3016                "FlashAttention is IO-aware",
3017                Some(0.9),
3018                Some(props.clone()),
3019                vec![],
3020            )
3021            .await
3022            .unwrap();
3023
3024        assert_eq!(note.properties.as_ref().unwrap(), &props);
3025    }
3026
3027    #[tokio::test]
3028    async fn create_note_creates_annotates_edges() {
3029        let rt = rt();
3030        let tok = NamespaceToken::local();
3031        let entity = rt
3032            .create_entity(&tok, "concept", None, "FlashAttention", None, None, vec![])
3033            .await
3034            .unwrap();
3035
3036        let note = rt
3037            .create_note(
3038                &tok,
3039                "observation",
3040                None,
3041                "FlashAttention uses SRAM tiling for memory efficiency",
3042                Some(0.9),
3043                None,
3044                vec![entity.id],
3045            )
3046            .await
3047            .unwrap();
3048
3049        // The note should have an outbound `annotates` edge to the entity.
3050        let out_neighbors = rt
3051            .neighbors(
3052                &tok,
3053                note.id,
3054                Direction::Out,
3055                None,
3056                Some(vec![EdgeRelation::Annotates]),
3057            )
3058            .await
3059            .unwrap();
3060        assert_eq!(out_neighbors.len(), 1);
3061        assert_eq!(out_neighbors[0].node_id, entity.id);
3062        assert_eq!(out_neighbors[0].relation, EdgeRelation::Annotates);
3063
3064        // The entity should have an inbound `annotates` edge from the note.
3065        let in_neighbors = rt
3066            .neighbors(
3067                &tok,
3068                entity.id,
3069                Direction::In,
3070                None,
3071                Some(vec![EdgeRelation::Annotates]),
3072            )
3073            .await
3074            .unwrap();
3075        assert_eq!(in_neighbors.len(), 1);
3076        assert_eq!(in_neighbors[0].node_id, note.id);
3077    }
3078
3079    #[tokio::test]
3080    async fn neighbors_without_relation_filter_returns_all() {
3081        let rt = rt();
3082        let tok = NamespaceToken::local();
3083        let a = rt
3084            .create_entity(&tok, "concept", None, "A", None, None, vec![])
3085            .await
3086            .unwrap();
3087        let b = rt
3088            .create_entity(&tok, "concept", None, "B", None, None, vec![])
3089            .await
3090            .unwrap();
3091        let c = rt
3092            .create_entity(&tok, "concept", None, "C", None, None, vec![])
3093            .await
3094            .unwrap();
3095
3096        rt.link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
3097            .await
3098            .unwrap();
3099        rt.link(&tok, a.id, c.id, EdgeRelation::Enables, 1.0, None)
3100            .await
3101            .unwrap();
3102
3103        let all = rt
3104            .neighbors(&tok, a.id, Direction::Out, None, None)
3105            .await
3106            .unwrap();
3107        assert_eq!(all.len(), 2);
3108    }
3109
3110    #[tokio::test]
3111    async fn neighbors_with_relation_filter_returns_subset() {
3112        let rt = rt();
3113        let tok = NamespaceToken::local();
3114        let a = rt
3115            .create_entity(&tok, "concept", None, "A", None, None, vec![])
3116            .await
3117            .unwrap();
3118        let b = rt
3119            .create_entity(&tok, "concept", None, "B", None, None, vec![])
3120            .await
3121            .unwrap();
3122        let c = rt
3123            .create_entity(&tok, "concept", None, "C", None, None, vec![])
3124            .await
3125            .unwrap();
3126
3127        rt.link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
3128            .await
3129            .unwrap();
3130        rt.link(&tok, a.id, c.id, EdgeRelation::Enables, 1.0, None)
3131            .await
3132            .unwrap();
3133
3134        let filtered = rt
3135            .neighbors(
3136                &tok,
3137                a.id,
3138                Direction::Out,
3139                None,
3140                Some(vec![EdgeRelation::Extends]),
3141            )
3142            .await
3143            .unwrap();
3144        assert_eq!(filtered.len(), 1);
3145        assert_eq!(filtered[0].node_id, b.id);
3146        assert_eq!(filtered[0].relation, EdgeRelation::Extends);
3147    }
3148
3149    #[tokio::test]
3150    async fn search_notes_returns_relevant_note() {
3151        let rt = rt();
3152        let tok = NamespaceToken::local();
3153        rt.create_note(
3154            &tok,
3155            "observation",
3156            None,
3157            "GQA reduces KV cache memory for large models",
3158            Some(0.8),
3159            None,
3160            vec![],
3161        )
3162        .await
3163        .unwrap();
3164
3165        let results = rt
3166            .search_notes(&tok, "GQA KV cache", None, 10, None, false)
3167            .await
3168            .unwrap();
3169
3170        assert!(!results.is_empty(), "search should return the indexed note");
3171        let hit = &results[0];
3172        assert!(
3173            hit.title.is_some(),
3174            "note hit title should be populated (falls back to content)"
3175        );
3176        assert!(
3177            hit.snippet.is_some(),
3178            "note hit snippet should be populated"
3179        );
3180    }
3181
3182    #[tokio::test]
3183    async fn search_notes_excludes_soft_deleted() {
3184        let rt = rt();
3185        let tok = NamespaceToken::local();
3186        let note = rt
3187            .create_note(
3188                &tok,
3189                "observation",
3190                None,
3191                "RoPE positional encoding rotary embeddings",
3192                Some(0.7),
3193                None,
3194                vec![],
3195            )
3196            .await
3197            .unwrap();
3198
3199        // Soft-delete the note.
3200        rt.notes(&tok)
3201            .unwrap()
3202            .delete_note(note.id, DeleteMode::Soft)
3203            .await
3204            .unwrap();
3205
3206        let results = rt
3207            .search_notes(&tok, "RoPE rotary positional", None, 10, None, false)
3208            .await
3209            .unwrap();
3210
3211        assert!(
3212            results.iter().all(|h| h.note_id != note.id),
3213            "soft-deleted note should be excluded from search"
3214        );
3215    }
3216
3217    #[tokio::test]
3218    async fn resolve_returns_entity() {
3219        let rt = rt();
3220        let tok = NamespaceToken::local();
3221        let entity = rt
3222            .create_entity(&tok, "concept", None, "LoRA", None, None, vec![])
3223            .await
3224            .unwrap();
3225
3226        let resolved = rt.resolve(&tok, entity.id).await.unwrap();
3227        match resolved {
3228            Some(Resolved::Entity(e)) => assert_eq!(e.id, entity.id),
3229            other => panic!("expected Resolved::Entity, got {:?}", other),
3230        }
3231    }
3232
3233    #[tokio::test]
3234    async fn resolve_returns_note() {
3235        let rt = rt();
3236        let tok = NamespaceToken::local();
3237        let note = rt
3238            .create_note(
3239                &tok,
3240                "observation",
3241                None,
3242                "LoRA fine-tunes LLMs with low-rank adapters",
3243                Some(0.85),
3244                None,
3245                vec![],
3246            )
3247            .await
3248            .unwrap();
3249
3250        let resolved = rt.resolve(&tok, note.id).await.unwrap();
3251        match resolved {
3252            Some(Resolved::Note(n)) => assert_eq!(n.id, note.id),
3253            other => panic!("expected Resolved::Note, got {:?}", other),
3254        }
3255    }
3256
3257    #[tokio::test]
3258    async fn resolve_returns_none_for_unknown_uuid() {
3259        let rt = rt();
3260        let tok = NamespaceToken::local();
3261        let unknown = Uuid::new_v4();
3262        let resolved = rt.resolve(&tok, unknown).await.unwrap();
3263        assert!(resolved.is_none(), "unknown UUID should resolve to None");
3264    }
3265
3266    #[tokio::test]
3267    async fn resolve_prefix_finds_entity_in_own_namespace() {
3268        let rt = rt();
3269        let tok = NamespaceToken::local();
3270        let entity = rt
3271            .create_entity(&tok, "concept", None, "PrefixTest", None, None, vec![])
3272            .await
3273            .unwrap();
3274        let prefix = &entity.id.to_string()[..8];
3275
3276        let resolved = rt.resolve_prefix(&tok, prefix).await.unwrap();
3277        assert_eq!(resolved, Some(entity.id));
3278    }
3279
3280    #[tokio::test]
3281    async fn resolve_prefix_invisible_across_namespaces() {
3282        let rt = rt();
3283        let ns_a = NamespaceToken::for_namespace(Namespace::parse("ns-a").unwrap());
3284        let ns_b = NamespaceToken::for_namespace(Namespace::parse("ns-b").unwrap());
3285        let entity = rt
3286            .create_entity(&ns_a, "concept", None, "Invisible", None, None, vec![])
3287            .await
3288            .unwrap();
3289        let prefix = &entity.id.to_string()[..8];
3290
3291        // From ns_b, the entity in ns_a should not be visible.
3292        let resolved = rt.resolve_prefix(&ns_b, prefix).await.unwrap();
3293        assert_eq!(resolved, None);
3294    }
3295
3296    #[tokio::test]
3297    async fn resolve_prefix_ambiguous_same_namespace() {
3298        use khive_storage::entity::Entity;
3299
3300        let rt = rt();
3301        let tok = NamespaceToken::local();
3302        // Two entities with UUIDs sharing the same 8-char prefix "aabbccdd".
3303        let id_a = Uuid::parse_str("aabbccdd-1111-4000-8000-000000000001").unwrap();
3304        let id_b = Uuid::parse_str("aabbccdd-2222-4000-8000-000000000002").unwrap();
3305
3306        let mut entity_a = Entity::new("local", "concept", "AmbigA");
3307        entity_a.id = id_a;
3308        let mut entity_b = Entity::new("local", "concept", "AmbigB");
3309        entity_b.id = id_b;
3310
3311        let store = rt.entities(&tok).unwrap();
3312        store.upsert_entity(entity_a).await.unwrap();
3313        store.upsert_entity(entity_b).await.unwrap();
3314
3315        let err = rt.resolve_prefix(&tok, "aabbccdd").await.unwrap_err();
3316        assert!(
3317            matches!(
3318                err,
3319                RuntimeError::AmbiguousPrefix { ref prefix, ref matches }
3320                    if prefix == "aabbccdd" && matches.len() == 2
3321            ),
3322            "shared 8-char prefix must return AmbiguousPrefix; got {err:?}"
3323        );
3324    }
3325
3326    // ---- Event resolution tests (issue #30) ----
3327    //
3328    // resolve_prefix and handle_get already include events; these tests are
3329    // regression coverage confirming event UUIDs are resolvable and that get()
3330    // returns kind="event".
3331
3332    #[tokio::test]
3333    async fn resolve_finds_event_by_full_uuid() {
3334        use khive_storage::Event;
3335        use khive_types::{EventKind, SubstrateKind};
3336
3337        let rt = rt();
3338        let tok = NamespaceToken::local();
3339        let ns = tok.namespace().as_str();
3340        let event = Event::new(
3341            ns,
3342            "test_verb",
3343            EventKind::Audit,
3344            SubstrateKind::Entity,
3345            "actor",
3346        );
3347        let event_id = event.id;
3348        rt.events(&tok).unwrap().append_event(event).await.unwrap();
3349
3350        let resolved = rt.resolve(&tok, event_id).await.unwrap();
3351        assert!(
3352            matches!(resolved, Some(Resolved::Event(_))),
3353            "event UUID must resolve to Resolved::Event, got {resolved:?}"
3354        );
3355    }
3356
3357    #[tokio::test]
3358    async fn resolve_prefix_finds_event() {
3359        use khive_storage::Event;
3360        use khive_types::{EventKind, SubstrateKind};
3361
3362        let rt = rt();
3363        let tok = NamespaceToken::local();
3364        let ns = tok.namespace().as_str();
3365        let event = Event::new(
3366            ns,
3367            "test_verb",
3368            EventKind::Audit,
3369            SubstrateKind::Entity,
3370            "actor",
3371        );
3372        let event_id = event.id;
3373        rt.events(&tok).unwrap().append_event(event).await.unwrap();
3374
3375        let prefix = &event_id.to_string()[..8];
3376        let resolved = rt.resolve_prefix(&tok, prefix).await.unwrap();
3377        assert_eq!(
3378            resolved,
3379            Some(event_id),
3380            "resolve_prefix must return event UUID for 8-char prefix"
3381        );
3382    }
3383
3384    // ---- Referential integrity tests (fix/link-referential-integrity) ----
3385
3386    #[tokio::test]
3387    async fn link_phantom_source_returns_not_found() {
3388        let rt = rt();
3389        let tok = NamespaceToken::local();
3390        let b = rt
3391            .create_entity(&tok, "concept", None, "B", None, None, vec![])
3392            .await
3393            .unwrap();
3394        let phantom = Uuid::new_v4();
3395
3396        let result = rt
3397            .link(&tok, phantom, b.id, EdgeRelation::Extends, 1.0, None)
3398            .await;
3399        match result {
3400            Err(RuntimeError::NotFound(msg)) => {
3401                assert!(
3402                    msg.contains("source"),
3403                    "error message must name 'source': {msg}"
3404                );
3405            }
3406            other => panic!("expected NotFound for phantom source, got {other:?}"),
3407        }
3408    }
3409
3410    #[tokio::test]
3411    async fn link_phantom_target_returns_not_found() {
3412        let rt = rt();
3413        let tok = NamespaceToken::local();
3414        let a = rt
3415            .create_entity(&tok, "concept", None, "A", None, None, vec![])
3416            .await
3417            .unwrap();
3418        let phantom = Uuid::new_v4();
3419
3420        let result = rt
3421            .link(&tok, a.id, phantom, EdgeRelation::Extends, 1.0, None)
3422            .await;
3423        match result {
3424            Err(RuntimeError::NotFound(msg)) => {
3425                assert!(
3426                    msg.contains("target"),
3427                    "error message must name 'target': {msg}"
3428                );
3429            }
3430            other => panic!("expected NotFound for phantom target, got {other:?}"),
3431        }
3432    }
3433
3434    #[tokio::test]
3435    async fn link_real_entities_succeeds() {
3436        let rt = rt();
3437        let tok = NamespaceToken::local();
3438        let a = rt
3439            .create_entity(&tok, "concept", None, "A", None, None, vec![])
3440            .await
3441            .unwrap();
3442        let b = rt
3443            .create_entity(&tok, "concept", None, "B", None, None, vec![])
3444            .await
3445            .unwrap();
3446
3447        let edge = rt
3448            .link(&tok, a.id, b.id, EdgeRelation::Extends, 0.8, None)
3449            .await
3450            .unwrap();
3451        assert_eq!(edge.source_id, a.id);
3452        assert_eq!(edge.target_id, b.id);
3453        assert_eq!(edge.relation, EdgeRelation::Extends);
3454    }
3455
3456    #[tokio::test]
3457    async fn create_note_annotates_phantom_returns_not_found() {
3458        let rt = rt();
3459        let tok = NamespaceToken::local();
3460        let phantom = Uuid::new_v4();
3461
3462        let result = rt
3463            .create_note(
3464                &tok,
3465                "observation",
3466                None,
3467                "some content",
3468                Some(0.5),
3469                None,
3470                vec![phantom],
3471            )
3472            .await;
3473        assert!(
3474            matches!(result, Err(RuntimeError::NotFound(_))),
3475            "annotates with phantom uuid must return NotFound, got {result:?}"
3476        );
3477    }
3478
3479    #[tokio::test]
3480    async fn create_note_annotates_real_entity_succeeds() {
3481        let rt = rt();
3482        let tok = NamespaceToken::local();
3483        let entity = rt
3484            .create_entity(&tok, "concept", None, "RealTarget", None, None, vec![])
3485            .await
3486            .unwrap();
3487
3488        let note = rt
3489            .create_note(
3490                &tok,
3491                "observation",
3492                None,
3493                "content",
3494                Some(0.5),
3495                None,
3496                vec![entity.id],
3497            )
3498            .await
3499            .unwrap();
3500
3501        let neighbors = rt
3502            .neighbors(
3503                &tok,
3504                note.id,
3505                Direction::Out,
3506                None,
3507                Some(vec![EdgeRelation::Annotates]),
3508            )
3509            .await
3510            .unwrap();
3511        assert_eq!(neighbors.len(), 1);
3512        assert_eq!(neighbors[0].node_id, entity.id);
3513    }
3514
3515    // Atomicity: multi-target annotates golden path — all edges created, note present.
3516    #[tokio::test]
3517    async fn create_note_multi_annotates_creates_all_edges() {
3518        let rt = rt();
3519        let tok = NamespaceToken::local();
3520        let t1 = rt
3521            .create_entity(&tok, "concept", None, "Target1", None, None, vec![])
3522            .await
3523            .unwrap();
3524        let t2 = rt
3525            .create_entity(&tok, "concept", None, "Target2", None, None, vec![])
3526            .await
3527            .unwrap();
3528
3529        let note = rt
3530            .create_note(
3531                &tok,
3532                "observation",
3533                None,
3534                "content",
3535                Some(0.5),
3536                None,
3537                vec![t1.id, t2.id],
3538            )
3539            .await
3540            .unwrap();
3541
3542        let neighbors = rt
3543            .neighbors(
3544                &tok,
3545                note.id,
3546                Direction::Out,
3547                None,
3548                Some(vec![EdgeRelation::Annotates]),
3549            )
3550            .await
3551            .unwrap();
3552        assert_eq!(
3553            neighbors.len(),
3554            2,
3555            "multi-annotates note must have exactly 2 outbound annotates edges"
3556        );
3557        let target_ids: Vec<Uuid> = neighbors.iter().map(|n| n.node_id).collect();
3558        assert!(target_ids.contains(&t1.id));
3559        assert!(target_ids.contains(&t2.id));
3560    }
3561
3562    #[tokio::test]
3563    async fn link_target_in_different_namespace_returns_not_found() {
3564        let rt = rt();
3565        let ns_a = NamespaceToken::for_namespace(Namespace::parse("ns-a").unwrap());
3566        let ns_b = NamespaceToken::for_namespace(Namespace::parse("ns-b").unwrap());
3567        let a = rt
3568            .create_entity(&ns_a, "concept", None, "A", None, None, vec![])
3569            .await
3570            .unwrap();
3571        let b = rt
3572            .create_entity(&ns_b, "concept", None, "B", None, None, vec![])
3573            .await
3574            .unwrap();
3575
3576        // Linking from ns-a: target b lives in ns-b — must be treated as not found.
3577        let result = rt
3578            .link(&ns_a, a.id, b.id, EdgeRelation::Extends, 1.0, None)
3579            .await;
3580        assert!(
3581            matches!(result, Err(RuntimeError::NotFound(_))),
3582            "target in different namespace must return NotFound (fail-closed), got {result:?}"
3583        );
3584    }
3585
3586    #[tokio::test]
3587    async fn link_phantom_self_loop_returns_not_found() {
3588        let rt = rt();
3589        let tok = NamespaceToken::local();
3590        let phantom = Uuid::new_v4();
3591
3592        let result = rt
3593            .link(&tok, phantom, phantom, EdgeRelation::Extends, 1.0, None)
3594            .await;
3595        match result {
3596            Err(RuntimeError::NotFound(msg)) => {
3597                assert!(
3598                    msg.contains("source"),
3599                    "self-loop must fail on source first: {msg}"
3600                );
3601            }
3602            other => panic!("expected NotFound for phantom self-loop, got {other:?}"),
3603        }
3604    }
3605
3606    // ---- Round-2 tests: edge target coverage + atomicity ----
3607
3608    #[tokio::test]
3609    async fn link_note_to_edge_annotates_succeeds() {
3610        let rt = rt();
3611        let tok = NamespaceToken::local();
3612        let a = rt
3613            .create_entity(&tok, "concept", None, "A", None, None, vec![])
3614            .await
3615            .unwrap();
3616        let b = rt
3617            .create_entity(&tok, "concept", None, "B", None, None, vec![])
3618            .await
3619            .unwrap();
3620        // Create a real edge between a and b, capture its UUID.
3621        let edge = rt
3622            .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
3623            .await
3624            .unwrap();
3625        let edge_uuid: Uuid = edge.id.into();
3626
3627        // Create a note and annotate the edge itself (edge is a valid substrate target per ADR-024).
3628        let note = rt
3629            .create_note(
3630                &tok,
3631                "observation",
3632                None,
3633                "edge note",
3634                Some(0.5),
3635                None,
3636                vec![],
3637            )
3638            .await
3639            .unwrap();
3640
3641        let result = rt
3642            .link(&tok, note.id, edge_uuid, EdgeRelation::Annotates, 1.0, None)
3643            .await;
3644        assert!(
3645            result.is_ok(),
3646            "note→edge Annotates must succeed, got {result:?}"
3647        );
3648    }
3649
3650    #[tokio::test]
3651    async fn create_note_annotates_real_edge_succeeds() {
3652        let rt = rt();
3653        let tok = NamespaceToken::local();
3654        let a = rt
3655            .create_entity(&tok, "concept", None, "A", None, None, vec![])
3656            .await
3657            .unwrap();
3658        let b = rt
3659            .create_entity(&tok, "concept", None, "B", None, None, vec![])
3660            .await
3661            .unwrap();
3662        let edge = rt
3663            .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
3664            .await
3665            .unwrap();
3666        let edge_uuid: Uuid = edge.id.into();
3667
3668        let note = rt
3669            .create_note(
3670                &tok,
3671                "observation",
3672                None,
3673                "annotating an edge",
3674                Some(0.5),
3675                None,
3676                vec![edge_uuid],
3677            )
3678            .await
3679            .unwrap();
3680
3681        let neighbors = rt
3682            .neighbors(
3683                &tok,
3684                note.id,
3685                Direction::Out,
3686                None,
3687                Some(vec![EdgeRelation::Annotates]),
3688            )
3689            .await
3690            .unwrap();
3691        assert_eq!(neighbors.len(), 1);
3692        assert_eq!(neighbors[0].node_id, edge_uuid);
3693    }
3694
3695    #[tokio::test]
3696    async fn create_note_annotates_phantom_is_atomic_no_note_persisted() {
3697        let rt = rt();
3698        let tok = NamespaceToken::local();
3699        let phantom = Uuid::new_v4();
3700
3701        let before_count = rt.list_notes(&tok, None, 1000, 0).await.unwrap().len();
3702
3703        let result = rt
3704            .create_note(
3705                &tok,
3706                "observation",
3707                None,
3708                "should not persist",
3709                Some(0.5),
3710                None,
3711                vec![phantom],
3712            )
3713            .await;
3714        assert!(
3715            matches!(result, Err(RuntimeError::NotFound(_))),
3716            "phantom annotates target must return NotFound, got {result:?}"
3717        );
3718
3719        // Atomicity: the note row must NOT have been written.
3720        let after_count = rt.list_notes(&tok, None, 1000, 0).await.unwrap().len();
3721        assert_eq!(
3722            before_count, after_count,
3723            "failed create_note must not persist any note row (atomicity)"
3724        );
3725
3726        // FTS must not contain the content either.
3727        let search_hits = rt
3728            .search_notes(&tok, "should not persist", None, 10, None, false)
3729            .await
3730            .unwrap();
3731        assert!(
3732            search_hits.is_empty(),
3733            "failed create_note must not index into FTS (atomicity)"
3734        );
3735        // Vector-store row: only written when an embedding model is configured; the rt()
3736        // harness has none, so no vector assertion is needed here.
3737    }
3738
3739    // ---- Round-3 tests: relation-aware endpoint contract (ADR-002) ----
3740
3741    // Test #2: entity→entity with non-annotates rejects an edge UUID as target.
3742    #[tokio::test]
3743    async fn link_entity_to_edge_uuid_non_annotates_returns_invalid_input() {
3744        let rt = rt();
3745        let tok = NamespaceToken::local();
3746        let a = rt
3747            .create_entity(&tok, "concept", None, "A", None, None, vec![])
3748            .await
3749            .unwrap();
3750        let b = rt
3751            .create_entity(&tok, "concept", None, "B", None, None, vec![])
3752            .await
3753            .unwrap();
3754        // Create a real edge; capture its UUID as the bad target.
3755        let edge = rt
3756            .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
3757            .await
3758            .unwrap();
3759        let edge_uuid: Uuid = edge.id.into();
3760
3761        let result = rt
3762            .link(&tok, a.id, edge_uuid, EdgeRelation::Extends, 1.0, None)
3763            .await;
3764        match result {
3765            Err(RuntimeError::InvalidInput(msg)) => {
3766                assert!(
3767                    msg.contains("target"),
3768                    "error message must name 'target': {msg}"
3769                );
3770            }
3771            other => {
3772                panic!("expected InvalidInput for edge-uuid target with Extends, got {other:?}")
3773            }
3774        }
3775    }
3776
3777    // Test #3: non-annotates rejects a note UUID as source.
3778    #[tokio::test]
3779    async fn link_note_as_source_non_annotates_returns_invalid_input() {
3780        let rt = rt();
3781        let tok = NamespaceToken::local();
3782        let note = rt
3783            .create_note(&tok, "observation", None, "a note", Some(0.5), None, vec![])
3784            .await
3785            .unwrap();
3786        let entity = rt
3787            .create_entity(&tok, "concept", None, "E", None, None, vec![])
3788            .await
3789            .unwrap();
3790
3791        let result = rt
3792            .link(&tok, note.id, entity.id, EdgeRelation::DependsOn, 1.0, None)
3793            .await;
3794        match result {
3795            Err(RuntimeError::InvalidInput(msg)) => {
3796                assert!(
3797                    msg.contains("source"),
3798                    "error message must name 'source': {msg}"
3799                );
3800            }
3801            other => panic!("expected InvalidInput for note source with DependsOn, got {other:?}"),
3802        }
3803    }
3804
3805    // Test #4: annotates rejects entity as source (source must be a note).
3806    #[tokio::test]
3807    async fn link_entity_as_annotates_source_returns_invalid_input() {
3808        let rt = rt();
3809        let tok = NamespaceToken::local();
3810        let a = rt
3811            .create_entity(&tok, "concept", None, "A", None, None, vec![])
3812            .await
3813            .unwrap();
3814        let b = rt
3815            .create_entity(&tok, "concept", None, "B", None, None, vec![])
3816            .await
3817            .unwrap();
3818
3819        let result = rt
3820            .link(&tok, a.id, b.id, EdgeRelation::Annotates, 1.0, None)
3821            .await;
3822        match result {
3823            Err(RuntimeError::InvalidInput(msg)) => {
3824                assert!(
3825                    msg.contains("source") && msg.contains("note"),
3826                    "error must say source must be a note: {msg}"
3827                );
3828            }
3829            other => {
3830                panic!("expected InvalidInput for entity source with Annotates, got {other:?}")
3831            }
3832        }
3833    }
3834
3835    #[tokio::test]
3836    async fn link_edge_as_annotates_source_returns_invalid_input() {
3837        let rt = rt();
3838        let tok = NamespaceToken::local();
3839        let a = rt
3840            .create_entity(&tok, "concept", None, "A", None, None, vec![])
3841            .await
3842            .unwrap();
3843        let b = rt
3844            .create_entity(&tok, "concept", None, "B", None, None, vec![])
3845            .await
3846            .unwrap();
3847        let edge = rt
3848            .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
3849            .await
3850            .unwrap();
3851        let edge_uuid: Uuid = edge.id.into();
3852
3853        // An existing edge used as an annotates source: wrong kind, not absent.
3854        let result = rt
3855            .link(&tok, edge_uuid, a.id, EdgeRelation::Annotates, 1.0, None)
3856            .await;
3857        match result {
3858            Err(RuntimeError::InvalidInput(msg)) => {
3859                assert!(
3860                    msg.contains("source") && msg.contains("note"),
3861                    "edge-as-annotates-source must report wrong kind, not NotFound: {msg}"
3862                );
3863            }
3864            other => panic!("expected InvalidInput for edge source with Annotates, got {other:?}"),
3865        }
3866    }
3867
3868    // Test #5: note→event with annotates succeeds (event is a valid annotates target).
3869    #[tokio::test]
3870    async fn link_note_to_event_annotates_succeeds() {
3871        use khive_storage::Event;
3872        use khive_types::{EventKind, SubstrateKind};
3873
3874        let rt = rt();
3875        let tok = NamespaceToken::local();
3876        let note = rt
3877            .create_note(
3878                &tok,
3879                "observation",
3880                None,
3881                "observing an event",
3882                Some(0.6),
3883                None,
3884                vec![],
3885            )
3886            .await
3887            .unwrap();
3888
3889        // Build an event directly via the store (no runtime create_event exists).
3890        let ns = tok.namespace().as_str();
3891        let event = Event::new(
3892            ns,
3893            "test_verb",
3894            EventKind::Audit,
3895            SubstrateKind::Entity,
3896            "test_actor",
3897        );
3898        let event_id = event.id;
3899        rt.events(&tok).unwrap().append_event(event).await.unwrap();
3900
3901        let result = rt
3902            .link(&tok, note.id, event_id, EdgeRelation::Annotates, 1.0, None)
3903            .await;
3904        assert!(
3905            result.is_ok(),
3906            "note→event Annotates must succeed, got {result:?}"
3907        );
3908    }
3909
3910    // Test #6: create_note with event as annotates target succeeds.
3911    #[tokio::test]
3912    async fn create_note_annotates_event_succeeds() {
3913        use khive_storage::Event;
3914        use khive_types::{EventKind, SubstrateKind};
3915
3916        let rt = rt();
3917        let tok = NamespaceToken::local();
3918        let ns = tok.namespace().as_str();
3919        let event = Event::new(
3920            ns,
3921            "test_verb",
3922            EventKind::Audit,
3923            SubstrateKind::Entity,
3924            "test_actor",
3925        );
3926        let event_id = event.id;
3927        rt.events(&tok).unwrap().append_event(event).await.unwrap();
3928
3929        let result = rt
3930            .create_note(
3931                &tok,
3932                "observation",
3933                None,
3934                "note annotating an event",
3935                Some(0.5),
3936                None,
3937                vec![event_id],
3938            )
3939            .await;
3940        assert!(
3941            result.is_ok(),
3942            "create_note with event annotates target must succeed, got {result:?}"
3943        );
3944        // Verify the annotates edge was created.
3945        let note = result.unwrap();
3946        let neighbors = rt
3947            .neighbors(
3948                &tok,
3949                note.id,
3950                Direction::Out,
3951                None,
3952                Some(vec![EdgeRelation::Annotates]),
3953            )
3954            .await
3955            .unwrap();
3956        assert_eq!(neighbors.len(), 1);
3957        assert_eq!(neighbors[0].node_id, event_id);
3958    }
3959
3960    // ---- Round-4 tests: supersedes same-substrate contract (ADR-019/ADR-024) ----
3961
3962    // Headline regression: note→note supersedes must succeed (was wrongly rejected before this fix).
3963    #[tokio::test]
3964    async fn link_supersedes_note_to_note_succeeds() {
3965        let rt = rt();
3966        let tok = NamespaceToken::local();
3967        let old_note = rt
3968            .create_note(
3969                &tok,
3970                "observation",
3971                None,
3972                "old observation",
3973                Some(0.7),
3974                None,
3975                vec![],
3976            )
3977            .await
3978            .unwrap();
3979        let new_note = rt
3980            .create_note(
3981                &tok,
3982                "observation",
3983                None,
3984                "revised observation superseding the old one",
3985                Some(0.9),
3986                None,
3987                vec![],
3988            )
3989            .await
3990            .unwrap();
3991
3992        let result = rt
3993            .link(
3994                &tok,
3995                new_note.id,
3996                old_note.id,
3997                EdgeRelation::Supersedes,
3998                1.0,
3999                None,
4000            )
4001            .await;
4002        assert!(
4003            result.is_ok(),
4004            "note→note Supersedes must succeed (ADR-019 note supersession), got {result:?}"
4005        );
4006    }
4007
4008    #[tokio::test]
4009    async fn link_supersedes_entity_to_entity_succeeds() {
4010        let rt = rt();
4011        let tok = NamespaceToken::local();
4012        let old_entity = rt
4013            .create_entity(&tok, "concept", None, "OldConcept", None, None, vec![])
4014            .await
4015            .unwrap();
4016        let new_entity = rt
4017            .create_entity(&tok, "concept", None, "NewConcept", None, None, vec![])
4018            .await
4019            .unwrap();
4020
4021        let result = rt
4022            .link(
4023                &tok,
4024                new_entity.id,
4025                old_entity.id,
4026                EdgeRelation::Supersedes,
4027                1.0,
4028                None,
4029            )
4030            .await;
4031        assert!(
4032            result.is_ok(),
4033            "entity→entity Supersedes must succeed, got {result:?}"
4034        );
4035    }
4036
4037    #[tokio::test]
4038    async fn link_supersedes_note_to_entity_returns_invalid_input() {
4039        let rt = rt();
4040        let tok = NamespaceToken::local();
4041        let note = rt
4042            .create_note(&tok, "observation", None, "a note", Some(0.5), None, vec![])
4043            .await
4044            .unwrap();
4045        let entity = rt
4046            .create_entity(&tok, "concept", None, "SomeEntity", None, None, vec![])
4047            .await
4048            .unwrap();
4049
4050        let result = rt
4051            .link(
4052                &tok,
4053                note.id,
4054                entity.id,
4055                EdgeRelation::Supersedes,
4056                1.0,
4057                None,
4058            )
4059            .await;
4060        match result {
4061            Err(RuntimeError::InvalidInput(msg)) => {
4062                assert!(
4063                    msg.contains("same substrate") || msg.contains("same-substrate"),
4064                    "error must name the same-substrate rule: {msg}"
4065                );
4066            }
4067            other => panic!(
4068                "expected InvalidInput for note→entity Supersedes (cross-substrate), got {other:?}"
4069            ),
4070        }
4071    }
4072
4073    #[tokio::test]
4074    async fn link_supersedes_entity_to_note_returns_invalid_input() {
4075        let rt = rt();
4076        let tok = NamespaceToken::local();
4077        let entity = rt
4078            .create_entity(&tok, "concept", None, "SomeEntity", None, None, vec![])
4079            .await
4080            .unwrap();
4081        let note = rt
4082            .create_note(&tok, "observation", None, "a note", Some(0.5), None, vec![])
4083            .await
4084            .unwrap();
4085
4086        let result = rt
4087            .link(
4088                &tok,
4089                entity.id,
4090                note.id,
4091                EdgeRelation::Supersedes,
4092                1.0,
4093                None,
4094            )
4095            .await;
4096        match result {
4097            Err(RuntimeError::InvalidInput(msg)) => {
4098                assert!(
4099                    msg.contains("same substrate") || msg.contains("same-substrate"),
4100                    "error must name the same-substrate rule: {msg}"
4101                );
4102            }
4103            other => panic!(
4104                "expected InvalidInput for entity→note Supersedes (cross-substrate), got {other:?}"
4105            ),
4106        }
4107    }
4108
4109    #[tokio::test]
4110    async fn link_supersedes_event_source_returns_invalid_input() {
4111        use khive_storage::Event;
4112        use khive_types::{EventKind, SubstrateKind};
4113
4114        let rt = rt();
4115        let tok = NamespaceToken::local();
4116        let ns = tok.namespace().as_str();
4117        let event = Event::new(
4118            ns,
4119            "test_verb",
4120            EventKind::Audit,
4121            SubstrateKind::Entity,
4122            "test_actor",
4123        );
4124        let event_id = event.id;
4125        rt.events(&tok).unwrap().append_event(event).await.unwrap();
4126
4127        let entity = rt
4128            .create_entity(&tok, "concept", None, "SomeEntity", None, None, vec![])
4129            .await
4130            .unwrap();
4131
4132        let result = rt
4133            .link(
4134                &tok,
4135                event_id,
4136                entity.id,
4137                EdgeRelation::Supersedes,
4138                1.0,
4139                None,
4140            )
4141            .await;
4142        match result {
4143            Err(RuntimeError::InvalidInput(msg)) => {
4144                assert!(msg.contains("event"), "error must mention 'event': {msg}");
4145            }
4146            other => {
4147                panic!("expected InvalidInput for event source with Supersedes, got {other:?}")
4148            }
4149        }
4150    }
4151
4152    #[tokio::test]
4153    async fn link_supersedes_event_target_returns_invalid_input() {
4154        use khive_storage::Event;
4155        use khive_types::{EventKind, SubstrateKind};
4156
4157        let rt = rt();
4158        let tok = NamespaceToken::local();
4159        let ns = tok.namespace().as_str();
4160        let event = Event::new(
4161            ns,
4162            "test_verb",
4163            EventKind::Audit,
4164            SubstrateKind::Entity,
4165            "test_actor",
4166        );
4167        let event_id = event.id;
4168        rt.events(&tok).unwrap().append_event(event).await.unwrap();
4169
4170        let entity = rt
4171            .create_entity(&tok, "concept", None, "SomeEntity", None, None, vec![])
4172            .await
4173            .unwrap();
4174
4175        let result = rt
4176            .link(
4177                &tok,
4178                entity.id,
4179                event_id,
4180                EdgeRelation::Supersedes,
4181                1.0,
4182                None,
4183            )
4184            .await;
4185        match result {
4186            Err(RuntimeError::InvalidInput(msg)) => {
4187                assert!(msg.contains("event"), "error must mention 'event': {msg}");
4188            }
4189            other => {
4190                panic!("expected InvalidInput for event target with Supersedes, got {other:?}")
4191            }
4192        }
4193    }
4194
4195    #[tokio::test]
4196    async fn link_supersedes_edge_source_returns_invalid_input() {
4197        let rt = rt();
4198        let tok = NamespaceToken::local();
4199        let a = rt
4200            .create_entity(&tok, "concept", None, "A", None, None, vec![])
4201            .await
4202            .unwrap();
4203        let b = rt
4204            .create_entity(&tok, "concept", None, "B", None, None, vec![])
4205            .await
4206            .unwrap();
4207        let edge = rt
4208            .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
4209            .await
4210            .unwrap();
4211        let edge_uuid: Uuid = edge.id.into();
4212
4213        let result = rt
4214            .link(&tok, edge_uuid, a.id, EdgeRelation::Supersedes, 1.0, None)
4215            .await;
4216        match result {
4217            Err(RuntimeError::InvalidInput(msg)) => {
4218                assert!(msg.contains("source"), "error must name 'source': {msg}");
4219            }
4220            other => {
4221                panic!("expected InvalidInput for edge-uuid source with Supersedes, got {other:?}")
4222            }
4223        }
4224    }
4225
4226    #[tokio::test]
4227    async fn link_supersedes_edge_target_returns_invalid_input() {
4228        let rt = rt();
4229        let tok = NamespaceToken::local();
4230        let a = rt
4231            .create_entity(&tok, "concept", None, "A", None, None, vec![])
4232            .await
4233            .unwrap();
4234        let b = rt
4235            .create_entity(&tok, "concept", None, "B", None, None, vec![])
4236            .await
4237            .unwrap();
4238        let edge = rt
4239            .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
4240            .await
4241            .unwrap();
4242        let edge_uuid: Uuid = edge.id.into();
4243
4244        let result = rt
4245            .link(&tok, a.id, edge_uuid, EdgeRelation::Supersedes, 1.0, None)
4246            .await;
4247        match result {
4248            Err(RuntimeError::InvalidInput(msg)) => {
4249                assert!(msg.contains("target"), "error must name 'target': {msg}");
4250            }
4251            other => {
4252                panic!("expected InvalidInput for edge-uuid target with Supersedes, got {other:?}")
4253            }
4254        }
4255    }
4256
4257    #[tokio::test]
4258    async fn link_supersedes_phantom_source_returns_not_found() {
4259        let rt = rt();
4260        let tok = NamespaceToken::local();
4261        let note = rt
4262            .create_note(
4263                &tok,
4264                "observation",
4265                None,
4266                "existing note",
4267                Some(0.5),
4268                None,
4269                vec![],
4270            )
4271            .await
4272            .unwrap();
4273        let phantom = Uuid::new_v4();
4274
4275        let result = rt
4276            .link(&tok, phantom, note.id, EdgeRelation::Supersedes, 1.0, None)
4277            .await;
4278        match result {
4279            Err(RuntimeError::NotFound(msg)) => {
4280                assert!(msg.contains("source"), "error must name 'source': {msg}");
4281            }
4282            other => panic!("expected NotFound for phantom source with Supersedes, got {other:?}"),
4283        }
4284    }
4285
4286    #[tokio::test]
4287    async fn link_supersedes_phantom_target_returns_not_found() {
4288        let rt = rt();
4289        let tok = NamespaceToken::local();
4290        let note = rt
4291            .create_note(
4292                &tok,
4293                "observation",
4294                None,
4295                "existing note",
4296                Some(0.5),
4297                None,
4298                vec![],
4299            )
4300            .await
4301            .unwrap();
4302        let phantom = Uuid::new_v4();
4303
4304        let result = rt
4305            .link(&tok, note.id, phantom, EdgeRelation::Supersedes, 1.0, None)
4306            .await;
4307        match result {
4308            Err(RuntimeError::NotFound(msg)) => {
4309                assert!(msg.contains("target"), "error must name 'target': {msg}");
4310            }
4311            other => panic!("expected NotFound for phantom target with Supersedes, got {other:?}"),
4312        }
4313    }
4314
4315    #[tokio::test]
4316    async fn link_supersedes_cross_namespace_source_returns_not_found() {
4317        let rt = rt();
4318        let ns_a = NamespaceToken::for_namespace(Namespace::parse("ns-a").unwrap());
4319        let ns_b = NamespaceToken::for_namespace(Namespace::parse("ns-b").unwrap());
4320        let note_a = rt
4321            .create_note(
4322                &ns_a,
4323                "observation",
4324                None,
4325                "note in ns-a",
4326                Some(0.5),
4327                None,
4328                vec![],
4329            )
4330            .await
4331            .unwrap();
4332        let note_b = rt
4333            .create_note(
4334                &ns_b,
4335                "observation",
4336                None,
4337                "note in ns-b",
4338                Some(0.5),
4339                None,
4340                vec![],
4341            )
4342            .await
4343            .unwrap();
4344
4345        // From ns-a perspective, note_b is in a different namespace — treated as not found.
4346        let result = rt
4347            .link(
4348                &ns_a,
4349                note_b.id,
4350                note_a.id,
4351                EdgeRelation::Supersedes,
4352                1.0,
4353                None,
4354            )
4355            .await;
4356        assert!(
4357            matches!(result, Err(RuntimeError::NotFound(_))),
4358            "cross-namespace source with Supersedes must return NotFound (fail-closed), got {result:?}"
4359        );
4360    }
4361
4362    // Sanity: extends (non-annotates, non-supersedes) still requires entity→entity.
4363    #[tokio::test]
4364    async fn link_extends_note_source_still_returns_invalid_input() {
4365        let rt = rt();
4366        let tok = NamespaceToken::local();
4367        let note = rt
4368            .create_note(
4369                &tok,
4370                "observation",
4371                None,
4372                "a note that cannot be an extends source",
4373                Some(0.5),
4374                None,
4375                vec![],
4376            )
4377            .await
4378            .unwrap();
4379        let entity = rt
4380            .create_entity(&tok, "concept", None, "E", None, None, vec![])
4381            .await
4382            .unwrap();
4383
4384        let result = rt
4385            .link(&tok, note.id, entity.id, EdgeRelation::Extends, 1.0, None)
4386            .await;
4387        assert!(
4388            matches!(result, Err(RuntimeError::InvalidInput(_))),
4389            "note source with Extends must still return InvalidInput after this fix, got {result:?}"
4390        );
4391    }
4392
4393    // Sanity: annotates note→edge still succeeds (unchanged path not broken by this fix).
4394    #[tokio::test]
4395    async fn link_annotates_note_to_edge_still_succeeds_after_fix() {
4396        let rt = rt();
4397        let tok = NamespaceToken::local();
4398        let a = rt
4399            .create_entity(&tok, "concept", None, "A", None, None, vec![])
4400            .await
4401            .unwrap();
4402        let b = rt
4403            .create_entity(&tok, "concept", None, "B", None, None, vec![])
4404            .await
4405            .unwrap();
4406        let edge = rt
4407            .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
4408            .await
4409            .unwrap();
4410        let edge_uuid: Uuid = edge.id.into();
4411
4412        let note = rt
4413            .create_note(
4414                &tok,
4415                "observation",
4416                None,
4417                "annotating an edge",
4418                Some(0.5),
4419                None,
4420                vec![],
4421            )
4422            .await
4423            .unwrap();
4424
4425        let result = rt
4426            .link(&tok, note.id, edge_uuid, EdgeRelation::Annotates, 1.0, None)
4427            .await;
4428        assert!(
4429            result.is_ok(),
4430            "note→edge Annotates must still succeed after supersedes fix, got {result:?}"
4431        );
4432    }
4433
4434    // ---- Compensation-path rollback (fix/annotates) ----
4435
4436    // The compensation branch in `create_note_inner` (operations.rs) rolls back
4437    // a partial write — note row + first edge + FTS + vector — when a subsequent
4438    // link call fails. The failure trigger is a storage error (e.g. I/O failure)
4439    // that cannot occur in the in-memory runtime; this test instead exercises the
4440    // exact cleanup operations that the compensation branch performs, starting from
4441    // a manually-constructed partial state, and verifies the post-cleanup invariants.
4442    //
4443    // What this covers: the cleanup sequence (delete_edge, delete_note hard, FTS
4444    // index clean) is correct and leaves the DB in a pristine state. What it does
4445    // not cover: the trigger condition (second link failure). Storage-error injection
4446    // would require a mock GraphStore, which is beyond the current test infrastructure.
4447    #[tokio::test]
4448    async fn create_note_multi_annotates_compensation_cleanup_restores_pristine_state() {
4449        let rt = rt();
4450        let tok = NamespaceToken::local();
4451        let t1 = rt
4452            .create_entity(&tok, "concept", None, "T1", None, None, vec![])
4453            .await
4454            .unwrap();
4455
4456        // Construct the partial state that the compensation branch would encounter:
4457        // note persisted + first annotates edge created.
4458        let note = rt
4459            .create_note(
4460                &tok,
4461                "observation",
4462                None,
4463                "partial note",
4464                Some(0.5),
4465                None,
4466                vec![t1.id],
4467            )
4468            .await
4469            .unwrap();
4470
4471        // Confirm the partial state exists before compensation.
4472        let before_notes = rt.list_notes(&tok, None, 1000, 0).await.unwrap();
4473        assert_eq!(before_notes.len(), 1, "note must be present before cleanup");
4474        let before_edges = rt
4475            .neighbors(
4476                &tok,
4477                note.id,
4478                Direction::Out,
4479                None,
4480                Some(vec![EdgeRelation::Annotates]),
4481            )
4482            .await
4483            .unwrap();
4484        assert_eq!(
4485            before_edges.len(),
4486            1,
4487            "one annotates edge must exist before cleanup"
4488        );
4489        let edge_id: Uuid = before_edges[0].edge_id;
4490
4491        // Execute the same cleanup sequence that `create_note_inner`'s Err branch runs.
4492        rt.delete_edge(&tok, edge_id, true).await.unwrap();
4493        rt.delete_note(&tok, note.id, true /* hard */)
4494            .await
4495            .unwrap();
4496
4497        // Post-compensation invariants:
4498        let after_notes = rt.list_notes(&tok, None, 1000, 0).await.unwrap();
4499        assert!(
4500            after_notes.is_empty(),
4501            "compensation must remove the note row; got {after_notes:?}"
4502        );
4503        let search_hits = rt
4504            .search_notes(&tok, "partial note", None, 10, None, false)
4505            .await
4506            .unwrap();
4507        assert!(
4508            search_hits.is_empty(),
4509            "compensation must clean the FTS index; got {search_hits:?}"
4510        );
4511        let after_edges = rt
4512            .neighbors(&tok, note.id, Direction::Out, None, None)
4513            .await
4514            .unwrap();
4515        assert!(
4516            after_edges.is_empty(),
4517            "compensation must remove all partial edges; got {after_edges:?}"
4518        );
4519    }
4520
4521    // ---- Hard-delete cascade for note and edge annotation targets (fix/annotates) ----
4522
4523    // ADR-002:73 — annotates is note → ANYTHING (entity, note, edge, event).
4524    // ADR-024:103 — targets may be entity, edge, event, or note.
4525    // Hard-deleting any of those targets must cascade incident annotates edges.
4526    // Soft deletes leave edges (data-vs-view rule).
4527
4528    #[tokio::test]
4529    async fn annotated_entity_hard_delete_cascades_annotate_edge() {
4530        let rt = rt();
4531        let tok = NamespaceToken::local();
4532        let entity = rt
4533            .create_entity(&tok, "concept", None, "E", None, None, vec![])
4534            .await
4535            .unwrap();
4536        let note = rt
4537            .create_note(
4538                &tok,
4539                "observation",
4540                None,
4541                "note about entity",
4542                Some(0.5),
4543                None,
4544                vec![entity.id],
4545            )
4546            .await
4547            .unwrap();
4548
4549        // Confirm edge exists before delete.
4550        let before = rt
4551            .neighbors(
4552                &tok,
4553                note.id,
4554                Direction::Out,
4555                None,
4556                Some(vec![EdgeRelation::Annotates]),
4557            )
4558            .await
4559            .unwrap();
4560        assert_eq!(
4561            before.len(),
4562            1,
4563            "annotates edge must exist before entity delete"
4564        );
4565
4566        // Hard delete the entity.
4567        let deleted = rt.delete_entity(&tok, entity.id, true).await.unwrap();
4568        assert!(deleted, "entity hard delete must return true");
4569
4570        // Annotates edge must be gone.
4571        let after = rt
4572            .neighbors(
4573                &tok,
4574                note.id,
4575                Direction::Out,
4576                None,
4577                Some(vec![EdgeRelation::Annotates]),
4578            )
4579            .await
4580            .unwrap();
4581        assert!(
4582            after.is_empty(),
4583            "annotates edge must be cascaded on entity hard delete; got {after:?}"
4584        );
4585    }
4586
4587    #[tokio::test]
4588    async fn annotated_note_hard_delete_cascades_annotate_edge() {
4589        let rt = rt();
4590        let tok = NamespaceToken::local();
4591        // note_target is the thing being annotated (a note itself).
4592        let note_target = rt
4593            .create_note(
4594                &tok,
4595                "observation",
4596                None,
4597                "target note",
4598                Some(0.5),
4599                None,
4600                vec![],
4601            )
4602            .await
4603            .unwrap();
4604        // note_source annotates note_target.
4605        let note_source = rt
4606            .create_note(
4607                &tok,
4608                "insight",
4609                None,
4610                "annotation",
4611                Some(0.5),
4612                None,
4613                vec![note_target.id],
4614            )
4615            .await
4616            .unwrap();
4617
4618        let before = rt
4619            .neighbors(
4620                &tok,
4621                note_source.id,
4622                Direction::Out,
4623                None,
4624                Some(vec![EdgeRelation::Annotates]),
4625            )
4626            .await
4627            .unwrap();
4628        assert_eq!(
4629            before.len(),
4630            1,
4631            "annotates edge must exist before note delete"
4632        );
4633
4634        // Hard delete the annotation TARGET note.
4635        let deleted = rt.delete_note(&tok, note_target.id, true).await.unwrap();
4636        assert!(deleted, "note hard delete must return true");
4637
4638        // The annotates edge targeting note_target must be gone.
4639        let after = rt
4640            .neighbors(
4641                &tok,
4642                note_source.id,
4643                Direction::Out,
4644                None,
4645                Some(vec![EdgeRelation::Annotates]),
4646            )
4647            .await
4648            .unwrap();
4649        assert!(
4650            after.is_empty(),
4651            "annotates edge must be cascaded on note-target hard delete; got {after:?}"
4652        );
4653    }
4654
4655    #[tokio::test]
4656    async fn annotated_edge_delete_cascades_annotate_edge() {
4657        let rt = rt();
4658        let tok = NamespaceToken::local();
4659        let a = rt
4660            .create_entity(&tok, "concept", None, "A", None, None, vec![])
4661            .await
4662            .unwrap();
4663        let b = rt
4664            .create_entity(&tok, "concept", None, "B", None, None, vec![])
4665            .await
4666            .unwrap();
4667        // Create an edge to annotate.
4668        let base_edge = rt
4669            .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
4670            .await
4671            .unwrap();
4672        let base_edge_uuid: Uuid = base_edge.id.into();
4673
4674        // Create a note that annotates the edge.
4675        let note = rt
4676            .create_note(
4677                &tok,
4678                "observation",
4679                None,
4680                "note about edge",
4681                Some(0.5),
4682                None,
4683                vec![base_edge_uuid],
4684            )
4685            .await
4686            .unwrap();
4687
4688        let before = rt
4689            .neighbors(
4690                &tok,
4691                note.id,
4692                Direction::Out,
4693                None,
4694                Some(vec![EdgeRelation::Annotates]),
4695            )
4696            .await
4697            .unwrap();
4698        assert_eq!(
4699            before.len(),
4700            1,
4701            "annotates edge must exist before base edge delete"
4702        );
4703
4704        // Delete the base edge.
4705        let deleted = rt.delete_edge(&tok, base_edge_uuid, true).await.unwrap();
4706        assert!(deleted, "edge delete must return true");
4707
4708        // The annotates edge targeting base_edge must be gone.
4709        let after = rt
4710            .neighbors(
4711                &tok,
4712                note.id,
4713                Direction::Out,
4714                None,
4715                Some(vec![EdgeRelation::Annotates]),
4716            )
4717            .await
4718            .unwrap();
4719        assert!(
4720            after.is_empty(),
4721            "annotates edge must be cascaded on base edge delete; got {after:?}"
4722        );
4723    }
4724
4725    #[tokio::test]
4726    async fn mixed_multi_annotates_partial_target_hard_delete_leaves_remaining_edges() {
4727        let rt = rt();
4728        let tok = NamespaceToken::local();
4729        let t1 = rt
4730            .create_entity(&tok, "concept", None, "T1", None, None, vec![])
4731            .await
4732            .unwrap();
4733        let t2 = rt
4734            .create_entity(&tok, "concept", None, "T2", None, None, vec![])
4735            .await
4736            .unwrap();
4737
4738        // Note annotates both t1 and t2.
4739        let note = rt
4740            .create_note(
4741                &tok,
4742                "observation",
4743                None,
4744                "multi-target note",
4745                Some(0.5),
4746                None,
4747                vec![t1.id, t2.id],
4748            )
4749            .await
4750            .unwrap();
4751
4752        let before = rt
4753            .neighbors(
4754                &tok,
4755                note.id,
4756                Direction::Out,
4757                None,
4758                Some(vec![EdgeRelation::Annotates]),
4759            )
4760            .await
4761            .unwrap();
4762        assert_eq!(
4763            before.len(),
4764            2,
4765            "must have 2 annotates edges before any delete"
4766        );
4767
4768        // Hard delete only t1.
4769        rt.delete_entity(&tok, t1.id, true).await.unwrap();
4770
4771        // Edge to t1 must be gone, edge to t2 must remain.
4772        let after = rt
4773            .neighbors(
4774                &tok,
4775                note.id,
4776                Direction::Out,
4777                None,
4778                Some(vec![EdgeRelation::Annotates]),
4779            )
4780            .await
4781            .unwrap();
4782        assert_eq!(
4783            after.len(),
4784            1,
4785            "only the edge to t1 must be cascaded; t2 edge must remain"
4786        );
4787        assert_eq!(
4788            after[0].node_id, t2.id,
4789            "remaining annotates edge must point to t2"
4790        );
4791    }
4792
4793    #[tokio::test]
4794    async fn annotated_note_soft_delete_preserves_annotate_edge() {
4795        let rt = rt();
4796        let tok = NamespaceToken::local();
4797        let note_target = rt
4798            .create_note(&tok, "observation", None, "target", Some(0.5), None, vec![])
4799            .await
4800            .unwrap();
4801        let note_source = rt
4802            .create_note(
4803                &tok,
4804                "insight",
4805                None,
4806                "annotation",
4807                Some(0.5),
4808                None,
4809                vec![note_target.id],
4810            )
4811            .await
4812            .unwrap();
4813
4814        let before = rt
4815            .neighbors(
4816                &tok,
4817                note_source.id,
4818                Direction::Out,
4819                None,
4820                Some(vec![EdgeRelation::Annotates]),
4821            )
4822            .await
4823            .unwrap();
4824        assert_eq!(before.len(), 1);
4825
4826        // Soft delete must NOT cascade edges (data-vs-view principle).
4827        let deleted = rt.delete_note(&tok, note_target.id, false).await.unwrap();
4828        assert!(deleted, "soft delete must return true");
4829
4830        let after = rt
4831            .neighbors(
4832                &tok,
4833                note_source.id,
4834                Direction::Out,
4835                None,
4836                Some(vec![EdgeRelation::Annotates]),
4837            )
4838            .await
4839            .unwrap();
4840        assert_eq!(
4841            after.len(),
4842            1,
4843            "soft delete must NOT cascade edges; got {after:?}"
4844        );
4845    }
4846
4847    // ---- delete_edge public-API safety (fix/annotates round-3) ----
4848
4849    // Passing an entity/note UUID to `delete_edge` must return Ok(false) with no
4850    // side effects — it must NOT delete inbound annotates edges targeting that record.
4851    // Without the get_edge guard, the old code would cascade inbound edges before
4852    // returning false.
4853    #[tokio::test]
4854    async fn delete_edge_non_edge_uuid_has_no_side_effects() {
4855        let rt = rt();
4856        let tok = NamespaceToken::local();
4857
4858        // Create an entity that has an inbound annotates edge.
4859        let entity = rt
4860            .create_entity(&tok, "concept", None, "Target", None, None, vec![])
4861            .await
4862            .unwrap();
4863        let note = rt
4864            .create_note(
4865                &tok,
4866                "observation",
4867                None,
4868                "annotates the entity",
4869                Some(0.5),
4870                None,
4871                vec![entity.id],
4872            )
4873            .await
4874            .unwrap();
4875
4876        // Confirm the annotates edge exists.
4877        let before = rt
4878            .neighbors(
4879                &tok,
4880                note.id,
4881                Direction::Out,
4882                None,
4883                Some(vec![EdgeRelation::Annotates]),
4884            )
4885            .await
4886            .unwrap();
4887        assert_eq!(before.len(), 1, "annotates edge must exist before test");
4888        let annotates_edge_id: Uuid = before[0].edge_id;
4889
4890        // Call delete_edge with the entity UUID (NOT an edge UUID).
4891        let result = rt.delete_edge(&tok, entity.id, true).await;
4892        assert!(
4893            result.is_ok(),
4894            "delete_edge must not error on a non-edge UUID"
4895        );
4896        assert!(
4897            !result.unwrap(),
4898            "delete_edge must return false for a non-edge UUID"
4899        );
4900
4901        // The inbound annotates edge to the entity must still exist — no side effects.
4902        let after = rt
4903            .neighbors(
4904                &tok,
4905                note.id,
4906                Direction::Out,
4907                None,
4908                Some(vec![EdgeRelation::Annotates]),
4909            )
4910            .await
4911            .unwrap();
4912        assert_eq!(
4913            after.len(),
4914            1,
4915            "delete_edge with a non-edge UUID must not touch inbound annotates edges"
4916        );
4917        assert_eq!(
4918            after[0].edge_id, annotates_edge_id,
4919            "the original annotates edge must be unchanged"
4920        );
4921    }
4922
4923    // ---- create_note compensation branch (fix/annotates round-3) ----
4924
4925    // This test injects a deterministic failure on the second `link` call inside
4926    // `create_note_inner` (the one that would create the second annotates edge).
4927    // It verifies that the compensation branch is wired — i.e. this test would
4928    // fail if the `Err(e)` rollback arm at operations.rs were deleted.
4929    //
4930    // Injection mechanism: LINK_FAIL_AFTER thread-local (ops.rs, cfg(test) only).
4931    // Setting it to 2 forces the 2nd link call to return an error.  The counter is
4932    // reset to 0 once triggered, so no other test is affected.
4933    #[tokio::test]
4934    async fn create_note_multi_annotates_second_link_failure_rolls_back_partial_write() {
4935        let rt = rt();
4936        let tok = NamespaceToken::local();
4937        let t1 = rt
4938            .create_entity(&tok, "concept", None, "T1", None, None, vec![])
4939            .await
4940            .unwrap();
4941        let t2 = rt
4942            .create_entity(&tok, "concept", None, "T2", None, None, vec![])
4943            .await
4944            .unwrap();
4945
4946        // Arm the injection: fail on the 2nd link (link_idx+1 == 2).
4947        LINK_FAIL_AFTER.with(|cell| cell.set(2));
4948
4949        let result = rt
4950            .create_note(
4951                &tok,
4952                "observation",
4953                None,
4954                "rollback target",
4955                Some(0.5),
4956                None,
4957                vec![t1.id, t2.id],
4958            )
4959            .await;
4960
4961        // The call must fail with the injected error.
4962        assert!(
4963            result.is_err(),
4964            "create_note must propagate the injected link failure"
4965        );
4966        let err_msg = result.unwrap_err().to_string();
4967        assert!(
4968            err_msg.contains("injected link failure"),
4969            "error must carry injection message; got: {err_msg}"
4970        );
4971
4972        // Compensation must have removed the note row.
4973        let notes = rt.list_notes(&tok, None, 1000, 0).await.unwrap();
4974        assert!(
4975            notes.is_empty(),
4976            "compensation must remove the note row; got {notes:?}"
4977        );
4978
4979        // FTS must have no hit for the content.
4980        let hits = rt
4981            .search_notes(&tok, "rollback target", None, 10, None, false)
4982            .await
4983            .unwrap();
4984        assert!(
4985            hits.is_empty(),
4986            "compensation must clean FTS index; got {hits:?}"
4987        );
4988
4989        // No partial annotates edges must remain (first edge must have been deleted).
4990        let edges_from_t1 = rt
4991            .neighbors(
4992                &tok,
4993                t1.id,
4994                Direction::In,
4995                None,
4996                Some(vec![EdgeRelation::Annotates]),
4997            )
4998            .await
4999            .unwrap();
5000        let edges_from_t2 = rt
5001            .neighbors(
5002                &tok,
5003                t2.id,
5004                Direction::In,
5005                None,
5006                Some(vec![EdgeRelation::Annotates]),
5007            )
5008            .await
5009            .unwrap();
5010        assert!(
5011            edges_from_t1.is_empty(),
5012            "compensation must delete the first annotates edge; got {edges_from_t1:?}"
5013        );
5014        assert!(
5015            edges_from_t2.is_empty(),
5016            "no second annotates edge must exist; got {edges_from_t2:?}"
5017        );
5018    }
5019
5020    // ---- #232 soft-delete index cleanup tests ----
5021
5022    #[tokio::test]
5023    async fn soft_delete_entity_removes_indexes() {
5024        let rt = rt();
5025        let tok = NamespaceToken::local();
5026        let entity = rt
5027            .create_entity(
5028                &tok,
5029                "concept",
5030                None,
5031                "QuantumEntanglement",
5032                Some("unique FTS term xzqjwv for soft delete test"),
5033                None,
5034                vec![],
5035            )
5036            .await
5037            .unwrap();
5038
5039        let ns = tok.namespace().as_str().to_string();
5040
5041        let before = rt
5042            .text(&tok)
5043            .unwrap()
5044            .search(TextSearchRequest {
5045                query: "xzqjwv".to_string(),
5046                mode: TextQueryMode::Plain,
5047                filter: Some(TextFilter {
5048                    namespaces: vec![ns.clone()],
5049                    ..Default::default()
5050                }),
5051                top_k: 10,
5052                snippet_chars: 100,
5053            })
5054            .await
5055            .unwrap();
5056        assert!(
5057            before.iter().any(|h| h.subject_id == entity.id),
5058            "entity must be in FTS before soft-delete"
5059        );
5060
5061        let deleted = rt.delete_entity(&tok, entity.id, false).await.unwrap();
5062        assert!(deleted, "soft delete must return true");
5063
5064        let after = rt
5065            .text(&tok)
5066            .unwrap()
5067            .search(TextSearchRequest {
5068                query: "xzqjwv".to_string(),
5069                mode: TextQueryMode::Plain,
5070                filter: Some(TextFilter {
5071                    namespaces: vec![ns],
5072                    ..Default::default()
5073                }),
5074                top_k: 10,
5075                snippet_chars: 100,
5076            })
5077            .await
5078            .unwrap();
5079        assert!(
5080            after.iter().all(|h| h.subject_id != entity.id),
5081            "soft-deleted entity must be removed from FTS index"
5082        );
5083    }
5084
5085    #[tokio::test]
5086    async fn soft_delete_note_removes_indexes() {
5087        let rt = rt();
5088        let tok = NamespaceToken::local();
5089        let note = rt
5090            .create_note(
5091                &tok,
5092                "observation",
5093                None,
5094                "SpectralDecomposition unique term yvwkqz for soft delete test",
5095                Some(0.7),
5096                None,
5097                vec![],
5098            )
5099            .await
5100            .unwrap();
5101
5102        let before = rt
5103            .search_notes(&tok, "yvwkqz", None, 10, None, false)
5104            .await
5105            .unwrap();
5106        assert!(
5107            before.iter().any(|h| h.note_id == note.id),
5108            "note must be in FTS before soft-delete"
5109        );
5110
5111        let deleted = rt.delete_note(&tok, note.id, false).await.unwrap();
5112        assert!(deleted, "soft delete must return true");
5113
5114        let after = rt
5115            .search_notes(&tok, "yvwkqz", None, 10, None, false)
5116            .await
5117            .unwrap();
5118        assert!(
5119            after.iter().all(|h| h.note_id != note.id),
5120            "soft-deleted note must be removed from FTS index"
5121        );
5122    }
5123
5124    // F010 (CRIT): ADR-002 base endpoint allowlist — unlisted triples must fail closed.
5125    // Document->Document Extends is not in the ADR-002 table; current generic fallthrough accepts it.
5126    #[tokio::test]
5127    async fn link_extends_document_to_document_returns_invalid_input() {
5128        let rt = rt();
5129        let tok = NamespaceToken::local();
5130        let d1 = rt
5131            .create_entity(&tok, "document", None, "DocA", None, None, vec![])
5132            .await
5133            .unwrap();
5134        let d2 = rt
5135            .create_entity(&tok, "document", None, "DocB", None, None, vec![])
5136            .await
5137            .unwrap();
5138        let result = rt
5139            .link(&tok, d1.id, d2.id, EdgeRelation::Extends, 1.0, None)
5140            .await;
5141        assert!(
5142            result.is_err(),
5143            "F010: document->document Extends must be rejected by ADR-002 allowlist; \
5144             current generic entity fallthrough incorrectly accepts it"
5145        );
5146    }
5147
5148    // F010 happy path: Concept->Concept Extends is in the ADR-002 allowlist and must succeed.
5149    #[tokio::test]
5150    async fn link_extends_concept_to_concept_succeeds() {
5151        let rt = rt();
5152        let tok = NamespaceToken::local();
5153        let a = rt
5154            .create_entity(&tok, "concept", None, "CA", None, None, vec![])
5155            .await
5156            .unwrap();
5157        let b = rt
5158            .create_entity(&tok, "concept", None, "CB", None, None, vec![])
5159            .await
5160            .unwrap();
5161        let result = rt
5162            .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
5163            .await;
5164        assert!(
5165            result.is_ok(),
5166            "F010: concept->concept Extends must be allowed (ADR-002 allowlist)"
5167        );
5168    }
5169
5170    // F012 (CRIT): CompetesWith is symmetric; reversed pair must deduplicate to one canonical row.
5171    // Current code stores both directions as distinct rows (no canonicalization).
5172    #[tokio::test]
5173    async fn link_symmetric_relation_canonicalizes_endpoint_order() {
5174        use khive_storage::EdgeFilter;
5175        let rt = rt();
5176        let tok = NamespaceToken::local();
5177        let a = rt
5178            .create_entity(&tok, "concept", None, "ConceptP", None, None, vec![])
5179            .await
5180            .unwrap();
5181        let b = rt
5182            .create_entity(&tok, "concept", None, "ConceptQ", None, None, vec![])
5183            .await
5184            .unwrap();
5185        // Link A->B then B->A with the same symmetric relation.
5186        rt.link(&tok, a.id, b.id, EdgeRelation::CompetesWith, 1.0, None)
5187            .await
5188            .unwrap();
5189        rt.link(&tok, b.id, a.id, EdgeRelation::CompetesWith, 1.0, None)
5190            .await
5191            .unwrap();
5192        let count = rt
5193            .graph(&tok)
5194            .unwrap()
5195            .count_edges(EdgeFilter::default())
5196            .await
5197            .unwrap();
5198        assert_eq!(
5199            count,
5200            1,
5201            "F012: CompetesWith is symmetric; A->B and B->A must deduplicate to one canonical row; \
5202             found {count} rows (canonicalization not yet implemented)"
5203        );
5204    }
5205
5206    // F010 (ADR-002): Supersedes — positive tests for all 5 allowed entity kinds.
5207    #[tokio::test]
5208    async fn f010_supersedes_document_to_document_allowed() {
5209        let rt = rt();
5210        let tok = NamespaceToken::local();
5211        let a = rt
5212            .create_entity(&tok, "document", None, "DocA", None, None, vec![])
5213            .await
5214            .unwrap();
5215        let b = rt
5216            .create_entity(&tok, "document", None, "DocB", None, None, vec![])
5217            .await
5218            .unwrap();
5219        let result = rt
5220            .link(&tok, b.id, a.id, EdgeRelation::Supersedes, 1.0, None)
5221            .await;
5222        assert!(
5223            result.is_ok(),
5224            "document->document Supersedes must be allowed (ADR-002:191), got {result:?}"
5225        );
5226    }
5227
5228    #[tokio::test]
5229    async fn f010_supersedes_artifact_to_artifact_allowed() {
5230        let rt = rt();
5231        let tok = NamespaceToken::local();
5232        let a = rt
5233            .create_entity(&tok, "artifact", None, "ArtA", None, None, vec![])
5234            .await
5235            .unwrap();
5236        let b = rt
5237            .create_entity(&tok, "artifact", None, "ArtB", None, None, vec![])
5238            .await
5239            .unwrap();
5240        let result = rt
5241            .link(&tok, b.id, a.id, EdgeRelation::Supersedes, 1.0, None)
5242            .await;
5243        assert!(
5244            result.is_ok(),
5245            "artifact->artifact Supersedes must be allowed (ADR-002:192), got {result:?}"
5246        );
5247    }
5248
5249    #[tokio::test]
5250    async fn f010_supersedes_service_to_service_allowed() {
5251        let rt = rt();
5252        let tok = NamespaceToken::local();
5253        let a = rt
5254            .create_entity(&tok, "service", None, "SvcA", None, None, vec![])
5255            .await
5256            .unwrap();
5257        let b = rt
5258            .create_entity(&tok, "service", None, "SvcB", None, None, vec![])
5259            .await
5260            .unwrap();
5261        let result = rt
5262            .link(&tok, b.id, a.id, EdgeRelation::Supersedes, 1.0, None)
5263            .await;
5264        assert!(
5265            result.is_ok(),
5266            "service->service Supersedes must be allowed (ADR-002:193), got {result:?}"
5267        );
5268    }
5269
5270    #[tokio::test]
5271    async fn f010_supersedes_dataset_to_dataset_allowed() {
5272        let rt = rt();
5273        let tok = NamespaceToken::local();
5274        let a = rt
5275            .create_entity(&tok, "dataset", None, "DataA", None, None, vec![])
5276            .await
5277            .unwrap();
5278        let b = rt
5279            .create_entity(&tok, "dataset", None, "DataB", None, None, vec![])
5280            .await
5281            .unwrap();
5282        let result = rt
5283            .link(&tok, b.id, a.id, EdgeRelation::Supersedes, 1.0, None)
5284            .await;
5285        assert!(
5286            result.is_ok(),
5287            "dataset->dataset Supersedes must be allowed (ADR-002:194), got {result:?}"
5288        );
5289    }
5290
5291    // F010 (ADR-002): Supersedes — negative tests for rejected entity kinds.
5292    #[tokio::test]
5293    async fn f010_supersedes_project_to_project_rejected() {
5294        let rt = rt();
5295        let tok = NamespaceToken::local();
5296        let a = rt
5297            .create_entity(&tok, "project", None, "ProjA", None, None, vec![])
5298            .await
5299            .unwrap();
5300        let b = rt
5301            .create_entity(&tok, "project", None, "ProjB", None, None, vec![])
5302            .await
5303            .unwrap();
5304        let result = rt
5305            .link(&tok, b.id, a.id, EdgeRelation::Supersedes, 1.0, None)
5306            .await;
5307        assert!(
5308            matches!(result, Err(RuntimeError::InvalidInput(_))),
5309            "project->project Supersedes must be rejected (not in ADR-002 allowlist), got {result:?}"
5310        );
5311    }
5312
5313    #[tokio::test]
5314    async fn f010_supersedes_person_to_person_rejected() {
5315        let rt = rt();
5316        let tok = NamespaceToken::local();
5317        let a = rt
5318            .create_entity(&tok, "person", None, "Alice", None, None, vec![])
5319            .await
5320            .unwrap();
5321        let b = rt
5322            .create_entity(&tok, "person", None, "Bob", None, None, vec![])
5323            .await
5324            .unwrap();
5325        let result = rt
5326            .link(&tok, b.id, a.id, EdgeRelation::Supersedes, 1.0, None)
5327            .await;
5328        assert!(
5329            matches!(result, Err(RuntimeError::InvalidInput(_))),
5330            "person->person Supersedes must be rejected (not in ADR-002 allowlist), got {result:?}"
5331        );
5332    }
5333
5334    #[tokio::test]
5335    async fn f010_supersedes_org_to_org_rejected() {
5336        let rt = rt();
5337        let tok = NamespaceToken::local();
5338        let a = rt
5339            .create_entity(&tok, "org", None, "OrgA", None, None, vec![])
5340            .await
5341            .unwrap();
5342        let b = rt
5343            .create_entity(&tok, "org", None, "OrgB", None, None, vec![])
5344            .await
5345            .unwrap();
5346        let result = rt
5347            .link(&tok, b.id, a.id, EdgeRelation::Supersedes, 1.0, None)
5348            .await;
5349        assert!(
5350            matches!(result, Err(RuntimeError::InvalidInput(_))),
5351            "org->org Supersedes must be rejected (not in ADR-002 allowlist), got {result:?}"
5352        );
5353    }
5354
5355    // Fix 1: Supersedes entity→entity — same kind (concept→concept) must be allowed.
5356    #[tokio::test]
5357    async fn f010_supersedes_same_kind_entity_allowed() {
5358        let rt = rt();
5359        let tok = NamespaceToken::local();
5360        let a = rt
5361            .create_entity(&tok, "concept", None, "OldV", None, None, vec![])
5362            .await
5363            .unwrap();
5364        let b = rt
5365            .create_entity(&tok, "concept", None, "NewV", None, None, vec![])
5366            .await
5367            .unwrap();
5368        let result = rt
5369            .link(&tok, b.id, a.id, EdgeRelation::Supersedes, 1.0, None)
5370            .await;
5371        assert!(
5372            result.is_ok(),
5373            "concept->concept Supersedes must be allowed by ADR-002 allowlist, got {result:?}"
5374        );
5375    }
5376
5377    // F161: ADR-009 target_backend invariant — all edges written through link() must have
5378    // target_backend = None because validate_edge_relation_endpoints already ensured the
5379    // target exists locally.
5380    #[tokio::test]
5381    async fn f161_link_always_writes_null_target_backend() {
5382        let rt = rt();
5383        let tok = NamespaceToken::local();
5384        let a = rt
5385            .create_entity(&tok, "concept", None, "A", None, None, vec![])
5386            .await
5387            .unwrap();
5388        let b = rt
5389            .create_entity(&tok, "concept", None, "B", None, None, vec![])
5390            .await
5391            .unwrap();
5392        let edge = rt
5393            .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
5394            .await
5395            .unwrap();
5396        assert!(
5397            edge.target_backend.is_none(),
5398            "ADR-009: target_backend must be None for locally-routed edges (F161); got {:?}",
5399            edge.target_backend
5400        );
5401    }
5402
5403    // F161: link_many must also write null target_backend for all local edges.
5404    #[tokio::test]
5405    async fn f161_link_many_always_writes_null_target_backend() {
5406        let rt = rt();
5407        let tok = NamespaceToken::local();
5408        let a = rt
5409            .create_entity(&tok, "concept", None, "A", None, None, vec![])
5410            .await
5411            .unwrap();
5412        let b = rt
5413            .create_entity(&tok, "concept", None, "B", None, None, vec![])
5414            .await
5415            .unwrap();
5416        let c = rt
5417            .create_entity(&tok, "concept", None, "C", None, None, vec![])
5418            .await
5419            .unwrap();
5420        let specs = vec![
5421            LinkSpec {
5422                namespace: None,
5423                source_id: a.id,
5424                target_id: b.id,
5425                relation: EdgeRelation::Extends,
5426                weight: 1.0,
5427                metadata: None,
5428            },
5429            LinkSpec {
5430                namespace: None,
5431                source_id: a.id,
5432                target_id: c.id,
5433                relation: EdgeRelation::Enables,
5434                weight: 1.0,
5435                metadata: None,
5436            },
5437        ];
5438        let edges = rt.link_many(&tok, specs).await.unwrap();
5439        for edge in &edges {
5440            assert!(
5441                edge.target_backend.is_none(),
5442                "ADR-009: target_backend must be None for locally-routed edges in link_many (F161); got {:?}",
5443                edge.target_backend
5444            );
5445        }
5446    }
5447
5448    // F012: symmetric relation neighbors — competes_with queried from the non-canonical
5449    // endpoint must still return results when direction=Out is requested.
5450    #[tokio::test]
5451    async fn f012_symmetric_neighbors_visible_from_both_endpoints() {
5452        let rt = rt();
5453        let tok = NamespaceToken::local();
5454        let a = rt
5455            .create_entity(&tok, "concept", None, "A", None, None, vec![])
5456            .await
5457            .unwrap();
5458        let b = rt
5459            .create_entity(&tok, "concept", None, "B", None, None, vec![])
5460            .await
5461            .unwrap();
5462        // Link A→B competes_with; if A.id > B.id the edge is stored as B→A (canonical).
5463        rt.link(&tok, a.id, b.id, EdgeRelation::CompetesWith, 1.0, None)
5464            .await
5465            .unwrap();
5466        // Both endpoints should see the edge regardless of direction=Out.
5467        let from_a = rt
5468            .neighbors(
5469                &tok,
5470                a.id,
5471                Direction::Out,
5472                None,
5473                Some(vec![EdgeRelation::CompetesWith]),
5474            )
5475            .await
5476            .unwrap();
5477        let from_b = rt
5478            .neighbors(
5479                &tok,
5480                b.id,
5481                Direction::Out,
5482                None,
5483                Some(vec![EdgeRelation::CompetesWith]),
5484            )
5485            .await
5486            .unwrap();
5487        assert_eq!(
5488            from_a.len(),
5489            1,
5490            "node A must see competes_with neighbor from Direction::Out (F012); got {from_a:?}"
5491        );
5492        assert_eq!(
5493            from_b.len(),
5494            1,
5495            "node B must see competes_with neighbor from Direction::Out (F012); got {from_b:?}"
5496        );
5497    }
5498
5499    // Fix 1: Supersedes entity→entity — cross-kind (concept→document) must be rejected.
5500    #[tokio::test]
5501    async fn f010_supersedes_cross_kind_entity_rejected() {
5502        let rt = rt();
5503        let tok = NamespaceToken::local();
5504        let concept = rt
5505            .create_entity(&tok, "concept", None, "MyConcept", None, None, vec![])
5506            .await
5507            .unwrap();
5508        let doc = rt
5509            .create_entity(&tok, "document", None, "MyDoc", None, None, vec![])
5510            .await
5511            .unwrap();
5512        let result = rt
5513            .link(
5514                &tok,
5515                concept.id,
5516                doc.id,
5517                EdgeRelation::Supersedes,
5518                1.0,
5519                None,
5520            )
5521            .await;
5522        assert!(
5523            matches!(result, Err(RuntimeError::InvalidInput(_))),
5524            "concept->document Supersedes must be rejected by ADR-002 allowlist, got {result:?}"
5525        );
5526    }
5527
5528    #[tokio::test]
5529    async fn delete_note_cross_namespace_returns_mismatch_error() {
5530        let rt = rt();
5531        let ns_a = NamespaceToken::for_namespace(Namespace::parse("ns-a").unwrap());
5532        let ns_b = NamespaceToken::for_namespace(Namespace::parse("ns-b").unwrap());
5533        let note = rt
5534            .create_note(
5535                &ns_a,
5536                "observation",
5537                None,
5538                "note in ns-a",
5539                Some(0.8),
5540                None,
5541                vec![],
5542            )
5543            .await
5544            .unwrap();
5545
5546        // Attempt to delete from a different namespace must return Ok(false) —
5547        // indistinguishable from absent (ADR-007, no existence oracle).
5548        let result = rt.delete_note(&ns_b, note.id, true).await;
5549        assert!(
5550            !result.unwrap(),
5551            "cross-namespace delete_note must return Ok(false), not NamespaceMismatch"
5552        );
5553
5554        // Note must still exist in ns-a after the failed cross-ns delete.
5555        let note_store = rt.notes(&ns_a).unwrap();
5556        let still_there = note_store.get_note(note.id).await.unwrap();
5557        assert!(
5558            still_there.is_some(),
5559            "note must survive cross-ns delete attempt"
5560        );
5561    }
5562
5563    // H1-bulk regression: parallel link_many calls with overlapping triples must
5564    // return the identical persisted edge ID, not locally-generated phantom IDs.
5565    //
5566    // Sequence:
5567    //   1. First link_many creates the A→B Extends edge (persisted with ID₁).
5568    //   2. Second link_many upserts the same triple (ON CONFLICT DO UPDATE keeps ID₁).
5569    //   3. Both callers must see ID₁ in their returned Vec<Edge>.
5570    #[tokio::test]
5571    async fn link_many_overlapping_triple_returns_persisted_ids() {
5572        let rt = rt();
5573        let tok = NamespaceToken::local();
5574        let a = rt
5575            .create_entity(&tok, "concept", None, "A", None, None, vec![])
5576            .await
5577            .unwrap();
5578        let b = rt
5579            .create_entity(&tok, "concept", None, "B", None, None, vec![])
5580            .await
5581            .unwrap();
5582
5583        let spec = || LinkSpec {
5584            namespace: None,
5585            source_id: a.id,
5586            target_id: b.id,
5587            relation: EdgeRelation::Extends,
5588            weight: 1.0,
5589            metadata: None,
5590        };
5591
5592        // First call — creates the edge.
5593        let first = rt.link_many(&tok, vec![spec()]).await.unwrap();
5594        assert_eq!(first.len(), 1);
5595        let persisted_id: Uuid = first[0].id.into();
5596
5597        // Second call — same natural-key triple; ON CONFLICT updates, preserving the
5598        // existing row ID. link_many must read back the row and return that same ID.
5599        let second = rt.link_many(&tok, vec![spec()]).await.unwrap();
5600        assert_eq!(second.len(), 1);
5601        let second_id: Uuid = second[0].id.into();
5602
5603        assert_eq!(
5604            persisted_id, second_id,
5605            "link_many with an existing triple must return the persisted row ID ({persisted_id}), \
5606             not a new phantom ID ({second_id})"
5607        );
5608
5609        // Confirm only one edge row exists in the graph store.
5610        let count = rt
5611            .count_edges(&tok, crate::curation::EdgeListFilter::default())
5612            .await
5613            .unwrap();
5614        assert_eq!(count, 1, "upsert must not duplicate the edge row");
5615    }
5616
5617    // ── #548 regression: cross-namespace get_edge must return None (ADR-007) ──
5618
5619    #[tokio::test]
5620    async fn get_edge_cross_namespace_returns_none() {
5621        let rt = rt();
5622        let ns_a = NamespaceToken::for_namespace(Namespace::parse("ns-a").unwrap());
5623        let ns_b = NamespaceToken::for_namespace(Namespace::parse("ns-b").unwrap());
5624
5625        let src = rt
5626            .create_entity(&ns_a, "concept", None, "Src", None, None, vec![])
5627            .await
5628            .unwrap();
5629        let tgt = rt
5630            .create_entity(&ns_a, "concept", None, "Tgt", None, None, vec![])
5631            .await
5632            .unwrap();
5633        let edge = rt
5634            .link(&ns_a, src.id, tgt.id, EdgeRelation::Extends, 1.0, None)
5635            .await
5636            .unwrap();
5637
5638        // Visible from own namespace.
5639        let ok = rt.get_edge(&ns_a, Uuid::from(edge.id)).await;
5640        assert!(
5641            ok.is_ok() && ok.unwrap().is_some(),
5642            "edge must be visible in its own namespace"
5643        );
5644
5645        // Foreign namespace must return None — indistinguishable from absent.
5646        let result = rt.get_edge(&ns_b, Uuid::from(edge.id)).await;
5647        assert!(
5648            matches!(result, Ok(None)),
5649            "cross-namespace get_edge must return Ok(None), got {result:?}"
5650        );
5651
5652        // ADR-007: absent and foreign edge IDs must have identical observable shape.
5653        let absent = rt.get_edge(&ns_b, Uuid::new_v4()).await;
5654        match (&result, &absent) {
5655            (Ok(None), Ok(None)) => {}
5656            other => panic!(
5657                "foreign and absent edge IDs must have identical observable shape, got {other:?}"
5658            ),
5659        }
5660    }
5661
5662    // ── #568 regression: foreign traversal root must yield no expansion ───────
5663
5664    #[tokio::test]
5665    async fn traverse_foreign_namespace_root_yields_no_expansion() {
5666        use khive_storage::types::TraversalOptions;
5667
5668        let rt = rt();
5669        let ns_a = NamespaceToken::for_namespace(Namespace::parse("ns-a").unwrap());
5670        let ns_b = NamespaceToken::for_namespace(Namespace::parse("ns-b").unwrap());
5671
5672        let a = rt
5673            .create_entity(&ns_a, "concept", None, "A", None, None, vec![])
5674            .await
5675            .unwrap();
5676        let b = rt
5677            .create_entity(&ns_a, "concept", None, "B", None, None, vec![])
5678            .await
5679            .unwrap();
5680        rt.link(&ns_a, a.id, b.id, EdgeRelation::Extends, 1.0, None)
5681            .await
5682            .unwrap();
5683
5684        // Traversal from ns_b using a root that belongs to ns_a must return nothing.
5685        let paths = rt
5686            .traverse(
5687                &ns_b,
5688                TraversalRequest {
5689                    roots: vec![a.id],
5690                    options: TraversalOptions {
5691                        max_depth: 1,
5692                        direction: Direction::Out,
5693                        ..Default::default()
5694                    },
5695                    include_roots: true,
5696                },
5697            )
5698            .await
5699            .unwrap();
5700        assert!(
5701            paths.is_empty(),
5702            "foreign traversal root must be filtered before expansion, got {paths:?}"
5703        );
5704    }
5705}