Skip to main content

khive_runtime/
operations.rs

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