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