Skip to main content

khive_runtime/
operations.rs

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