Skip to main content

khive_runtime/
operations.rs

1// FILE SIZE JUSTIFICATION: operations.rs is the single coherent surface for all
2// runtime verb implementations (create, get, list, search, link, traverse, query,
3// recall, etc.). All verbs share internal helpers (namespace checks, edge validation,
4// canonical-endpoint logic) that require pub(crate) access — splitting into submodules
5// would require pub(crate) re-exports across every helper or circular dependencies.
6// Inline tests exercise those private helpers directly. Split plan: once the verb
7// surface stabilises post-retrieval-refactor, group by substrate (entity,
8// note, edge, search) into submodules under an `operations/` directory.
9//! High-level operations composing storage capabilities into user-facing verbs.
10
11use std::collections::HashMap;
12use std::str::FromStr;
13
14use serde::Serialize;
15use uuid::Uuid;
16
17use khive_score::DeterministicScore;
18use khive_storage::note::Note;
19use khive_storage::types::{
20    BatchWriteSummary, DeleteMode, Direction, EdgeSortField, GraphPath, LinkId, NeighborHit,
21    NeighborQuery, Page, PageRequest, SortOrder, SqlRow, SqlStatement, SqlValue, TextFilter,
22    TextQueryMode, TextSearchRequest, TraversalRequest,
23};
24use khive_storage::{Edge, EdgeRelation, Entity, EntityFilter, Event, EventFilter};
25use khive_types::{EdgeEndpointRule, EndpointKind, EventKind, SubstrateKind};
26
27use khive_db::SqliteError;
28use rusqlite::OptionalExtension;
29
30use crate::curation::{entity_fts_document, note_fts_document};
31use crate::error::{RuntimeError, RuntimeResult};
32use crate::runtime::{KhiveRuntime, NamespaceToken};
33
34// Test-only failure injection for `create_note_inner`.
35//
36// A test sets `LINK_FAIL_AFTER` to N > 0 before calling `create_note`.  The
37// Nth `link` call inside the loop returns `RuntimeError::Internal("injected
38// link failure")` instead of calling the real implementation.  The counter is
39// reset to 0 after each call regardless of whether it triggered, so tests are
40// isolated from one another.
41//
42// `FTS_FAIL_NS` / `VECTOR_FAIL_NS`: namespace-targeted injection mutexes, armed via
43// `arm_fts_fail(ns)` / `arm_vector_fail(ns)`.  Gated behind
44// `cfg(any(test, feature = "fault-injection"))` so they compile out of release builds
45// entirely — no lock acquisitions on the hot path in production, and no fault-injection
46// surface in published binaries.  Namespace-targeting means only `create_note` calls
47// for the armed namespace fire the injection; concurrent tests on other namespaces are
48// unaffected, eliminating cross-test races without requiring `#[serial]`.
49// External integration test crates enable the feature via a dev-dependency:
50//   khive-runtime = { ..., features = ["fault-injection"] }
51#[cfg(test)]
52std::thread_local! {
53    static LINK_FAIL_AFTER: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
54}
55
56// Count-targetable vector-INSERT fault injection: when set to N (N > 0), the next N
57// vector insert calls succeed and the (N+1)-th returns an injected error.  After
58// triggering the counter resets to 0.  `thread_local!` provides per-thread isolation;
59// `#[tokio::test]` uses a current-thread runtime so there is no thread migration
60// mid-test.  This lets T-E3 let model-a's insert succeed and fail on model-b's.
61#[cfg(any(test, feature = "fault-injection"))]
62std::thread_local! {
63    static VECTOR_FAIL_AFTER: std::cell::Cell<Option<usize>> =
64        const { std::cell::Cell::new(None) };
65}
66
67/// Arm the count-targetable vector-INSERT fault: let `n` inserts succeed, then fail
68/// the next one.  Set `n = 0` to fail immediately on the first insert.
69/// Available when compiled with `cfg(test)` or `feature = "fault-injection"`.
70#[cfg(any(test, feature = "fault-injection"))]
71pub fn arm_vector_fail_after(n: usize) {
72    VECTOR_FAIL_AFTER.with(|cell| cell.set(Some(n)));
73}
74
75#[cfg(any(test, feature = "fault-injection"))]
76static FTS_FAIL_NS: std::sync::Mutex<Option<String>> = std::sync::Mutex::new(None);
77#[cfg(any(test, feature = "fault-injection"))]
78static VECTOR_FAIL_NS: std::sync::Mutex<Option<String>> = std::sync::Mutex::new(None);
79/// FTS failure injection for `create_many` — separate from `FTS_FAIL_NS` so that
80/// create_note_inner and create_many tests cannot disarm each other.
81#[cfg(any(test, feature = "fault-injection"))]
82static FTS_FAIL_MANY_NS: std::sync::Mutex<Option<String>> = std::sync::Mutex::new(None);
83/// FTS partial-failure injection for `create_many` — returns `Ok(BatchWriteSummary)`
84/// with `failed > 0` so that the `summary.failed > 0` rollback branch is exercised.
85/// Distinct from `FTS_FAIL_MANY_NS` which injects a hard `Err`.
86#[cfg(any(test, feature = "fault-injection"))]
87static FTS_FAIL_MANY_PARTIAL_NS: std::sync::Mutex<Option<String>> = std::sync::Mutex::new(None);
88
89/// Arm the FTS failure injection for `create_note_inner` targeting namespace `ns`.
90///
91/// The next `create_note` call whose note namespace equals `ns` returns an injected
92/// error at the FTS upsert step (after the note row is committed), then disarms.
93/// Calls on other namespaces are unaffected.
94/// Available when compiled with `cfg(test)` or `feature = "fault-injection"`.
95#[cfg(any(test, feature = "fault-injection"))]
96pub fn arm_fts_fail(ns: &str) {
97    *FTS_FAIL_NS.lock().unwrap() = Some(ns.to_string());
98}
99
100/// Arm the FTS failure injection for `create_many` targeting namespace `ns`.
101///
102/// The next `create_many` call whose namespace equals `ns` returns an injected
103/// error at the FTS upsert step (after entity rows are committed), then disarms.
104/// Calls on other namespaces are unaffected.
105/// Available when compiled with `cfg(test)` or `feature = "fault-injection"`.
106#[cfg(any(test, feature = "fault-injection"))]
107pub fn arm_fts_fail_many(ns: &str) {
108    *FTS_FAIL_MANY_NS.lock().unwrap() = Some(ns.to_string());
109}
110
111/// Arm the FTS *partial*-failure injection for `create_many` targeting namespace `ns`.
112///
113/// The next `create_many` call whose namespace equals `ns` returns
114/// `Ok(BatchWriteSummary { attempted: 2, affected: 1, failed: 1, ... })` from the
115/// FTS upsert step, exercising the `summary.failed > 0` rollback branch (as opposed
116/// to the hard-`Err` branch exercised by `arm_fts_fail_many`).  Then disarms.
117/// Available when compiled with `cfg(test)` or `feature = "fault-injection"`.
118#[cfg(any(test, feature = "fault-injection"))]
119pub fn arm_fts_fail_many_partial(ns: &str) {
120    *FTS_FAIL_MANY_PARTIAL_NS.lock().unwrap() = Some(ns.to_string());
121}
122
123/// Arm the vector insertion failure injection for `create_note_inner` targeting `ns`.
124///
125/// The next `create_note` call whose note namespace equals `ns` returns an injected
126/// error at the first vector insert step, then disarms.  Calls on other namespaces
127/// are unaffected.
128/// Available when compiled with `cfg(test)` or `feature = "fault-injection"`.
129#[cfg(any(test, feature = "fault-injection"))]
130pub fn arm_vector_fail(ns: &str) {
131    *VECTOR_FAIL_NS.lock().unwrap() = Some(ns.to_string());
132}
133
134/// A note search result with UUID, salience-weighted RRF score, and display text.
135#[derive(Clone, Debug)]
136pub struct NoteSearchHit {
137    pub note_id: Uuid,
138    pub score: DeterministicScore,
139    pub title: Option<String>,
140    pub snippet: Option<String>,
141}
142
143fn text_preview(text: &str, max_chars: usize) -> Option<String> {
144    let trimmed = text.trim();
145    if trimmed.is_empty() {
146        None
147    } else {
148        Some(trimmed.chars().take(max_chars).collect())
149    }
150}
151
152/// Symmetric relations (`competes_with`, `composed_with`) are stored with a
153/// canonical source (lower UUID wins), so a directed `Out` or `In` query may
154/// miss results. When the relations filter is non-empty and contains **only**
155/// symmetric relations, override direction to `Both` so callers always see all
156/// edges for these relations regardless of storage canonicalization.
157fn normalize_symmetric_direction(
158    direction: Direction,
159    relations: Option<&[EdgeRelation]>,
160) -> Direction {
161    let Some(rels) = relations else {
162        return direction;
163    };
164    if rels.is_empty() {
165        return direction;
166    }
167    let all_symmetric = rels
168        .iter()
169        .all(|r| matches!(r, EdgeRelation::CompetesWith | EdgeRelation::ComposedWith));
170    if all_symmetric {
171        Direction::Both
172    } else {
173        direction
174    }
175}
176
177fn note_title(note: &Note) -> Option<String> {
178    note.name
179        .clone()
180        .filter(|s| !s.trim().is_empty())
181        .or_else(|| Some(format!("[{}]", note.kind.as_str())))
182}
183
184fn note_snippet(note: &Note) -> Option<String> {
185    text_preview(&note.content, 200)
186}
187
188/// Result of resolving a UUID to its substrate kind.
189#[derive(Clone, Debug)]
190pub enum Resolved {
191    Entity(Entity),
192    Note(Note),
193    Event(Event),
194    /// A record owned by a pack's private tables.
195    ///
196    /// `pack` identifies the owning pack by name, `kind` is the pack-local
197    /// record type (e.g. "domain", "atom"), and `data` is the full record as
198    /// a JSON Value. Pack-private records are not valid edge endpoints,
199    /// annotates sources, or task context entities.
200    PackRecord {
201        pack: String,
202        kind: String,
203        data: serde_json::Value,
204    },
205}
206
207/// Map a resolved endpoint to its `(substrate, kind, entity_type)` triple, or
208/// `None` if the substrate is not a valid edge endpoint (events, edges).
209///
210/// `entity_type` carries the pack-owned granular subtype (`Entity::entity_type`,
211/// e.g. `"theorem"`); it is `None` for notes and for entities with no subtype.
212fn resolved_pair(r: Option<&Resolved>) -> Option<(&'static str, &str, Option<&str>)> {
213    match r? {
214        Resolved::Entity(e) => Some(("entity", e.kind.as_str(), e.entity_type.as_deref())),
215        Resolved::Note(n) => Some(("note", n.kind.as_str(), None)),
216        Resolved::Event(_) => None,
217        Resolved::PackRecord { .. } => None,
218    }
219}
220
221/// `true` if `spec` matches the given substrate + kind + entity_type triple.
222fn endpoint_matches(
223    spec: &EndpointKind,
224    substrate: &str,
225    kind: &str,
226    entity_type: Option<&str>,
227) -> bool {
228    match spec {
229        EndpointKind::EntityOfKind(k) => substrate == "entity" && *k == kind,
230        EndpointKind::NoteOfKind(k) => substrate == "note" && *k == kind,
231        EndpointKind::EntityOfType {
232            kind: k,
233            entity_type: t,
234        } => substrate == "entity" && *k == kind && entity_type == Some(*t),
235    }
236}
237
238/// `true` if any pack-declared edge endpoint rule allows the
239/// `(source, relation, target)` triple. Pack rules are additive only.
240fn pack_rule_allows(
241    rules: &[EdgeEndpointRule],
242    relation: EdgeRelation,
243    src: Option<&Resolved>,
244    tgt: Option<&Resolved>,
245) -> bool {
246    let Some((src_sub, src_kind, src_type)) = resolved_pair(src) else {
247        return false;
248    };
249    let Some((tgt_sub, tgt_kind, tgt_type)) = resolved_pair(tgt) else {
250        return false;
251    };
252    rules.iter().any(|r| {
253        r.relation == relation
254            && endpoint_matches(&r.source, src_sub, src_kind, src_type)
255            && endpoint_matches(&r.target, tgt_sub, tgt_kind, tgt_type)
256    })
257}
258
259/// Base entity endpoint allowlist — the closed set of permitted entity→entity
260/// relation triples.
261///
262/// Each entry `(src_kind, relation, tgt_kind)` explicitly allows that combination.
263/// `"*"` as `src_kind` means "any entity kind" (used by `instance_of` whose source
264/// is unrestricted).
265///
266/// Pack rules (via `EDGE_RULES`) are additive — they cannot remove rows here.
267/// Exposed via `base_entity_endpoint_rules()` for the ADR-076 certificate tests.
268pub const BASE_ENTITY_ENDPOINT_RULES: &[(&str, EdgeRelation, &str)] = &[
269    // Structure
270    ("concept", EdgeRelation::Contains, "concept"),
271    ("project", EdgeRelation::Contains, "project"),
272    ("project", EdgeRelation::Contains, "artifact"),
273    ("org", EdgeRelation::Contains, "project"),
274    ("org", EdgeRelation::Contains, "service"),
275    ("concept", EdgeRelation::PartOf, "concept"),
276    ("project", EdgeRelation::PartOf, "project"),
277    ("project", EdgeRelation::PartOf, "org"),
278    ("*", EdgeRelation::InstanceOf, "concept"),
279    ("service", EdgeRelation::InstanceOf, "project"),
280    // Derivation
281    ("concept", EdgeRelation::Extends, "concept"),
282    ("concept", EdgeRelation::VariantOf, "concept"),
283    ("artifact", EdgeRelation::VariantOf, "artifact"),
284    ("concept", EdgeRelation::IntroducedBy, "document"),
285    ("concept", EdgeRelation::IntroducedBy, "person"),
286    ("artifact", EdgeRelation::IntroducedBy, "document"),
287    // Provenance
288    ("artifact", EdgeRelation::DerivedFrom, "dataset"),
289    ("artifact", EdgeRelation::DerivedFrom, "document"),
290    ("artifact", EdgeRelation::DerivedFrom, "project"),
291    ("artifact", EdgeRelation::DerivedFrom, "artifact"),
292    // Temporal
293    ("document", EdgeRelation::Precedes, "document"),
294    ("dataset", EdgeRelation::Precedes, "dataset"),
295    ("artifact", EdgeRelation::Precedes, "artifact"),
296    ("service", EdgeRelation::Precedes, "service"),
297    ("project", EdgeRelation::Precedes, "project"),
298    // Dependency
299    ("project", EdgeRelation::DependsOn, "project"),
300    ("service", EdgeRelation::DependsOn, "project"),
301    ("service", EdgeRelation::DependsOn, "service"),
302    ("service", EdgeRelation::DependsOn, "artifact"),
303    ("service", EdgeRelation::DependsOn, "dataset"),
304    ("artifact", EdgeRelation::DependsOn, "project"),
305    ("artifact", EdgeRelation::DependsOn, "service"),
306    ("concept", EdgeRelation::Enables, "concept"),
307    ("service", EdgeRelation::Enables, "concept"),
308    ("dataset", EdgeRelation::Enables, "concept"),
309    // Implementation
310    ("project", EdgeRelation::Implements, "concept"),
311    ("service", EdgeRelation::Implements, "concept"),
312    // Lateral
313    ("concept", EdgeRelation::CompetesWith, "concept"),
314    ("project", EdgeRelation::CompetesWith, "project"),
315    ("service", EdgeRelation::CompetesWith, "service"),
316    ("concept", EdgeRelation::ComposedWith, "concept"),
317    ("project", EdgeRelation::ComposedWith, "project"),
318    // Versioning (Supersedes — Concept/Document/Artifact/Service/Dataset only)
319    ("concept", EdgeRelation::Supersedes, "concept"),
320    ("document", EdgeRelation::Supersedes, "document"),
321    ("artifact", EdgeRelation::Supersedes, "artifact"),
322    ("service", EdgeRelation::Supersedes, "service"),
323    ("dataset", EdgeRelation::Supersedes, "dataset"),
324    // Epistemic (Supports/Refutes — evidence sources → Concept claim only)
325    ("concept", EdgeRelation::Supports, "concept"),
326    ("document", EdgeRelation::Supports, "concept"),
327    ("dataset", EdgeRelation::Supports, "concept"),
328    ("artifact", EdgeRelation::Supports, "concept"),
329    ("concept", EdgeRelation::Refutes, "concept"),
330    ("document", EdgeRelation::Refutes, "concept"),
331    ("dataset", EdgeRelation::Refutes, "concept"),
332    ("artifact", EdgeRelation::Refutes, "concept"),
333];
334
335/// Returns the base entity endpoint allowlist.
336///
337/// The returned slice is the same data that `base_entity_rule_allows` consults at
338/// runtime. Exposed for the ADR-076 certificate tests in `khive-pack-kg`, which
339/// must audit live rules rather than hand-copied snapshots.
340pub fn base_entity_endpoint_rules() -> &'static [(&'static str, EdgeRelation, &'static str)] {
341    BASE_ENTITY_ENDPOINT_RULES
342}
343
344fn base_entity_rule_allows(src_kind: &str, relation: EdgeRelation, tgt_kind: &str) -> bool {
345    BASE_ENTITY_ENDPOINT_RULES.iter().any(|(src, rel, tgt)| {
346        *rel == relation && (*src == "*" || *src == src_kind) && *tgt == tgt_kind
347    })
348}
349
350/// Canonical endpoint order for symmetric relations (F012).
351///
352/// For `competes_with` and `composed_with`, normalises direction so that
353/// `source_uuid < target_uuid` (lexicographic on the UUID bytes). This
354/// collapses A→B and B→A into a single canonical row, preventing duplicates.
355pub(crate) fn canonical_edge_endpoints(
356    relation: EdgeRelation,
357    source_id: Uuid,
358    target_id: Uuid,
359) -> (Uuid, Uuid) {
360    if relation.is_symmetric() && target_id < source_id {
361        (target_id, source_id)
362    } else {
363        (source_id, target_id)
364    }
365}
366
367/// Infer the default `dependency_kind` from endpoint entity kinds.
368fn infer_dependency_kind(src_kind: &str, tgt_kind: &str) -> Option<&'static str> {
369    match (src_kind, tgt_kind) {
370        ("project", "project") => Some("build"),
371        ("service", "service") => Some("runtime"),
372        ("service", "dataset") => Some("data"),
373        ("service", "artifact") => Some("artifact"),
374        ("artifact", "project") | ("artifact", "service") => Some("tooling"),
375        _ => None,
376    }
377}
378
379/// Merge an inferred `dependency_kind` into `depends_on` edge metadata.
380///
381/// If `metadata` already carries a `dependency_kind` key the existing value is
382/// preserved. If the key is absent and the endpoint pair has a known default,
383/// the inferred value is added. Returns `metadata` unchanged for all other
384/// cases (no matching default, or metadata already has the key).
385fn merge_dependency_kind(
386    src_kind: &str,
387    tgt_kind: &str,
388    metadata: Option<serde_json::Value>,
389) -> Option<serde_json::Value> {
390    if let Some(ref m) = metadata {
391        if m.get("dependency_kind").is_some() {
392            return metadata;
393        }
394    }
395    let inferred = infer_dependency_kind(src_kind, tgt_kind)?;
396    let mut obj = metadata.unwrap_or_else(|| serde_json::json!({}));
397    if let Some(o) = obj.as_object_mut() {
398        o.insert("dependency_kind".to_string(), serde_json::json!(inferred));
399    }
400    Some(obj)
401}
402
403/// Valid `dependency_kind` values for `depends_on` edges.
404const VALID_DEPENDENCY_KINDS: &[&str] = &["build", "runtime", "data", "artifact", "tooling"];
405
406/// Validate that an edge weight is finite and within `[0.0, 1.0]`.
407///
408/// Rejects NaN, infinities, negative values, and values exceeding 1.0.
409/// Used by `link` and `import_kg` to enforce the weight invariant consistently
410/// across all edge creation paths.
411pub(crate) fn validate_edge_weight(weight: f64) -> RuntimeResult<()> {
412    if !weight.is_finite() || !(0.0..=1.0).contains(&weight) {
413        return Err(RuntimeError::InvalidInput(format!(
414            "edge weight must be finite and in [0.0, 1.0], got {weight}"
415        )));
416    }
417    Ok(())
418}
419
420/// Validate governed edge metadata keys.
421///
422/// Currently enforces:
423/// - `dependency_kind` is only valid on `depends_on` edges.
424/// - `dependency_kind`, when present, must be one of the five governed values.
425fn validate_edge_metadata(
426    relation: EdgeRelation,
427    metadata: Option<&serde_json::Value>,
428) -> RuntimeResult<()> {
429    let Some(meta) = metadata else {
430        return Ok(());
431    };
432    if let Some(dk) = meta.get("dependency_kind") {
433        if relation != EdgeRelation::DependsOn {
434            return Err(RuntimeError::InvalidInput(format!(
435                "dependency_kind is only valid on depends_on edges (got {})",
436                relation.as_str()
437            )));
438        }
439        let dk_str = dk
440            .as_str()
441            .ok_or_else(|| RuntimeError::InvalidInput("dependency_kind must be a string".into()))?;
442        if !VALID_DEPENDENCY_KINDS.contains(&dk_str) {
443            return Err(RuntimeError::InvalidInput(format!(
444                "unknown dependency_kind {dk_str:?}; valid: {}",
445                VALID_DEPENDENCY_KINDS.join(" | ")
446            )));
447        }
448    }
449    Ok(())
450}
451
452/// Returns `true` when `note_props` is a superset of all key-value pairs in `filter`.
453///
454/// Mirrors the semantics of `khive_pack_kg::handlers::common::props_match` so that the
455/// storage-leg predicate in `search_notes` is identical to the handler-side post-filter.
456fn note_props_match(note_props: Option<&serde_json::Value>, filter: &serde_json::Value) -> bool {
457    let required = match filter.as_object() {
458        Some(obj) if !obj.is_empty() => obj,
459        _ => return true,
460    };
461    let actual = match note_props.and_then(serde_json::Value::as_object) {
462        Some(obj) => obj,
463        None => return false,
464    };
465    required
466        .iter()
467        .all(|(k, v)| actual.get(k).is_some_and(|av| av == v))
468}
469
470impl KhiveRuntime {
471    // ---- Entity operations ----
472
473    /// Create and persist a new entity.
474    // REASON: entity creation requires kind, type, name, description, properties, tags, and
475    // namespace token — refactoring into a builder would add indirection without reducing
476    // caller complexity; this signature mirrors the MCP verb surface directly.
477    #[allow(clippy::too_many_arguments)]
478    pub async fn create_entity(
479        &self,
480        token: &NamespaceToken,
481        kind: &str,
482        entity_type: Option<&str>,
483        name: &str,
484        description: Option<&str>,
485        properties: Option<serde_json::Value>,
486        tags: Vec<String>,
487    ) -> RuntimeResult<Entity> {
488        self.validate_entity_kind(kind)?;
489        // Secret gate: scan name, description, structured properties, and tags.
490        crate::secret_gate::check(name)?;
491        if let Some(d) = description {
492            crate::secret_gate::check(d)?;
493        }
494        if let Some(ref p) = properties {
495            crate::secret_gate::check_json(p)?;
496        }
497        crate::secret_gate::check_tags(&tags)?;
498        let ns = token.namespace().as_str();
499        let mut entity = Entity::new(ns, kind, name).with_entity_type(entity_type);
500        if let Some(d) = description {
501            entity = entity.with_description(d);
502        }
503        if let Some(p) = properties {
504            entity = entity.with_properties(p);
505        }
506        if !tags.is_empty() {
507            entity = entity.with_tags(tags);
508        }
509        self.entities(token)?.upsert_entity(entity.clone()).await?;
510
511        let doc = entity_fts_document(&entity);
512        let embed_body = doc.body.clone();
513
514        // FTS step — compensate entity row on failure (mirrors create_note_inner).
515        {
516            #[cfg(any(test, feature = "fault-injection"))]
517            let fts_inject = {
518                let mut g = FTS_FAIL_NS.lock().unwrap();
519                if g.as_deref() == Some(ns) {
520                    *g = None;
521                    true
522                } else {
523                    false
524                }
525            };
526            #[cfg(not(any(test, feature = "fault-injection")))]
527            let fts_inject = false;
528            let fts_result: RuntimeResult<()> = if fts_inject {
529                Err(RuntimeError::Internal("injected FTS failure".to_string()))
530            } else {
531                match self.text(token) {
532                    Ok(fts) => fts.upsert_document(doc).await.map_err(RuntimeError::from),
533                    Err(e) => Err(e),
534                }
535            };
536            if let Err(e) = fts_result {
537                if let Ok(store) = self.entities(token) {
538                    if let Err(ce) = store.delete_entity(entity.id, DeleteMode::Hard).await {
539                        tracing::error!(
540                            error = %ce,
541                            id = %entity.id,
542                            "create_entity: failed to roll back entity row after FTS failure"
543                        );
544                    }
545                }
546                return Err(e);
547            }
548        }
549
550        // Vector embedding + insert step — compensate entity row + FTS doc on failure.
551        // Fan out to ALL registered models (mirrors create_note_inner multi-model path).
552        let embed_model_names = {
553            let names = self.registered_embedding_model_names();
554            if names.is_empty() {
555                vec![]
556            } else {
557                names
558            }
559        };
560
561        if embed_model_names.len() == 1 {
562            let model_name = &embed_model_names[0];
563            let vec_result = self
564                .embed_document_with_model(model_name, &embed_body)
565                .await;
566
567            #[cfg(any(test, feature = "fault-injection"))]
568            let vec_inject = {
569                let mut g = VECTOR_FAIL_NS.lock().unwrap();
570                if g.as_deref() == Some(ns) {
571                    *g = None;
572                    true
573                } else {
574                    false
575                }
576            };
577            #[cfg(not(any(test, feature = "fault-injection")))]
578            let vec_inject = false;
579            let vec_result: RuntimeResult<Vec<f32>> = if vec_inject {
580                Err(RuntimeError::Internal(
581                    "injected vector failure".to_string(),
582                ))
583            } else {
584                vec_result
585            };
586
587            let single_result: RuntimeResult<()> = match vec_result {
588                Ok(vector) => match self.vectors_for_model(token, model_name) {
589                    Ok(vs) => vs
590                        .insert(
591                            entity.id,
592                            SubstrateKind::Entity,
593                            ns,
594                            "entity.body",
595                            vec![vector],
596                        )
597                        .await
598                        .map_err(RuntimeError::from),
599                    Err(e) => Err(e),
600                },
601                Err(e) => Err(e),
602            };
603            if let Err(e) = single_result {
604                if let Ok(store) = self.entities(token) {
605                    if let Err(ce) = store.delete_entity(entity.id, DeleteMode::Hard).await {
606                        tracing::error!(
607                            error = %ce,
608                            id = %entity.id,
609                            "create_entity: failed to roll back entity row after vector failure"
610                        );
611                    }
612                }
613                if let Ok(fts) = self.text(token) {
614                    if let Err(ce) = fts.delete_document(ns, entity.id).await {
615                        tracing::error!(
616                            error = %ce,
617                            id = %entity.id,
618                            "create_entity: failed to roll back FTS document after vector failure"
619                        );
620                    }
621                }
622                return Err(e);
623            }
624        } else if !embed_model_names.is_empty() {
625            // Multi-model path: embed with each model in parallel, then insert sequentially
626            // with inserted_models tracking for rollback on partial failure.
627            let rt_clone = self.clone();
628            let body_owned = embed_body.clone();
629            let mut handles = Vec::with_capacity(embed_model_names.len());
630            for model_name in &embed_model_names {
631                let rt = rt_clone.clone();
632                let text = body_owned.clone();
633                let name = model_name.clone();
634                handles.push(tokio::spawn(async move {
635                    rt.embed_document_with_model(&name, &text).await
636                }));
637            }
638            let mut vectors: Vec<Vec<f32>> = Vec::with_capacity(embed_model_names.len());
639            for handle in handles {
640                let join_result = handle
641                    .await
642                    .map_err(|e| RuntimeError::Internal(format!("embed task panicked: {e}")));
643                match join_result {
644                    Err(e) => {
645                        if let Ok(store) = self.entities(token) {
646                            if let Err(ce) = store.delete_entity(entity.id, DeleteMode::Hard).await
647                            {
648                                tracing::error!(
649                                    error = %ce,
650                                    id = %entity.id,
651                                    "create_entity: failed to roll back entity row after embed task panic"
652                                );
653                            }
654                        }
655                        if let Ok(fts) = self.text(token) {
656                            if let Err(ce) = fts.delete_document(ns, entity.id).await {
657                                tracing::error!(
658                                    error = %ce,
659                                    id = %entity.id,
660                                    "create_entity: failed to roll back FTS document after embed task panic"
661                                );
662                            }
663                        }
664                        return Err(e);
665                    }
666                    Ok(Err(e)) => {
667                        if let Ok(store) = self.entities(token) {
668                            if let Err(ce) = store.delete_entity(entity.id, DeleteMode::Hard).await
669                            {
670                                tracing::error!(
671                                    error = %ce,
672                                    id = %entity.id,
673                                    "create_entity: failed to roll back entity row after embed failure"
674                                );
675                            }
676                        }
677                        if let Ok(fts) = self.text(token) {
678                            if let Err(ce) = fts.delete_document(ns, entity.id).await {
679                                tracing::error!(
680                                    error = %ce,
681                                    id = %entity.id,
682                                    "create_entity: failed to roll back FTS document after embed failure"
683                                );
684                            }
685                        }
686                        return Err(e);
687                    }
688                    Ok(Ok(vec)) => vectors.push(vec),
689                }
690            }
691            // TODO(P2): parallelize vector inserts
692            let mut inserted_models: Vec<String> = Vec::with_capacity(embed_model_names.len());
693            for (model_name, vector) in embed_model_names.iter().zip(vectors.into_iter()) {
694                // Count-targetable fault injection for multi-model insert path (T-E3).
695                #[cfg(any(test, feature = "fault-injection"))]
696                let count_inject = VECTOR_FAIL_AFTER.with(|cell| match cell.get() {
697                    Some(0) => {
698                        cell.set(None);
699                        true
700                    }
701                    Some(n) => {
702                        cell.set(Some(n - 1));
703                        false
704                    }
705                    None => false,
706                });
707                #[cfg(not(any(test, feature = "fault-injection")))]
708                let count_inject = false;
709
710                let insert_result = if count_inject {
711                    Err(RuntimeError::Internal(
712                        "injected vector insert failure".to_string(),
713                    ))
714                } else {
715                    match self.vectors_for_model(token, model_name) {
716                        Ok(vs) => vs
717                            .insert(
718                                entity.id,
719                                SubstrateKind::Entity,
720                                ns,
721                                "entity.body",
722                                vec![vector],
723                            )
724                            .await
725                            .map_err(RuntimeError::from),
726                        Err(e) => Err(e),
727                    }
728                };
729                if let Err(e) = insert_result {
730                    // Compensate entity row + FTS + already-inserted vectors.
731                    if let Ok(store) = self.entities(token) {
732                        if let Err(ce) = store.delete_entity(entity.id, DeleteMode::Hard).await {
733                            tracing::error!(
734                                error = %ce,
735                                id = %entity.id,
736                                "create_entity: failed to roll back entity row after vector insert failure"
737                            );
738                        }
739                    }
740                    if let Ok(fts) = self.text(token) {
741                        if let Err(ce) = fts.delete_document(ns, entity.id).await {
742                            tracing::error!(
743                                error = %ce,
744                                id = %entity.id,
745                                "create_entity: failed to roll back FTS document after vector insert failure"
746                            );
747                        }
748                    }
749                    for m in &inserted_models {
750                        if let Ok(vs) = self.vectors_for_model(token, m) {
751                            if let Err(ce) = vs.delete(entity.id).await {
752                                tracing::error!(
753                                    error = %ce,
754                                    model = m,
755                                    id = %entity.id,
756                                    "create_entity: failed to roll back vector for model after insert failure"
757                                );
758                            }
759                        }
760                    }
761                    return Err(e);
762                }
763                inserted_models.push(model_name.clone());
764            }
765        }
766
767        Ok(entity)
768    }
769
770    /// Retrieve an entity by ID.
771    ///
772    /// UUID v4 is globally unique — no namespace filter on by-ID ops (ADR-007 rule 2).
773    pub async fn get_entity(&self, token: &NamespaceToken, id: Uuid) -> RuntimeResult<Entity> {
774        self.entities(token)?
775            .get_entity(id)
776            .await?
777            .ok_or_else(|| RuntimeError::NotFound(format!("entity {id}")))
778    }
779
780    /// Retrieve an entity by ID including soft-deleted rows.
781    ///
782    /// UUID v4 is globally unique — no namespace filter on by-ID ops (ADR-007 rule 2).
783    pub async fn get_entity_including_deleted(
784        &self,
785        token: &NamespaceToken,
786        id: Uuid,
787    ) -> RuntimeResult<Option<Entity>> {
788        self.entities(token)?
789            .get_entity_including_deleted(id)
790            .await
791            .map_err(Into::into)
792    }
793
794    /// Retrieve a note by ID including soft-deleted rows.
795    ///
796    /// UUID v4 is globally unique — no namespace filter on by-ID ops (ADR-007 rule 2).
797    pub async fn get_note_including_deleted(
798        &self,
799        token: &NamespaceToken,
800        id: Uuid,
801    ) -> RuntimeResult<Option<khive_storage::note::Note>> {
802        self.notes(token)?
803            .get_note_including_deleted(id)
804            .await
805            .map_err(Into::into)
806    }
807
808    /// Fetch multiple entities by ID, returning only those that exist in the
809    /// caller's namespace.  Missing or namespace-mismatched IDs are silently
810    /// omitted so that batch lookups don't abort on a single stale reference.
811    pub async fn get_entities_by_ids(
812        &self,
813        token: &NamespaceToken,
814        ids: &[Uuid],
815    ) -> RuntimeResult<Vec<Entity>> {
816        if ids.is_empty() {
817            return Ok(vec![]);
818        }
819        let filter = EntityFilter {
820            ids: ids.to_vec(),
821            ..Default::default()
822        };
823        let page = self
824            .entities(token)?
825            .query_entities(
826                token.namespace().as_str(),
827                filter,
828                PageRequest {
829                    offset: 0,
830                    limit: ids.len() as u32,
831                },
832            )
833            .await?;
834        Ok(page.items)
835    }
836
837    /// Like `get_entities_by_ids` but scoped to the token's full visible-namespace
838    /// set (`primary ∪ extra_visible`) instead of primary only.
839    ///
840    /// Graph expansion (`neighbors`, `traverse`) iterates over all visible
841    /// namespaces, so enrichment must use the same scope — otherwise neighbors
842    /// or path nodes whose entities live in an extra-visible namespace are left
843    /// with `name = None`, `kind = None`.  Missing or out-of-scope IDs are
844    /// silently omitted (best-effort, same as `get_entities_by_ids`).
845    async fn get_entities_by_ids_visible(
846        &self,
847        token: &NamespaceToken,
848        ids: &[Uuid],
849    ) -> RuntimeResult<Vec<Entity>> {
850        if ids.is_empty() {
851            return Ok(vec![]);
852        }
853        let namespaces: Vec<String> = token
854            .visible_namespaces()
855            .iter()
856            .map(|ns| ns.as_str().to_owned())
857            .collect();
858        let filter = EntityFilter {
859            ids: ids.to_vec(),
860            namespaces,
861            ..Default::default()
862        };
863        let page = self
864            .entities(token)?
865            .query_entities(
866                token.namespace().as_str(),
867                filter,
868                PageRequest {
869                    offset: 0,
870                    limit: ids.len() as u32,
871                },
872            )
873            .await?;
874        Ok(page.items)
875    }
876
877    /// Enforce that `record_ns` is within the caller's visible namespace set.
878    ///
879    /// Returns `Err(NotFound)` when the record namespace is not in the visible
880    /// set — wrong-namespace and absent UUIDs must be indistinguishable
881    /// externally (no existence oracle).
882    ///
883    /// When the visible set is a single entry equal to `caller_primary_ns`, this
884    /// is identical to the former strict-equality check (backward-compatible).
885    pub(crate) fn ensure_namespace(record_ns: &str, caller_primary_ns: &str) -> RuntimeResult<()> {
886        if record_ns == caller_primary_ns {
887            return Ok(());
888        }
889        Err(RuntimeError::NotFound("not found in this namespace".into()))
890    }
891
892    /// Enforce that `record_ns` is a member of the token's visible namespace set.
893    ///
894    /// This is the multi-namespace-aware variant used when the token carries an
895    /// extended visibility set. For single-namespace tokens (visible == [primary])
896    /// this degenerates to the same strict-equality check as `ensure_namespace`.
897    pub(crate) fn ensure_namespace_visible(
898        record_ns: &str,
899        token: &NamespaceToken,
900    ) -> RuntimeResult<()> {
901        for ns in token.visible_namespaces() {
902            if record_ns == ns.as_str() {
903                return Ok(());
904            }
905        }
906        Err(RuntimeError::NotFound("not found in this namespace".into()))
907    }
908
909    /// List entities visible to the token, optionally filtered by kind and entity_type.
910    ///
911    /// When the token carries a multi-namespace visible set, entities from all
912    /// visible namespaces are returned. When the visible set is `[primary]`
913    /// (the default) this behaves identically to the pre-visibility behaviour.
914    pub async fn list_entities(
915        &self,
916        token: &NamespaceToken,
917        kind: Option<&str>,
918        entity_type: Option<&str>,
919        limit: u32,
920        offset: u32,
921    ) -> RuntimeResult<Vec<Entity>> {
922        let ns_strs: Vec<String> = token
923            .visible_namespaces()
924            .iter()
925            .map(|ns| ns.as_str().to_owned())
926            .collect();
927        let filter = EntityFilter {
928            kinds: match kind {
929                Some(k) => vec![k.to_string()],
930                None => vec![],
931            },
932            entity_types: match entity_type {
933                Some(t) => vec![t.to_string()],
934                None => vec![],
935            },
936            namespaces: ns_strs,
937            ..Default::default()
938        };
939        let page = self
940            .entities(token)?
941            .query_entities(
942                token.namespace().as_str(),
943                filter,
944                PageRequest {
945                    offset: offset.into(),
946                    limit,
947                },
948            )
949            .await?;
950        Ok(page.items)
951    }
952
953    /// List entities filtered by kind, optional domain tag, limit, and offset.
954    ///
955    /// When `domain_tag` is Some, the query is restricted at the storage layer via
956    /// `EntityFilter::tags_any` so the page result already reflects the domain
957    /// constraint.  This avoids the silent truncation that occurs when filtering
958    /// post-page (K-3). Multi-namespace visibility from the token is applied.
959    pub async fn list_entities_tagged(
960        &self,
961        token: &NamespaceToken,
962        kind: Option<&str>,
963        domain_tag: Option<&str>,
964        limit: u32,
965        offset: u32,
966    ) -> RuntimeResult<Vec<Entity>> {
967        let ns_strs: Vec<String> = token
968            .visible_namespaces()
969            .iter()
970            .map(|ns| ns.as_str().to_owned())
971            .collect();
972        let filter = EntityFilter {
973            kinds: match kind {
974                Some(k) => vec![k.to_string()],
975                None => vec![],
976            },
977            tags_any: match domain_tag {
978                Some(t) if !t.is_empty() => vec![t.to_string()],
979                _ => vec![],
980            },
981            namespaces: ns_strs,
982            ..Default::default()
983        };
984        let page = self
985            .entities(token)?
986            .query_entities(
987                token.namespace().as_str(),
988                filter,
989                PageRequest {
990                    offset: offset.into(),
991                    limit,
992                },
993            )
994            .await?;
995        Ok(page.items)
996    }
997
998    /// Count entities filtered by kind and optional domain tag.
999    ///
1000    /// Used to report a meaningful `total` alongside a paginated listing (K-6).
1001    /// Multi-namespace visibility from the token is applied.
1002    pub async fn count_entities_tagged(
1003        &self,
1004        token: &NamespaceToken,
1005        kind: Option<&str>,
1006        domain_tag: Option<&str>,
1007    ) -> RuntimeResult<u64> {
1008        let ns_strs: Vec<String> = token
1009            .visible_namespaces()
1010            .iter()
1011            .map(|ns| ns.as_str().to_owned())
1012            .collect();
1013        let filter = EntityFilter {
1014            kinds: match kind {
1015                Some(k) => vec![k.to_string()],
1016                None => vec![],
1017            },
1018            tags_any: match domain_tag {
1019                Some(t) if !t.is_empty() => vec![t.to_string()],
1020                _ => vec![],
1021            },
1022            namespaces: ns_strs,
1023            ..Default::default()
1024        };
1025        Ok(self
1026            .entities(token)?
1027            .count_entities(token.namespace().as_str(), filter)
1028            .await?)
1029    }
1030
1031    /// List events in the namespace proven by the caller token.
1032    pub async fn list_events(
1033        &self,
1034        token: &NamespaceToken,
1035        filter: EventFilter,
1036        page: PageRequest,
1037    ) -> RuntimeResult<Page<Event>> {
1038        self.events(token)?
1039            .query_events(filter, page)
1040            .await
1041            .map_err(Into::into)
1042    }
1043
1044    // ---- Edge operations ----
1045
1046    /// Validate that `source_id` and `target_id` are legal endpoints for `relation`.
1047    ///
1048    /// Centralises the three-case relation contract so that both
1049    /// `link()` and `update_edge()` share identical enforcement:
1050    ///
1051    /// - `annotates`: source MUST be a note; target may be any substrate.
1052    /// - `supersedes` / `supports` / `refutes`: same-substrate only (note→note or entity→entity).
1053    /// - All other 13 relations: both endpoints MUST be entities.
1054    ///
1055    /// Returns `Ok(())` when valid; otherwise `InvalidInput` or `NotFound` with
1056    /// the same messages as the previous inline block (byte-identical behaviour).
1057    async fn validate_edge_relation_endpoints(
1058        &self,
1059        token: &NamespaceToken,
1060        source_id: Uuid,
1061        target_id: Uuid,
1062        relation: EdgeRelation,
1063    ) -> RuntimeResult<()> {
1064        if source_id == target_id {
1065            return Err(RuntimeError::InvalidInput(
1066                "self-loop edges are not allowed: source_id and target_id must be different".into(),
1067            ));
1068        }
1069        if relation == EdgeRelation::Annotates {
1070            // Source must be a note in the primary namespace.
1071            match self.resolve_primary(token, source_id).await? {
1072                Some(Resolved::Note(_)) => {}
1073                Some(_) => {
1074                    return Err(RuntimeError::InvalidInput(format!(
1075                        "annotates source {source_id} must be a note"
1076                    )));
1077                }
1078                None => {
1079                    // Existing edge used as annotates source: wrong kind, not absent.
1080                    if self.get_edge(token, source_id).await?.is_some() {
1081                        return Err(RuntimeError::InvalidInput(format!(
1082                            "annotates source {source_id} must be a note"
1083                        )));
1084                    }
1085                    return Err(RuntimeError::NotFound(format!(
1086                        "link source {source_id} not found in namespace"
1087                    )));
1088                }
1089            }
1090            // Target may be any substrate (entity, note, event, or edge) — primary only.
1091            if !self.substrate_exists_in_primary(token, target_id).await? {
1092                return Err(RuntimeError::NotFound(format!(
1093                    "link target {target_id} not found in namespace"
1094                )));
1095            }
1096        } else if matches!(
1097            relation,
1098            EdgeRelation::Supersedes | EdgeRelation::Supports | EdgeRelation::Refutes
1099        ) {
1100            // supersedes / supports / refutes: same-substrate only (note→note or entity→entity).
1101            // Event and edge endpoints are invalid regardless of the other endpoint.
1102            let rel_name = relation.as_str();
1103            let src = match self.resolve_primary(token, source_id).await? {
1104                Some(r) => r,
1105                None => {
1106                    if self.get_edge(token, source_id).await?.is_some() {
1107                        return Err(RuntimeError::InvalidInput(format!(
1108                            "{rel_name} source {source_id} must be a note or entity (got edge)"
1109                        )));
1110                    }
1111                    return Err(RuntimeError::NotFound(format!(
1112                        "link source {source_id} not found in namespace"
1113                    )));
1114                }
1115            };
1116            let tgt = match self.resolve_primary(token, target_id).await? {
1117                Some(r) => r,
1118                None => {
1119                    if self.get_edge(token, target_id).await?.is_some() {
1120                        return Err(RuntimeError::InvalidInput(format!(
1121                            "{rel_name} target {target_id} must be a note or entity (got edge)"
1122                        )));
1123                    }
1124                    return Err(RuntimeError::NotFound(format!(
1125                        "link target {target_id} not found in namespace"
1126                    )));
1127                }
1128            };
1129            match (&src, &tgt) {
1130                (Resolved::Entity(src_e), Resolved::Entity(tgt_e)) => {
1131                    if !base_entity_rule_allows(&src_e.kind, relation, &tgt_e.kind) {
1132                        let rule_hint = match relation {
1133                            EdgeRelation::Supports | EdgeRelation::Refutes => {
1134                                "requires concept|document|dataset|artifact -> concept \
1135                                 (or same-substrate note -> note)"
1136                            }
1137                            _ => "requires same-kind entity endpoints",
1138                        };
1139                        return Err(RuntimeError::InvalidInput(format!(
1140                            "({}) -[{rel_name}]-> ({}) is not in the base endpoint \
1141                             allowlist; {rel_name} {rule_hint}",
1142                            src_e.kind, tgt_e.kind
1143                        )));
1144                    }
1145                }
1146                (Resolved::Note(_), Resolved::Note(_)) => {}
1147                (Resolved::Event(_), _) => {
1148                    return Err(RuntimeError::InvalidInput(format!(
1149                        "{rel_name} does not apply to events; source {source_id} is an event"
1150                    )));
1151                }
1152                (_, Resolved::Event(_)) => {
1153                    return Err(RuntimeError::InvalidInput(format!(
1154                        "{rel_name} does not apply to events; target {target_id} is an event"
1155                    )));
1156                }
1157                (Resolved::Entity(_), Resolved::Note(_)) => {
1158                    return Err(RuntimeError::InvalidInput(format!(
1159                        "{rel_name} endpoints must be the same substrate (note→note or entity→entity); \
1160                         got source={source_id} (entity) target={target_id} (note)"
1161                    )));
1162                }
1163                (Resolved::Note(_), Resolved::Entity(_)) => {
1164                    return Err(RuntimeError::InvalidInput(format!(
1165                        "{rel_name} endpoints must be the same substrate (note→note or entity→entity); \
1166                         got source={source_id} (note) target={target_id} (entity)"
1167                    )));
1168                }
1169                (Resolved::PackRecord { .. }, _) | (_, Resolved::PackRecord { .. }) => {
1170                    return Err(RuntimeError::InvalidInput(format!(
1171                        "pack-private record is not a valid edge endpoint for {rel_name}"
1172                    )));
1173                }
1174            }
1175        } else {
1176            // All remaining base relations require entity→entity with kind-level
1177            // restrictions (see base allowlist). Packs may extend the allowlist
1178            // additively via EDGE_RULES.
1179            //
1180            // Strategy: resolve both endpoints once (primary-only), consult pack rules; on
1181            // miss, fall through to the original base-rule error messages.
1182            let src_res = self.resolve_primary(token, source_id).await?;
1183            let tgt_res = self.resolve_primary(token, target_id).await?;
1184
1185            if pack_rule_allows(
1186                &self.pack_edge_rules(),
1187                relation,
1188                src_res.as_ref(),
1189                tgt_res.as_ref(),
1190            ) {
1191                return Ok(());
1192            }
1193
1194            // Substrate check: both endpoints must be entities.
1195            let src_kind = match src_res {
1196                Some(Resolved::Entity(e)) => e.kind,
1197                Some(_) => {
1198                    return Err(RuntimeError::InvalidInput(format!(
1199                        "link source {source_id} must be an entity for relation {relation:?} \
1200                         (only `annotates` crosses substrates)"
1201                    )));
1202                }
1203                None => {
1204                    if self.get_edge(token, source_id).await?.is_some() {
1205                        return Err(RuntimeError::InvalidInput(format!(
1206                            "link source {source_id} must be an entity for relation {relation:?} \
1207                             (only `annotates` crosses substrates)"
1208                        )));
1209                    }
1210                    return Err(RuntimeError::NotFound(format!(
1211                        "link source {source_id} not found in namespace"
1212                    )));
1213                }
1214            };
1215            let tgt_kind = match tgt_res {
1216                Some(Resolved::Entity(e)) => e.kind,
1217                Some(_) => {
1218                    return Err(RuntimeError::InvalidInput(format!(
1219                        "link target {target_id} must be an entity for relation {relation:?} \
1220                         (only `annotates` crosses substrates)"
1221                    )));
1222                }
1223                None => {
1224                    if self.get_edge(token, target_id).await?.is_some() {
1225                        return Err(RuntimeError::InvalidInput(format!(
1226                            "link target {target_id} must be an entity for relation {relation:?} \
1227                             (only `annotates` crosses substrates)"
1228                        )));
1229                    }
1230                    return Err(RuntimeError::NotFound(format!(
1231                        "link target {target_id} not found in namespace"
1232                    )));
1233                }
1234            };
1235            if !base_entity_rule_allows(&src_kind, relation, &tgt_kind) {
1236                return Err(RuntimeError::InvalidInput(format!(
1237                    "({src_kind}) -[{}]-> ({tgt_kind}) is not in the base endpoint \
1238                     allowlist; use pack EDGE_RULES to extend the allowlist",
1239                    relation.as_str()
1240                )));
1241            }
1242        }
1243        Ok(())
1244    }
1245
1246    /// Public delegator for cross-backend link validation (ADR-029 D3).
1247    ///
1248    /// Exposes `validate_edge_relation_endpoints` for the `SubstrateCoordinator`
1249    /// so it can validate the relation before writing the edge on the source backend.
1250    pub async fn validate_link_endpoints(
1251        &self,
1252        token: &NamespaceToken,
1253        source_id: Uuid,
1254        target_id: Uuid,
1255        relation: EdgeRelation,
1256    ) -> RuntimeResult<()> {
1257        self.validate_edge_relation_endpoints(token, source_id, target_id, relation)
1258            .await
1259    }
1260
1261    /// Validate an edge relation using pre-fetched endpoint records (ADR-029 D3).
1262    ///
1263    /// For cross-backend links the source and target live on different backends —
1264    /// the source runtime cannot resolve the target. The coordinator fetches each
1265    /// endpoint from its own backend, then calls this method to enforce ADR-002
1266    /// kind-pairing rules without a second DB round-trip.
1267    ///
1268    /// `src` and `tgt` are the `resolve_primary` results from each backend. The
1269    /// `token` supplies the pack edge rules installed on this (source) runtime;
1270    /// no DB access is performed.
1271    pub fn validate_link_endpoints_by_resolved(
1272        &self,
1273        source_id: Uuid,
1274        target_id: Uuid,
1275        relation: EdgeRelation,
1276        src: Option<&Resolved>,
1277        tgt: Option<&Resolved>,
1278    ) -> RuntimeResult<()> {
1279        if source_id == target_id {
1280            return Err(RuntimeError::InvalidInput(
1281                "self-loop edges are not allowed: source_id and target_id must be different".into(),
1282            ));
1283        }
1284
1285        if relation == EdgeRelation::Annotates {
1286            match src {
1287                Some(Resolved::Note(_)) => {}
1288                Some(_) => {
1289                    return Err(RuntimeError::InvalidInput(format!(
1290                        "annotates source {source_id} must be a note"
1291                    )));
1292                }
1293                None => {
1294                    return Err(RuntimeError::NotFound(format!(
1295                        "link source {source_id} not found"
1296                    )));
1297                }
1298            }
1299            if tgt.is_none() {
1300                return Err(RuntimeError::NotFound(format!(
1301                    "link target {target_id} not found"
1302                )));
1303            }
1304            return Ok(());
1305        }
1306
1307        if matches!(
1308            relation,
1309            EdgeRelation::Supersedes | EdgeRelation::Supports | EdgeRelation::Refutes
1310        ) {
1311            let rel_name = relation.as_str();
1312            let src = src.ok_or_else(|| {
1313                RuntimeError::NotFound(format!("link source {source_id} not found"))
1314            })?;
1315            let tgt = tgt.ok_or_else(|| {
1316                RuntimeError::NotFound(format!("link target {target_id} not found"))
1317            })?;
1318            match (src, tgt) {
1319                (Resolved::Entity(src_e), Resolved::Entity(tgt_e)) => {
1320                    if !base_entity_rule_allows(&src_e.kind, relation, &tgt_e.kind) {
1321                        let rule_hint = match relation {
1322                            EdgeRelation::Supports | EdgeRelation::Refutes => {
1323                                "requires concept|document|dataset|artifact -> concept \
1324                                 (or same-substrate note -> note)"
1325                            }
1326                            _ => "requires same-kind entity endpoints",
1327                        };
1328                        return Err(RuntimeError::InvalidInput(format!(
1329                            "({}) -[{rel_name}]-> ({}) is not in the base endpoint \
1330                             allowlist; {rel_name} {rule_hint}",
1331                            src_e.kind, tgt_e.kind
1332                        )));
1333                    }
1334                }
1335                (Resolved::Note(_), Resolved::Note(_)) => {}
1336                (Resolved::Entity(_), Resolved::Note(_)) => {
1337                    return Err(RuntimeError::InvalidInput(format!(
1338                        "{rel_name} endpoints must be the same substrate \
1339                         (note→note or entity→entity); got source={source_id} (entity) \
1340                         target={target_id} (note)"
1341                    )));
1342                }
1343                (Resolved::Note(_), Resolved::Entity(_)) => {
1344                    return Err(RuntimeError::InvalidInput(format!(
1345                        "{rel_name} endpoints must be the same substrate \
1346                         (note→note or entity→entity); got source={source_id} (note) \
1347                         target={target_id} (entity)"
1348                    )));
1349                }
1350                (Resolved::PackRecord { .. }, _) | (_, Resolved::PackRecord { .. }) => {
1351                    return Err(RuntimeError::InvalidInput(format!(
1352                        "pack-private record is not a valid edge endpoint for {rel_name}"
1353                    )));
1354                }
1355                _ => {
1356                    return Err(RuntimeError::InvalidInput(format!(
1357                        "{rel_name} endpoints must be notes or entities (not events)"
1358                    )));
1359                }
1360            }
1361            return Ok(());
1362        }
1363
1364        // All remaining base relations: entity→entity with kind-level restrictions.
1365        // Consult pack rules installed on this (source) runtime first.
1366        if pack_rule_allows(&self.pack_edge_rules(), relation, src, tgt) {
1367            return Ok(());
1368        }
1369
1370        let src_kind = match src {
1371            Some(Resolved::Entity(e)) => &e.kind,
1372            Some(_) => {
1373                return Err(RuntimeError::InvalidInput(format!(
1374                    "link source {source_id} must be an entity for relation {relation:?} \
1375                     (only `annotates` crosses substrates)"
1376                )));
1377            }
1378            None => {
1379                return Err(RuntimeError::NotFound(format!(
1380                    "link source {source_id} not found"
1381                )));
1382            }
1383        };
1384        let tgt_kind = match tgt {
1385            Some(Resolved::Entity(e)) => &e.kind,
1386            Some(_) => {
1387                return Err(RuntimeError::InvalidInput(format!(
1388                    "link target {target_id} must be an entity for relation {relation:?} \
1389                     (only `annotates` crosses substrates)"
1390                )));
1391            }
1392            None => {
1393                return Err(RuntimeError::NotFound(format!(
1394                    "link target {target_id} not found"
1395                )));
1396            }
1397        };
1398
1399        if !base_entity_rule_allows(src_kind, relation, tgt_kind) {
1400            return Err(RuntimeError::InvalidInput(format!(
1401                "({src_kind}) -[{}]-> ({tgt_kind}) is not in the base endpoint \
1402                 allowlist; use pack EDGE_RULES to extend the allowlist",
1403                relation.as_str()
1404            )));
1405        }
1406
1407        Ok(())
1408    }
1409
1410    /// Create a directed edge between two substrates.
1411    ///
1412    /// Enforces the three-case relation contract via
1413    /// `validate_edge_relation_endpoints`. See that method for the full contract.
1414    ///
1415    /// For symmetric relations (`competes_with`, `composed_with`) the endpoint
1416    /// pair is canonicalised to `source_uuid < target_uuid` so that A→B and B→A
1417    /// deduplicate to one row (F012).
1418    ///
1419    /// `metadata` is validated against governed keys; `dependency_kind` is
1420    /// inferred for `depends_on` edges when absent (F013).
1421    ///
1422    /// `target_backend` is always `None` for locally-routed edges written through
1423    /// this path. Both endpoints must exist in the local namespace, so setting
1424    /// `target_backend = None` is the only valid choice (F161).
1425    ///
1426    /// A record that exists but belongs to a different namespace is treated as not found
1427    /// (fail-closed; no cross-namespace existence leak).
1428    pub async fn link(
1429        &self,
1430        token: &NamespaceToken,
1431        source_id: Uuid,
1432        target_id: Uuid,
1433        relation: EdgeRelation,
1434        weight: f64,
1435        metadata: Option<serde_json::Value>,
1436    ) -> RuntimeResult<Edge> {
1437        validate_edge_weight(weight)?;
1438        self.validate_edge_relation_endpoints(token, source_id, target_id, relation)
1439            .await?;
1440        let (source_id, target_id) = canonical_edge_endpoints(relation, source_id, target_id);
1441        let metadata = if relation == EdgeRelation::DependsOn {
1442            match (
1443                self.resolve(token, source_id).await?,
1444                self.resolve(token, target_id).await?,
1445            ) {
1446                (Some(Resolved::Entity(src_e)), Some(Resolved::Entity(tgt_e))) => {
1447                    merge_dependency_kind(&src_e.kind, &tgt_e.kind, metadata)
1448                }
1449                _ => metadata,
1450            }
1451        } else {
1452            metadata
1453        };
1454        validate_edge_metadata(relation, metadata.as_ref())?;
1455        let now = chrono::Utc::now();
1456        let ns = token.namespace().as_str();
1457        let edge = Edge {
1458            id: LinkId::from(Uuid::new_v4()),
1459            namespace: ns.to_string(),
1460            source_id,
1461            target_id,
1462            relation,
1463            weight,
1464            created_at: now,
1465            updated_at: now,
1466            deleted_at: None,
1467            metadata,
1468            target_backend: None,
1469        };
1470        self.graph(token)?.upsert_edge(edge).await?;
1471
1472        // H1 fix: read back the persisted row by natural key so the returned
1473        // edge ID is always the one stored in the database, not the locally
1474        // generated UUID that was displaced by an ON CONFLICT DO UPDATE.
1475        // Under parallel calls for the same triple, every caller now returns
1476        // the same persisted edge ID — the winner's insert or the updated row.
1477        let persisted = self
1478            .list_edges(
1479                token,
1480                crate::curation::EdgeListFilter {
1481                    source_id: Some(source_id),
1482                    target_id: Some(target_id),
1483                    relations: vec![relation],
1484                    ..Default::default()
1485                },
1486                1,
1487            )
1488            .await?
1489            .into_iter()
1490            .next()
1491            .ok_or_else(|| {
1492                crate::RuntimeError::Internal(format!(
1493                    "upsert_edge succeeded but natural-key lookup for ({source_id}, {target_id}, {relation}) returned nothing"
1494                ))
1495            })?;
1496        Ok(persisted)
1497    }
1498
1499    /// Write an edge with an explicit `target_backend` stamp (ADR-029 D3).
1500    ///
1501    /// Called by the `SubstrateCoordinator` when source and target are on
1502    /// different backends. The coordinator validates endpoints before calling
1503    /// this method via [`Self::validate_link_endpoints`], so endpoint validation is
1504    /// skipped here. The edge is written on the source backend only.
1505    #[allow(clippy::too_many_arguments)]
1506    pub async fn link_with_target_backend(
1507        &self,
1508        token: &NamespaceToken,
1509        source_id: Uuid,
1510        target_id: Uuid,
1511        relation: EdgeRelation,
1512        weight: f64,
1513        metadata: Option<serde_json::Value>,
1514        target_backend: Option<String>,
1515    ) -> RuntimeResult<Edge> {
1516        validate_edge_weight(weight)?;
1517        let (source_id, target_id) = canonical_edge_endpoints(relation, source_id, target_id);
1518        validate_edge_metadata(relation, metadata.as_ref())?;
1519        let now = chrono::Utc::now();
1520        let ns = token.namespace().as_str();
1521        let edge = Edge {
1522            id: LinkId::from(Uuid::new_v4()),
1523            namespace: ns.to_string(),
1524            source_id,
1525            target_id,
1526            relation,
1527            weight,
1528            created_at: now,
1529            updated_at: now,
1530            deleted_at: None,
1531            metadata,
1532            target_backend,
1533        };
1534        self.graph(token)?.upsert_edge(edge).await?;
1535        let persisted = self
1536            .list_edges(
1537                token,
1538                crate::curation::EdgeListFilter {
1539                    source_id: Some(source_id),
1540                    target_id: Some(target_id),
1541                    relations: vec![relation],
1542                    ..Default::default()
1543                },
1544                1,
1545            )
1546            .await?
1547            .into_iter()
1548            .next()
1549            .ok_or_else(|| {
1550                crate::RuntimeError::Internal(format!(
1551                    "upsert_edge succeeded but natural-key lookup for ({source_id}, {target_id}, {relation}) returned nothing"
1552                ))
1553            })?;
1554        Ok(persisted)
1555    }
1556
1557    /// Returns `true` if `id` resolves to a live substrate record in the
1558    /// caller's visible namespace set.
1559    ///
1560    /// Covers entity, note, event (via `resolve`) and edge (via `get_edge_visible`).
1561    /// Only records that are accessible to the caller (primary or configured visible
1562    /// namespaces) return `true`; absent or foreign-invisible records return `false`.
1563    pub(crate) async fn substrate_exists_in_ns(
1564        &self,
1565        token: &NamespaceToken,
1566        id: Uuid,
1567    ) -> RuntimeResult<bool> {
1568        if self.resolve(token, id).await?.is_some() {
1569            return Ok(true);
1570        }
1571        match self.get_edge_visible(token, id).await {
1572            Ok(Some(_)) => Ok(true),
1573            Ok(None) | Err(RuntimeError::NotFound(_)) => Ok(false),
1574            Err(err) => Err(err),
1575        }
1576    }
1577
1578    /// Returns `true` if `id` resolves to a live substrate record in the PRIMARY namespace only.
1579    ///
1580    /// Used from mutation endpoint validation where visible-set membership is not
1581    /// sufficient — the record must belong to the caller's write namespace.
1582    pub(crate) async fn substrate_exists_in_primary(
1583        &self,
1584        token: &NamespaceToken,
1585        id: Uuid,
1586    ) -> RuntimeResult<bool> {
1587        if self.resolve_primary(token, id).await?.is_some() {
1588            return Ok(true);
1589        }
1590        match self.get_edge(token, id).await {
1591            Ok(Some(_)) => Ok(true),
1592            Ok(None) | Err(RuntimeError::NotFound(_)) => Ok(false),
1593            Err(err) => Err(err),
1594        }
1595    }
1596
1597    /// Get immediate neighbors of a node, optionally filtered by relation type.
1598    ///
1599    /// Pass `relations: Some(vec![EdgeRelation::Annotates])` to retrieve only
1600    /// annotation edges, enabling cross-substrate navigation.
1601    ///
1602    /// Symmetric relations (`competes_with`, `composed_with`) are stored
1603    /// with the canonical source as the lower UUID. Direction normalization is
1604    /// applied in `neighbors_with_query` so both callers see correct results.
1605    pub async fn neighbors(
1606        &self,
1607        token: &NamespaceToken,
1608        node_id: Uuid,
1609        direction: Direction,
1610        limit: Option<u32>,
1611        relations: Option<Vec<EdgeRelation>>,
1612    ) -> RuntimeResult<Vec<NeighborHit>> {
1613        self.neighbors_with_query(
1614            token,
1615            node_id,
1616            NeighborQuery {
1617                direction,
1618                relations,
1619                limit,
1620                min_weight: None,
1621            },
1622        )
1623        .await
1624    }
1625
1626    /// Get neighbors with full query control (includes `min_weight`).
1627    ///
1628    /// Applies symmetric-relation direction normalization: if the
1629    /// relations filter contains only symmetric relations the direction is
1630    /// overridden to `Both` so edges stored in canonical order are always found.
1631    ///
1632    /// Soft-deleted entity nodes are excluded from results unless the caller
1633    /// explicitly requested them (future: `include_deleted` flag; currently
1634    /// always false per Fix 2).
1635    pub async fn neighbors_with_query(
1636        &self,
1637        token: &NamespaceToken,
1638        node_id: Uuid,
1639        mut query: NeighborQuery,
1640    ) -> RuntimeResult<Vec<NeighborHit>> {
1641        if !self.substrate_exists_in_ns(token, node_id).await? {
1642            return Ok(Vec::new());
1643        }
1644
1645        query.direction =
1646            normalize_symmetric_direction(query.direction, query.relations.as_deref());
1647        let mut hits = Vec::new();
1648        for ns in token.visible_namespaces() {
1649            let temp = NamespaceToken::for_namespace(ns.clone());
1650            let mut ns_hits = self.graph(&temp)?.neighbors(node_id, query.clone()).await?;
1651            hits.append(&mut ns_hits);
1652        }
1653        hits.sort_by_key(|h| (h.node_id, h.edge_id));
1654        hits.dedup_by_key(|h| (h.node_id, h.edge_id));
1655        self.enrich_neighbor_hits(token, &mut hits).await;
1656        // Filter out soft-deleted entity nodes (Fix 2).
1657        let candidate_ids: Vec<Uuid> = hits.iter().map(|h| h.node_id).collect();
1658        let deleted = self.deleted_entity_ids(candidate_ids).await;
1659        if !deleted.is_empty() {
1660            hits.retain(|h| !deleted.contains(&h.node_id));
1661        }
1662        Ok(hits)
1663    }
1664
1665    /// Traverse the graph from a set of root nodes.
1666    ///
1667    /// Roots in a foreign namespace are silently filtered before storage expansion.
1668    /// Soft-deleted entity nodes are excluded from results (Fix 2).
1669    pub async fn traverse(
1670        &self,
1671        token: &NamespaceToken,
1672        request: TraversalRequest,
1673    ) -> RuntimeResult<Vec<GraphPath>> {
1674        let mut request = request;
1675        let mut visible_roots = Vec::with_capacity(request.roots.len());
1676        for root in request.roots.drain(..) {
1677            if self.substrate_exists_in_ns(token, root).await? {
1678                visible_roots.push(root);
1679            }
1680        }
1681        request.roots = visible_roots;
1682        if request.roots.is_empty() {
1683            return Ok(Vec::new());
1684        }
1685
1686        let mut paths = Vec::new();
1687        for ns in token.visible_namespaces() {
1688            let temp = NamespaceToken::for_namespace(ns.clone());
1689            let mut ns_paths = self.graph(&temp)?.traverse(request.clone()).await?;
1690            paths.append(&mut ns_paths);
1691        }
1692        self.enrich_path_nodes(token, &mut paths, request.include_properties)
1693            .await;
1694        // Filter out soft-deleted entity nodes from all path nodes (Fix 2).
1695        let all_node_ids: Vec<Uuid> = paths
1696            .iter()
1697            .flat_map(|p| p.nodes.iter().map(|n| n.node_id))
1698            .collect();
1699        let deleted = self.deleted_entity_ids(all_node_ids).await;
1700        if !deleted.is_empty() {
1701            for path in paths.iter_mut() {
1702                path.nodes.retain(|n| !deleted.contains(&n.node_id));
1703            }
1704            paths.retain(|p| !p.nodes.is_empty());
1705        }
1706        Ok(paths)
1707    }
1708
1709    /// Batch-query for soft-deleted entity UUIDs in `ids`.
1710    ///
1711    /// Returns the subset of `ids` that have `deleted_at IS NOT NULL` in the
1712    /// entities table. Takes `Vec<Uuid>` (not an iterator) so the async
1713    /// state machine holds only owned data — no iterator borrow across yields.
1714    async fn deleted_entity_ids(&self, ids: Vec<Uuid>) -> std::collections::HashSet<Uuid> {
1715        if ids.is_empty() {
1716            return std::collections::HashSet::new();
1717        }
1718        let id_strs: Vec<String> = ids.iter().map(|u| u.to_string()).collect();
1719        let placeholders = id_strs
1720            .iter()
1721            .enumerate()
1722            .map(|(i, _)| format!("?{}", i + 1))
1723            .collect::<Vec<_>>()
1724            .join(",");
1725        let sql_str = format!(
1726            "SELECT id FROM entities WHERE id IN ({placeholders}) AND deleted_at IS NOT NULL"
1727        );
1728        let params: Vec<SqlValue> = id_strs.into_iter().map(SqlValue::Text).collect();
1729        let stmt = SqlStatement {
1730            sql: sql_str,
1731            params,
1732            label: Some("deleted_entity_ids".into()),
1733        };
1734        let mut out = std::collections::HashSet::new();
1735        let sql = self.sql();
1736        if let Ok(mut reader) = sql.reader().await {
1737            if let Ok(rows) = reader.query_all(stmt).await {
1738                for row in rows {
1739                    if let Some(col) = row.columns.first() {
1740                        if let SqlValue::Text(s) = &col.value {
1741                            if let Ok(u) = s.parse::<Uuid>() {
1742                                out.insert(u);
1743                            }
1744                        }
1745                    }
1746                }
1747            }
1748            // best-effort: on reader or query error, treat none as deleted
1749        }
1750        out
1751    }
1752
1753    /// Populate `name` and `kind` on each `NeighborHit` from the corresponding
1754    /// entity or note record. Best-effort: unresolved IDs leave the fields `None`.
1755    ///
1756    /// Uses a single batched entity lookup via `get_entities_by_ids_visible`
1757    /// (scoped to the token's full visible-namespace set so that neighbors in
1758    /// extra-visible namespaces are enriched), then a batched note lookup
1759    /// (`get_notes_batch`) for the residual IDs not resolved as entities.
1760    /// Order and identity of hits is preserved via `HashMap` re-index.
1761    async fn enrich_neighbor_hits(&self, token: &NamespaceToken, hits: &mut [NeighborHit]) {
1762        if hits.is_empty() {
1763            return;
1764        }
1765
1766        // Deduplicated IDs for the batch call.
1767        let unique_ids: Vec<Uuid> = {
1768            let mut seen = std::collections::HashSet::new();
1769            hits.iter()
1770                .filter_map(|h| {
1771                    if seen.insert(h.node_id) {
1772                        Some(h.node_id)
1773                    } else {
1774                        None
1775                    }
1776                })
1777                .collect()
1778        };
1779
1780        let entity_map: HashMap<Uuid, Entity> = self
1781            .get_entities_by_ids_visible(token, &unique_ids)
1782            .await
1783            .unwrap_or_default()
1784            .into_iter()
1785            .map(|e| (e.id, e))
1786            .collect();
1787
1788        // Batch note lookup for IDs not found as entities.
1789        let residual_ids: Vec<Uuid> = unique_ids
1790            .iter()
1791            .filter(|id| !entity_map.contains_key(id))
1792            .copied()
1793            .collect();
1794
1795        let note_map: HashMap<Uuid, Note> = if !residual_ids.is_empty() {
1796            if let Ok(store) = self.notes(token) {
1797                store
1798                    .get_notes_batch(&residual_ids)
1799                    .await
1800                    .unwrap_or_default()
1801                    .into_iter()
1802                    .map(|n| (n.id, n))
1803                    .collect()
1804            } else {
1805                HashMap::new()
1806            }
1807        } else {
1808            HashMap::new()
1809        };
1810
1811        for hit in hits.iter_mut() {
1812            if let Some(entity) = entity_map.get(&hit.node_id) {
1813                hit.name = Some(entity.name.clone());
1814                hit.kind = Some(entity.kind.clone());
1815                hit.entity_type = entity.entity_type.clone();
1816            } else if let Some(note) = note_map.get(&hit.node_id) {
1817                let kind = note.kind.clone();
1818                let name = note
1819                    .name
1820                    .as_deref()
1821                    .filter(|s| !s.trim().is_empty())
1822                    .map(|s| s.to_owned())
1823                    .unwrap_or_else(|| format!("[{kind}]"));
1824                hit.name = Some(name);
1825                hit.kind = Some(kind);
1826            }
1827        }
1828    }
1829
1830    /// Populate `name` and `kind` on each `PathNode` from the corresponding
1831    /// entity record (#162). Same best-effort policy as `enrich_neighbor_hits`.
1832    ///
1833    /// Uses `get_entities_by_ids_visible` so that path nodes whose entities
1834    /// live in extra-visible namespaces are enriched correctly. Node IDs that
1835    /// repeat across paths are fetched exactly once.
1836    ///
1837    /// `include_properties` gates whether `entity.properties` is cloned onto
1838    /// each node. When `false` (the default), the potentially large JSON blob
1839    /// is never read from the map, keeping the hot path allocation-free.
1840    async fn enrich_path_nodes(
1841        &self,
1842        token: &NamespaceToken,
1843        paths: &mut [GraphPath],
1844        include_properties: bool,
1845    ) {
1846        if paths.is_empty() {
1847            return;
1848        }
1849
1850        // Deduplicate node IDs across all paths before the batch call.
1851        let unique_ids: Vec<Uuid> = {
1852            let mut seen = std::collections::HashSet::new();
1853            paths
1854                .iter()
1855                .flat_map(|p| p.nodes.iter())
1856                .filter_map(|n| {
1857                    if seen.insert(n.node_id) {
1858                        Some(n.node_id)
1859                    } else {
1860                        None
1861                    }
1862                })
1863                .collect()
1864        };
1865
1866        let entity_map: HashMap<Uuid, Entity> = self
1867            .get_entities_by_ids_visible(token, &unique_ids)
1868            .await
1869            .unwrap_or_default()
1870            .into_iter()
1871            .map(|e| (e.id, e))
1872            .collect();
1873
1874        for path in paths.iter_mut() {
1875            for node in path.nodes.iter_mut() {
1876                if let Some(entity) = entity_map.get(&node.node_id) {
1877                    node.name = Some(entity.name.clone());
1878                    node.kind = Some(entity.kind.clone());
1879                    if include_properties {
1880                        node.properties = entity.properties.clone();
1881                    }
1882                }
1883            }
1884        }
1885    }
1886
1887    // ---- Note operations ----
1888
1889    /// Create and persist a note, optionally with properties and annotation targets.
1890    ///
1891    /// After creating the note:
1892    /// - Always indexes into FTS5 at the `notes_<namespace>` key.
1893    /// - If an embedding model is configured, indexes into the vector store with
1894    ///   `SubstrateKind::Note`.
1895    /// - For each UUID in `annotates`, creates an `EdgeRelation::Annotates` edge from
1896    ///   the note to that target.
1897    // REASON: note creation requires kind, name, content, salience, properties, annotates,
1898    // and namespace token — mirrors the MCP verb surface; a builder would not reduce
1899    // caller complexity for pack handler callers.
1900    #[allow(clippy::too_many_arguments)]
1901    pub async fn create_note(
1902        &self,
1903        token: &NamespaceToken,
1904        kind: &str,
1905        name: Option<&str>,
1906        content: &str,
1907        salience: Option<f64>,
1908        properties: Option<serde_json::Value>,
1909        annotates: Vec<Uuid>,
1910    ) -> RuntimeResult<Note> {
1911        self.create_note_inner(
1912            token, kind, name, content, salience, None, properties, annotates, None,
1913        )
1914        .await
1915    }
1916
1917    /// Like [`Self::create_note`] but also sets a non-zero decay factor on the note.
1918    // REASON: extends create_note with an additional decay_factor parameter; same
1919    // rationale — mirrors the MCP surface and reduces an extra builder layer.
1920    #[allow(clippy::too_many_arguments)]
1921    pub async fn create_note_with_decay(
1922        &self,
1923        token: &NamespaceToken,
1924        kind: &str,
1925        name: Option<&str>,
1926        content: &str,
1927        salience: Option<f64>,
1928        decay_factor: f64,
1929        properties: Option<serde_json::Value>,
1930        annotates: Vec<Uuid>,
1931    ) -> RuntimeResult<Note> {
1932        self.create_note_with_decay_for_embedding_model(
1933            token,
1934            kind,
1935            name,
1936            content,
1937            salience,
1938            decay_factor,
1939            properties,
1940            annotates,
1941            None,
1942        )
1943        .await
1944    }
1945
1946    /// Like [`Self::create_note_with_decay`] but targets a specific embedding model.
1947    // REASON: adds an embedding_model parameter to the decay variant; the full parameter
1948    // set is required for correct MCP verb routing and cannot be collapsed without
1949    // introducing a separate config struct that would obscure call sites.
1950    #[allow(clippy::too_many_arguments)]
1951    pub async fn create_note_with_decay_for_embedding_model(
1952        &self,
1953        token: &NamespaceToken,
1954        kind: &str,
1955        name: Option<&str>,
1956        content: &str,
1957        salience: Option<f64>,
1958        decay_factor: f64,
1959        properties: Option<serde_json::Value>,
1960        annotates: Vec<Uuid>,
1961        embedding_model: Option<&str>,
1962    ) -> RuntimeResult<Note> {
1963        self.create_note_inner(
1964            token,
1965            kind,
1966            name,
1967            content,
1968            salience,
1969            Some(decay_factor),
1970            properties,
1971            annotates,
1972            embedding_model,
1973        )
1974        .await
1975    }
1976
1977    /// Insert a note using `INSERT OR IGNORE` semantics for atomic deduplication.
1978    ///
1979    /// Returns `Ok(Some(note))` when the note was newly written.  Returns
1980    /// `Ok(None)` when a unique constraint (e.g. the `external_id` partial
1981    /// index on comm message notes) was already satisfied by an existing row,
1982    /// making this call a no-op.  FTS indexing and vector embedding are
1983    /// attempted on success but treated as best-effort: failures are logged
1984    /// and do not abort the write.
1985    ///
1986    /// This method is intentionally narrower than `create_note`: it skips
1987    /// salience/decay, annotates edges, and embedding-model selection, which
1988    /// are not needed for channel-ingest paths.
1989    pub async fn try_create_note(
1990        &self,
1991        token: &NamespaceToken,
1992        kind: &str,
1993        name: Option<&str>,
1994        content: &str,
1995        properties: Option<serde_json::Value>,
1996    ) -> RuntimeResult<Option<Note>> {
1997        self.validate_note_kind(kind)?;
1998        crate::secret_gate::check(content)?;
1999        if let Some(n) = name {
2000            crate::secret_gate::check(n)?;
2001        }
2002        if let Some(ref p) = properties {
2003            crate::secret_gate::check_json(p)?;
2004        }
2005
2006        let ns = token.namespace().as_str();
2007        let mut note = Note::new(ns, kind, content);
2008        if let Some(n) = name {
2009            note = note.with_name(n);
2010        }
2011        if let Some(p) = properties {
2012            note = note.with_properties(p);
2013        }
2014
2015        let inserted = self.notes(token)?.try_insert_note(note.clone()).await?;
2016        if !inserted {
2017            return Ok(None);
2018        }
2019
2020        // Best-effort FTS: log and continue on failure.
2021        if let Ok(fts) = self.text_for_notes(token) {
2022            if let Err(e) = fts.upsert_document(note_fts_document(&note)).await {
2023                tracing::warn!(
2024                    note_id = %note.id,
2025                    error = %e,
2026                    "try_create_note: FTS indexing failed (non-fatal)"
2027                );
2028            }
2029        }
2030
2031        // Best-effort vector embedding: log and continue on failure.
2032        let embed_model_names = self.registered_embedding_model_names();
2033        for model_name in &embed_model_names {
2034            match self
2035                .embed_document_with_model(model_name, &note.content)
2036                .await
2037            {
2038                Ok(vector) => {
2039                    if let Ok(vs) = self.vectors_for_model(token, model_name) {
2040                        if let Err(e) = vs
2041                            .insert(
2042                                note.id,
2043                                SubstrateKind::Note,
2044                                ns,
2045                                "note.content",
2046                                vec![vector],
2047                            )
2048                            .await
2049                        {
2050                            tracing::warn!(
2051                                note_id = %note.id,
2052                                model = %model_name,
2053                                error = %e,
2054                                "try_create_note: vector insert failed (non-fatal)"
2055                            );
2056                        }
2057                    }
2058                }
2059                Err(e) => {
2060                    tracing::warn!(
2061                        note_id = %note.id,
2062                        model = %model_name,
2063                        error = %e,
2064                        "try_create_note: embedding failed (non-fatal)"
2065                    );
2066                }
2067            }
2068        }
2069
2070        Ok(Some(note))
2071    }
2072
2073    // REASON: private inner function unifies all create_note variants; it receives every
2074    // optional parameter individually so that public variants can pass None without
2075    // requiring callers to construct an intermediate struct.
2076    #[allow(clippy::too_many_arguments)]
2077    async fn create_note_inner(
2078        &self,
2079        token: &NamespaceToken,
2080        kind: &str,
2081        name: Option<&str>,
2082        content: &str,
2083        salience: Option<f64>,
2084        decay_factor: Option<f64>,
2085        properties: Option<serde_json::Value>,
2086        annotates: Vec<Uuid>,
2087        embedding_model: Option<&str>,
2088    ) -> RuntimeResult<Note> {
2089        self.validate_note_kind(kind)?;
2090        // Secret gate: scan content, optional name, and structured properties.
2091        crate::secret_gate::check(content)?;
2092        if let Some(n) = name {
2093            crate::secret_gate::check(n)?;
2094        }
2095        if let Some(ref p) = properties {
2096            crate::secret_gate::check_json(p)?;
2097        }
2098        let ns = token.namespace().as_str();
2099
2100        // Validate all annotates targets before any write (atomicity: all-or-nothing).
2101        // Targets must be in the primary namespace — visible-set membership is not
2102        // sufficient for mutation endpoint validation.
2103        for &target_id in &annotates {
2104            if !self.substrate_exists_in_primary(token, target_id).await? {
2105                return Err(RuntimeError::NotFound(format!(
2106                    "create_note annotates target {target_id} not found in namespace"
2107                )));
2108            }
2109        }
2110
2111        // Reject non-finite or out-of-range salience/decay at the runtime boundary
2112        // rather than letting storage silently clamp them (coding-standards §508-516).
2113        if let Some(s) = salience {
2114            if !s.is_finite() || !(0.0..=1.0).contains(&s) {
2115                return Err(RuntimeError::InvalidInput(format!(
2116                    "salience must be a finite value in [0.0, 1.0]; got {s}"
2117                )));
2118            }
2119        }
2120        if let Some(d) = decay_factor {
2121            if !d.is_finite() || d < 0.0 {
2122                return Err(RuntimeError::InvalidInput(format!(
2123                    "decay_factor must be a finite value >= 0.0; got {d}"
2124                )));
2125            }
2126        }
2127
2128        // Codex round 2 Medium (PR #407): resolve embedding_model BEFORE any
2129        // note/FTS/vector write so unknown-model errors are atomic at the
2130        // runtime layer, not just at one pack handler. Direct Rust callers
2131        // (other packs, integration tests) get the same guarantee.
2132        if let Some(model_name) = embedding_model {
2133            self.resolve_embedding_model(Some(model_name))?;
2134        }
2135
2136        let mut note = Note::new(ns, kind, content);
2137        if let Some(s) = salience {
2138            note = note.with_salience(s);
2139        }
2140        if let Some(df) = decay_factor {
2141            note = note.with_decay(df);
2142        }
2143        if let Some(n) = name {
2144            note = note.with_name(n);
2145        }
2146        if let Some(p) = properties {
2147            note = note.with_properties(p);
2148        }
2149        self.notes(token)?.upsert_note(note.clone()).await?;
2150
2151        // From here on, any error must compensate by removing the note row, its
2152        // FTS document, and any vector entries already inserted — the same
2153        // cleanup used by the annotates-edge block below.  A local closure
2154        // captures those operations so both this block and the edge block share
2155        // the same cleanup path without duplication.
2156        //
2157        // Note: the closure borrows `self`, `token`, `ns`, `note`, and
2158        // `embed_model_names` (populated after the FTS step); because the
2159        // vector-model list is only known after embedding is decided, we collect
2160        // it once before the FTS step and thread it through.
2161
2162        // Decide which embedding models to use (before touching FTS/vectors).
2163        let embed_model_names: Vec<String> = if let Some(m) = embedding_model {
2164            vec![m.to_string()]
2165        } else {
2166            // Fan out to ALL registered models — includes both lattice models
2167            // from RuntimeConfig and any custom providers added via
2168            // register_embedder() (codex High #1, PR #444).
2169            // Gate on the registry, not config().embedding_model, so that
2170            // custom-only runtimes (no lattice model in config) also fan out.
2171            let names = self.registered_embedding_model_names();
2172            if names.is_empty() {
2173                // No models configured at all — skip vector embedding.
2174                vec![]
2175            } else {
2176                names
2177            }
2178        };
2179
2180        // FTS step — compensate note row on failure.
2181        {
2182            // Injection: check FTS_FAIL_NS (armed by `arm_fts_fail(ns)`).
2183            // Fires only when the armed namespace matches this note's namespace,
2184            // then clears (one-shot).  No lock acquisition in release builds —
2185            // the cfg(not) branch is a const false so the compiler eliminates
2186            // the if-branch entirely.
2187            #[cfg(any(test, feature = "fault-injection"))]
2188            let fts_inject = {
2189                let mut g = FTS_FAIL_NS.lock().unwrap();
2190                if g.as_deref() == Some(ns) {
2191                    *g = None;
2192                    true
2193                } else {
2194                    false
2195                }
2196            };
2197            #[cfg(not(any(test, feature = "fault-injection")))]
2198            let fts_inject = false;
2199            let fts_result: RuntimeResult<()> = if fts_inject {
2200                Err(RuntimeError::Internal("injected FTS failure".to_string()))
2201            } else {
2202                match self.text_for_notes(token) {
2203                    Ok(fts) => fts
2204                        .upsert_document(note_fts_document(&note))
2205                        .await
2206                        .map_err(RuntimeError::from),
2207                    Err(e) => Err(e),
2208                }
2209            };
2210
2211            if let Err(e) = fts_result {
2212                // Best-effort compensation — ignore cleanup errors.
2213                if let Ok(store) = self.notes(token) {
2214                    let _ = store.delete_note(note.id, DeleteMode::Hard).await;
2215                }
2216                return Err(e);
2217            }
2218        }
2219
2220        // Vector embedding + insert step — compensate note row + FTS doc on failure.
2221        // Multi-model vector embedding:
2222        //   - explicit embedding_model → single model (existing behaviour)
2223        //   - None + any models registered → ALL registered models in parallel
2224        //   - None + no models configured → skip (text-only)
2225        if embed_model_names.len() == 1 {
2226            // Single-model path: preserves original sequential behaviour.
2227            let model_name = &embed_model_names[0];
2228            let vec_result = self
2229                .embed_document_with_model(model_name, &note.content)
2230                .await;
2231
2232            // Injection: check VECTOR_FAIL_NS (armed by `arm_vector_fail(ns)`).
2233            // Fires only when the armed namespace matches this note's namespace,
2234            // then clears (one-shot).  No lock acquisition in release builds —
2235            // the cfg(not) branch is a const false eliminating the if-branch.
2236            #[cfg(any(test, feature = "fault-injection"))]
2237            let vec_inject = {
2238                let mut g = VECTOR_FAIL_NS.lock().unwrap();
2239                if g.as_deref() == Some(ns) {
2240                    *g = None;
2241                    true
2242                } else {
2243                    false
2244                }
2245            };
2246            #[cfg(not(any(test, feature = "fault-injection")))]
2247            let vec_inject = false;
2248            let vec_result: RuntimeResult<Vec<f32>> = if vec_inject {
2249                Err(RuntimeError::Internal(
2250                    "injected vector failure".to_string(),
2251                ))
2252            } else {
2253                vec_result
2254            };
2255
2256            let single_model_result: RuntimeResult<()> = match vec_result {
2257                Ok(vector) => match self.vectors_for_model(token, model_name) {
2258                    Ok(vs) => vs
2259                        .insert(
2260                            note.id,
2261                            SubstrateKind::Note,
2262                            ns,
2263                            "note.content",
2264                            vec![vector],
2265                        )
2266                        .await
2267                        .map_err(RuntimeError::from),
2268                    Err(e) => Err(e),
2269                },
2270                Err(e) => Err(e),
2271            };
2272            if let Err(e) = single_model_result {
2273                // Compensate note row + FTS.
2274                if let Ok(store) = self.notes(token) {
2275                    let _ = store.delete_note(note.id, DeleteMode::Hard).await;
2276                }
2277                if let Ok(fts) = self.text_for_notes(token) {
2278                    let _ = fts.delete_document(ns, note.id).await;
2279                }
2280                return Err(e);
2281            }
2282        } else if !embed_model_names.is_empty() {
2283            // Multi-model path: embed with each model in parallel via spawned tasks,
2284            // then insert one VectorRecord per model.
2285            let rt_clone = self.clone();
2286            let content_owned = note.content.clone();
2287            let mut handles = Vec::with_capacity(embed_model_names.len());
2288            for model_name in &embed_model_names {
2289                let rt = rt_clone.clone();
2290                let text = content_owned.clone();
2291                let name = model_name.clone();
2292                handles.push(tokio::spawn(async move {
2293                    rt.embed_document_with_model(&name, &text).await
2294                }));
2295            }
2296            let mut vectors: Vec<Vec<f32>> = Vec::with_capacity(embed_model_names.len());
2297            for handle in handles {
2298                let join_result = handle
2299                    .await
2300                    .map_err(|e| RuntimeError::Internal(format!("embed task panicked: {e}")));
2301                match join_result {
2302                    Err(e) => {
2303                        // Compensate note row + FTS (no vectors inserted yet).
2304                        if let Ok(store) = self.notes(token) {
2305                            let _ = store.delete_note(note.id, DeleteMode::Hard).await;
2306                        }
2307                        if let Ok(fts) = self.text_for_notes(token) {
2308                            let _ = fts.delete_document(ns, note.id).await;
2309                        }
2310                        return Err(e);
2311                    }
2312                    Ok(Err(e)) => {
2313                        // Embed call failed — compensate note row + FTS.
2314                        if let Ok(store) = self.notes(token) {
2315                            let _ = store.delete_note(note.id, DeleteMode::Hard).await;
2316                        }
2317                        if let Ok(fts) = self.text_for_notes(token) {
2318                            let _ = fts.delete_document(ns, note.id).await;
2319                        }
2320                        return Err(e);
2321                    }
2322                    Ok(Ok(vec)) => vectors.push(vec),
2323                }
2324            }
2325            // TODO(P2): parallelize vector inserts (codex review #444)
2326            let mut inserted_models: Vec<String> = Vec::with_capacity(embed_model_names.len());
2327            for (model_name, vector) in embed_model_names.iter().zip(vectors.into_iter()) {
2328                let insert_result = match self.vectors_for_model(token, model_name) {
2329                    Ok(vs) => vs
2330                        .insert(
2331                            note.id,
2332                            SubstrateKind::Note,
2333                            ns,
2334                            "note.content",
2335                            vec![vector],
2336                        )
2337                        .await
2338                        .map_err(RuntimeError::from),
2339                    Err(e) => Err(e),
2340                };
2341                if let Err(e) = insert_result {
2342                    // Compensate note row + FTS + already-inserted vectors.
2343                    if let Ok(store) = self.notes(token) {
2344                        let _ = store.delete_note(note.id, DeleteMode::Hard).await;
2345                    }
2346                    if let Ok(fts) = self.text_for_notes(token) {
2347                        let _ = fts.delete_document(ns, note.id).await;
2348                    }
2349                    for m in &inserted_models {
2350                        if let Ok(vs) = self.vectors_for_model(token, m) {
2351                            let _ = vs.delete(note.id).await;
2352                        }
2353                    }
2354                    return Err(e);
2355                }
2356                inserted_models.push(model_name.clone());
2357            }
2358        }
2359
2360        // Create annotates edges, compensating on failure to preserve atomicity.
2361        //
2362        // Pre-validation (above) ensures all targets exist, so link failures are
2363        // unexpected. If one occurs: delete any edges already created, then remove
2364        // the note, its FTS document, and its vector entry.
2365        let mut created_edges: Vec<Uuid> = Vec::with_capacity(annotates.len());
2366
2367        // In test builds, iterate with an index so the failure-injection hook can
2368        // target a specific call.  In release builds, skip the enumerate overhead.
2369        #[cfg(test)]
2370        let annotates_iter: Vec<(usize, Uuid)> = annotates
2371            .iter()
2372            .enumerate()
2373            .map(|(i, &id)| (i, id))
2374            .collect();
2375        #[cfg(test)]
2376        macro_rules! next_target {
2377            ($pair:expr) => {
2378                $pair.1
2379            };
2380        }
2381        #[cfg(not(test))]
2382        let annotates_iter: Vec<Uuid> = annotates.to_vec();
2383        #[cfg(not(test))]
2384        macro_rules! next_target {
2385            ($pair:expr) => {
2386                $pair
2387            };
2388        }
2389
2390        for pair in annotates_iter {
2391            let target_id = next_target!(pair);
2392
2393            // Test-only: inject a failure on the configured call index (1-based).
2394            #[cfg(test)]
2395            let injected_err: Option<RuntimeError> = {
2396                let call_idx = pair.0;
2397                LINK_FAIL_AFTER.with(|cell| {
2398                    let n = cell.get();
2399                    if n > 0 && call_idx + 1 == n {
2400                        cell.set(0); // reset so subsequent calls are unaffected
2401                        Some(RuntimeError::Internal("injected link failure".to_string()))
2402                    } else {
2403                        None
2404                    }
2405                })
2406            };
2407            #[cfg(not(test))]
2408            let injected_err: Option<RuntimeError> = None;
2409
2410            let link_result = if let Some(e) = injected_err {
2411                Err(e)
2412            } else {
2413                self.link(
2414                    token,
2415                    note.id,
2416                    target_id,
2417                    EdgeRelation::Annotates,
2418                    1.0,
2419                    None,
2420                )
2421                .await
2422            };
2423
2424            match link_result {
2425                Ok(edge) => created_edges.push(edge.id.into()),
2426                Err(e) => {
2427                    // Best-effort compensation — ignore cleanup errors.
2428                    for edge_id in created_edges {
2429                        let _ = self.delete_edge(token, edge_id, true).await;
2430                    }
2431                    if let Ok(store) = self.notes(token) {
2432                        let _ = store.delete_note(note.id, DeleteMode::Hard).await;
2433                    }
2434                    if let Ok(fts) = self.text_for_notes(token) {
2435                        let _ = fts.delete_document(ns, note.id).await;
2436                    }
2437                    for model_name in &embed_model_names {
2438                        if let Ok(vs) = self.vectors_for_model(token, model_name) {
2439                            let _ = vs.delete(note.id).await;
2440                        }
2441                    }
2442                    return Err(e);
2443                }
2444            }
2445        }
2446
2447        Ok(note)
2448    }
2449
2450    /// List notes visible to the token, optionally filtered by kind.
2451    ///
2452    /// When the token carries a multi-namespace visible set, notes from all
2453    /// visible namespaces are returned. When the visible set is `[primary]`
2454    /// (the default) this behaves identically to the pre-visibility behaviour.
2455    pub async fn list_notes(
2456        &self,
2457        token: &NamespaceToken,
2458        kind: Option<&str>,
2459        limit: u32,
2460        offset: u32,
2461    ) -> RuntimeResult<Vec<Note>> {
2462        let visible = token.visible_namespaces();
2463        if visible.len() == 1 {
2464            // Fast path: single namespace — use the dedicated query_notes method.
2465            let page = self
2466                .notes(token)?
2467                .query_notes(
2468                    token.namespace().as_str(),
2469                    kind,
2470                    PageRequest {
2471                        offset: offset.into(),
2472                        limit,
2473                    },
2474                )
2475                .await?;
2476            return Ok(page.items);
2477        }
2478        // Multi-namespace path: use query_notes_filtered with the visible set.
2479        use khive_storage::note::NoteFilter;
2480        let ns_strs: Vec<String> = visible.iter().map(|ns| ns.as_str().to_owned()).collect();
2481        let filter = NoteFilter {
2482            kind: kind.map(|k| k.to_string()),
2483            namespaces: ns_strs,
2484            ..Default::default()
2485        };
2486        let page = self
2487            .notes(token)?
2488            .query_notes_filtered(
2489                token.namespace().as_str(),
2490                &filter,
2491                PageRequest {
2492                    offset: offset.into(),
2493                    limit,
2494                },
2495            )
2496            .await?;
2497        Ok(page.items)
2498    }
2499
2500    /// Search notes using a hybrid FTS5 + vector pipeline with salience weighting.
2501    ///
2502    /// Pipeline:
2503    /// 1. FTS5 query against `notes_<namespace>`.
2504    /// 2. If embedding model is configured: vector search filtered to `kind="note"`.
2505    /// 3. RRF fusion (k=60).
2506    /// 4. Salience-weighted rerank: `score *= (0.5 + 0.5 * note.salience)`.
2507    /// 5. Filter soft-deleted notes, apply optional kind / tag / properties predicates.
2508    ///    Tags and properties are pushed into the per-note fetch loop BEFORE truncation
2509    ///    so that matching notes ranked beyond `limit` in the raw fusion are not silently
2510    ///    dropped (fix for issue #225).
2511    /// 6. Truncate to `limit`.
2512    ///
2513    /// `tags_any`: when non-empty, only notes that have at least one of these tags
2514    /// (stored in `properties["tags"]`, case-insensitive match) are retained. The
2515    /// check happens inside the alive-note loop, before `hits.truncate(limit)`.
2516    ///
2517    /// `properties_filter`: when `Some`, only notes whose `properties` JSON object is
2518    /// a superset of the given filter object are retained. Also applied before truncation.
2519    #[allow(clippy::too_many_arguments)]
2520    pub async fn search_notes(
2521        &self,
2522        token: &NamespaceToken,
2523        query_text: &str,
2524        query_vector: Option<Vec<f32>>,
2525        limit: u32,
2526        note_kind: Option<&str>,
2527        include_superseded: bool,
2528        tags_any: &[String],
2529        properties_filter: Option<&serde_json::Value>,
2530    ) -> RuntimeResult<Vec<NoteSearchHit>> {
2531        const RRF_K: usize = 60;
2532        let candidates = limit.saturating_mul(4).max(limit);
2533        let visible_ns: Vec<String> = token
2534            .visible_namespaces()
2535            .iter()
2536            .map(|ns| ns.as_str().to_owned())
2537            .collect();
2538
2539        // FTS5 over the notes index — search all visible namespaces.
2540        let text_hits = self
2541            .text_for_notes(token)?
2542            .search(TextSearchRequest {
2543                query: query_text.to_string(),
2544                mode: TextQueryMode::Plain,
2545                filter: Some(TextFilter {
2546                    namespaces: visible_ns.clone(),
2547                    ..TextFilter::default()
2548                }),
2549                top_k: candidates,
2550                snippet_chars: 200,
2551            })
2552            .await?;
2553
2554        // Vector search filtered to notes.
2555        let vector_hits = if query_vector.is_some() || self.config().embedding_model.is_some() {
2556            self.vector_search(
2557                token,
2558                query_vector,
2559                Some(query_text),
2560                candidates,
2561                Some(SubstrateKind::Note),
2562            )
2563            .await?
2564        } else {
2565            vec![]
2566        };
2567
2568        // Keep the full text∪vector union through RRF — salience weighting and
2569        // soft-delete/kind filtering happen *after* this, and the final
2570        // `hits.truncate(limit)` is the only result-limiting cut. Truncating to
2571        // `candidates` here would drop a high-salience note ranked just outside
2572        // the raw RRF cutoff before salience ever applied (codex #526).
2573        let fuse_k = text_hits.len() + vector_hits.len();
2574        let fused = crate::fusion::rrf_fuse_k(text_hits, vector_hits, RRF_K, fuse_k)?;
2575
2576        let candidate_ids: Vec<Uuid> = fused.iter().map(|hit| hit.entity_id).collect();
2577        if candidate_ids.is_empty() {
2578            return Ok(vec![]);
2579        }
2580
2581        // Fetch each candidate note individually to get salience and apply
2582        // soft-delete + (optional) kind filtering. Notes whose `kind` doesn't
2583        // match `note_kind` are dropped post-fetch — they're a small set
2584        // bounded by the text∪vector union (≤ 2×candidates), so the read is cheap.
2585        let note_store = self.notes(token)?;
2586        let mut alive_notes: HashMap<Uuid, Note> = HashMap::new();
2587        for id in &candidate_ids {
2588            if let Some(note) = note_store.get_note(*id).await? {
2589                if note.deleted_at.is_some() {
2590                    continue;
2591                }
2592                if let Some(want_kind) = note_kind {
2593                    if note.kind != want_kind {
2594                        continue;
2595                    }
2596                }
2597                // Apply tag predicate before adding to alive set: tags on notes live
2598                // inside `properties["tags"]` (a JSON array). This pushes the filter
2599                // before truncation so matching notes ranked beyond `limit` in the raw
2600                // fusion are not silently dropped (fix for issue #225).
2601                if !tags_any.is_empty() {
2602                    let note_tags: Vec<String> = note
2603                        .properties
2604                        .as_ref()
2605                        .and_then(|p| p.get("tags"))
2606                        .and_then(serde_json::Value::as_array)
2607                        .map(|arr| {
2608                            arr.iter()
2609                                .filter_map(serde_json::Value::as_str)
2610                                .map(str::to_owned)
2611                                .collect()
2612                        })
2613                        .unwrap_or_default();
2614                    if !note_tags
2615                        .iter()
2616                        .any(|t| tags_any.iter().any(|w| t.eq_ignore_ascii_case(w)))
2617                    {
2618                        continue;
2619                    }
2620                }
2621                // Apply properties predicate before truncation (fix for issue #225).
2622                if let Some(pf) = properties_filter {
2623                    if !note_props_match(note.properties.as_ref(), pf) {
2624                        continue;
2625                    }
2626                }
2627                alive_notes.insert(*id, note);
2628            }
2629        }
2630
2631        // Drop superseded notes unless include_superseded is true: any note targeted
2632        // by a `supersedes` edge is obsolete and excluded from default search.
2633        if !include_superseded && !alive_notes.is_empty() {
2634            let graph = self.graph(token)?;
2635            let mut superseded: std::collections::HashSet<Uuid> = std::collections::HashSet::new();
2636            for &note_id in alive_notes.keys() {
2637                let inbound = graph
2638                    .neighbors(
2639                        note_id,
2640                        NeighborQuery {
2641                            direction: Direction::In,
2642                            relations: Some(vec![EdgeRelation::Supersedes]),
2643                            limit: Some(1),
2644                            min_weight: None,
2645                        },
2646                    )
2647                    .await?;
2648                if !inbound.is_empty() {
2649                    superseded.insert(note_id);
2650                }
2651            }
2652            alive_notes.retain(|id, _| !superseded.contains(id));
2653        }
2654
2655        // Apply salience weighting and collect final hits.
2656        let mut hits: Vec<NoteSearchHit> = fused
2657            .into_iter()
2658            .filter_map(|hit| {
2659                let note = alive_notes.get(&hit.entity_id)?;
2660                let salience = note.salience.unwrap_or(0.5);
2661                let weight = 0.5 + 0.5 * salience;
2662                let weighted = DeterministicScore::from_f64(hit.score.to_f64() * weight);
2663                Some(NoteSearchHit {
2664                    note_id: hit.entity_id,
2665                    score: weighted,
2666                    title: hit.title.or_else(|| note_title(note)),
2667                    snippet: hit.snippet.or_else(|| note_snippet(note)),
2668                })
2669            })
2670            .collect();
2671
2672        hits.sort_by(|a, b| b.score.cmp(&a.score).then(a.note_id.cmp(&b.note_id)));
2673        hits.truncate(limit as usize);
2674        Ok(hits)
2675    }
2676
2677    /// Resolve a short UUID prefix (8+ hex chars) to a full UUID.
2678    ///
2679    /// Searches entities, notes, and edges tables for a UUID starting with the
2680    /// given prefix, scoped to the caller's namespace. Returns `Ok(Some(uuid))`
2681    /// if exactly one match is found, `Ok(None)` if no matches, or an error if
2682    /// ambiguous (multiple matches).
2683    pub async fn resolve_prefix(
2684        &self,
2685        token: &NamespaceToken,
2686        prefix: &str,
2687    ) -> RuntimeResult<Option<Uuid>> {
2688        self.resolve_prefix_inner(token, prefix, false).await
2689    }
2690
2691    pub async fn resolve_prefix_including_deleted(
2692        &self,
2693        token: &NamespaceToken,
2694        prefix: &str,
2695    ) -> RuntimeResult<Option<Uuid>> {
2696        self.resolve_prefix_inner(token, prefix, true).await
2697    }
2698
2699    async fn resolve_prefix_inner(
2700        &self,
2701        token: &NamespaceToken,
2702        prefix: &str,
2703        include_deleted: bool,
2704    ) -> RuntimeResult<Option<Uuid>> {
2705        use khive_storage::types::{SqlStatement, SqlValue};
2706
2707        let ns = token.namespace().as_str().to_owned();
2708        let pattern = format!("{}%", prefix);
2709
2710        let tables = [
2711            ("entities", true),
2712            ("notes", true),
2713            ("events", false),
2714            ("graph_edges", false),
2715        ];
2716
2717        let mut matches: Vec<String> = Vec::new();
2718        let mut reader = self.sql().reader().await.map_err(RuntimeError::Storage)?;
2719
2720        for (table, has_deleted_at) in tables {
2721            let deleted_filter = if has_deleted_at && !include_deleted {
2722                " AND deleted_at IS NULL"
2723            } else {
2724                ""
2725            };
2726            let sql = SqlStatement {
2727                sql: format!(
2728                    "SELECT id FROM {table} WHERE id LIKE ?1 AND namespace = ?2{deleted_filter} LIMIT 2"
2729                ),
2730                params: vec![
2731                    SqlValue::Text(pattern.clone()),
2732                    SqlValue::Text(ns.clone()),
2733                ],
2734                label: Some("resolve_prefix".into()),
2735            };
2736            match reader.query_all(sql).await {
2737                Ok(rows) => {
2738                    for row in rows {
2739                        if let Some(col) = row.columns.first() {
2740                            if let SqlValue::Text(s) = &col.value {
2741                                matches.push(s.clone());
2742                            }
2743                        }
2744                    }
2745                }
2746                Err(e) => {
2747                    let msg = e.to_string();
2748                    if msg.contains("no such table") {
2749                        continue;
2750                    }
2751                    return Err(RuntimeError::Storage(e));
2752                }
2753            }
2754            if matches.len() > 1 {
2755                break;
2756            }
2757        }
2758
2759        match matches.len() {
2760            0 => Ok(None),
2761            1 => {
2762                let uuid = Uuid::from_str(&matches[0])
2763                    .map_err(|e| RuntimeError::Internal(format!("stored UUID is invalid: {e}")))?;
2764                Ok(Some(uuid))
2765            }
2766            _ => {
2767                let uuids: Vec<uuid::Uuid> = matches
2768                    .iter()
2769                    .filter_map(|s| Uuid::from_str(s).ok())
2770                    .collect();
2771                Err(RuntimeError::AmbiguousPrefix {
2772                    prefix: prefix.to_string(),
2773                    matches: uuids,
2774                })
2775            }
2776        }
2777    }
2778
2779    /// Resolve a UUID to its substrate kind with NO namespace filter.
2780    ///
2781    /// By-ID contract (ADR-007): UUID v4 is globally unique — by-ID substrate
2782    /// inference must return the record regardless of caller namespace.  Used by
2783    /// the public `update` and `delete` verb handlers when no explicit `kind` is
2784    /// supplied (PR-A1 / codex r2).
2785    ///
2786    /// Does NOT consult the visible set or the primary-namespace check.  The
2787    /// token is still required to route to the correct backend pool but its
2788    /// namespace value is not used as a filter.
2789    pub async fn resolve_by_id(
2790        &self,
2791        token: &NamespaceToken,
2792        id: Uuid,
2793    ) -> RuntimeResult<Option<Resolved>> {
2794        // Entity: direct by-UUID fetch (ID-only, no namespace check).
2795        if let Some(entity) = self.entities(token)?.get_entity(id).await? {
2796            return Ok(Some(Resolved::Entity(entity)));
2797        }
2798
2799        // Note: direct by-UUID fetch (ID-only).
2800        if let Some(note) = self.notes(token)?.get_note(id).await? {
2801            return Ok(Some(Resolved::Note(note)));
2802        }
2803
2804        // Edges and events are not returned here; the caller's `_` arm handles
2805        // those with a separate get_edge / get_event check.
2806        Ok(None)
2807    }
2808
2809    /// Resolve a UUID to its substrate kind with NO namespace filter, including
2810    /// soft-deleted rows.
2811    ///
2812    /// Used by the hard-delete path when no explicit `kind` is supplied, so
2813    /// already-soft-deleted records can still be located by UUID alone.
2814    pub async fn resolve_by_id_including_deleted(
2815        &self,
2816        token: &NamespaceToken,
2817        id: Uuid,
2818    ) -> RuntimeResult<Option<Resolved>> {
2819        // Entity: including soft-deleted, no namespace check.
2820        if let Some(entity) = self
2821            .entities(token)?
2822            .get_entity_including_deleted(id)
2823            .await?
2824        {
2825            return Ok(Some(Resolved::Entity(entity)));
2826        }
2827
2828        // Note: including soft-deleted, no namespace check.
2829        if let Some(note) = self.notes(token)?.get_note_including_deleted(id).await? {
2830            return Ok(Some(Resolved::Note(note)));
2831        }
2832
2833        // Edges and events are not returned here; the caller's `_` arm handles
2834        // those with a separate get_edge_including_deleted check.
2835        Ok(None)
2836    }
2837
2838    /// Resolve a UUID to its substrate kind by trying entity, then note, then event stores.
2839    ///
2840    /// Returns `None` if the UUID is not found in any substrate.
2841    /// Cost: at most 3 store lookups per call (cheap for v0.1).
2842    pub async fn resolve(
2843        &self,
2844        token: &NamespaceToken,
2845        id: Uuid,
2846    ) -> RuntimeResult<Option<Resolved>> {
2847        // Entity: use the namespace-checked getter (errors on mismatch/absent).
2848        match self.get_entity(token, id).await {
2849            Ok(entity) => return Ok(Some(Resolved::Entity(entity))),
2850            Err(RuntimeError::NotFound(_) | RuntimeError::NamespaceMismatch { .. }) => {}
2851            Err(e) => return Err(e),
2852        }
2853
2854        // Note: storage get_note is ID-only — verify against visible set.
2855        if let Some(note) = self.notes(token)?.get_note(id).await? {
2856            if Self::ensure_namespace_visible(&note.namespace, token).is_ok() {
2857                return Ok(Some(Resolved::Note(note)));
2858            }
2859        }
2860
2861        // Event: storage get_event is ID-only — verify against visible set.
2862        if let Some(event) = self.events(token)?.get_event(id).await? {
2863            if Self::ensure_namespace_visible(&event.namespace, token).is_ok() {
2864                return Ok(Some(Resolved::Event(event)));
2865            }
2866        }
2867
2868        Ok(None)
2869    }
2870
2871    /// Resolve a UUID to its substrate kind using primary-namespace-only enforcement.
2872    ///
2873    /// Unlike `resolve`, never consults the visible set. Use from mutation validation
2874    /// paths (link, annotate, build_edge) where strict primary ownership is required.
2875    pub async fn resolve_primary(
2876        &self,
2877        token: &NamespaceToken,
2878        id: Uuid,
2879    ) -> RuntimeResult<Option<Resolved>> {
2880        let ns = token.namespace().as_str();
2881
2882        // Entity: primary-only check (exclude entities in visible-only namespaces).
2883        if let Some(entity) = self.entities(token)?.get_entity(id).await? {
2884            if Self::ensure_namespace(&entity.namespace, ns).is_ok() {
2885                return Ok(Some(Resolved::Entity(entity)));
2886            }
2887        }
2888
2889        // Note: primary-only check.
2890        if let Some(note) = self.notes(token)?.get_note(id).await? {
2891            if Self::ensure_namespace(&note.namespace, ns).is_ok() {
2892                return Ok(Some(Resolved::Note(note)));
2893            }
2894        }
2895
2896        // Event: primary-only check.
2897        if let Some(event) = self.events(token)?.get_event(id).await? {
2898            if Self::ensure_namespace(&event.namespace, ns).is_ok() {
2899                return Ok(Some(Resolved::Event(event)));
2900            }
2901        }
2902
2903        Ok(None)
2904    }
2905
2906    /// Resolve a UUID to its substrate kind, including soft-deleted rows.
2907    ///
2908    /// Used exclusively by the hard-delete path to locate records that have
2909    /// already been soft-deleted. Namespace isolation is still enforced.
2910    pub async fn resolve_including_deleted(
2911        &self,
2912        token: &NamespaceToken,
2913        id: Uuid,
2914    ) -> RuntimeResult<Option<Resolved>> {
2915        let ns = token.namespace().as_str();
2916
2917        if let Some(entity) = self
2918            .entities(token)?
2919            .get_entity_including_deleted(id)
2920            .await?
2921        {
2922            if Self::ensure_namespace(&entity.namespace, ns).is_ok() {
2923                return Ok(Some(Resolved::Entity(entity)));
2924            }
2925        }
2926
2927        if let Some(note) = self.notes(token)?.get_note_including_deleted(id).await? {
2928            if Self::ensure_namespace(&note.namespace, ns).is_ok() {
2929                return Ok(Some(Resolved::Note(note)));
2930            }
2931        }
2932
2933        if let Some(event) = self.events(token)?.get_event(id).await? {
2934            if Self::ensure_namespace(&event.namespace, ns).is_ok() {
2935                return Ok(Some(Resolved::Event(event)));
2936            }
2937        }
2938
2939        Ok(None)
2940    }
2941
2942    /// Delete a note by ID, enforcing namespace isolation.
2943    ///
2944    /// On hard delete, cascades to remove all incident edges (both inbound and
2945    /// outbound) and cleans up FTS and vector indexes, preventing dangling
2946    /// references for `annotates` edges that target this note.
2947    /// Soft delete also cleans FTS and vector indexes; edges are left in place.
2948    ///
2949    /// Returns `Ok(false)` if the note does not exist or belongs to a different
2950    /// namespace (wrong-namespace is indistinguishable from absent).
2951    /// Soft-delete or hard-delete a note by ID.
2952    ///
2953    /// PR-A1: UUID v4 is globally unique — no namespace filter on by-ID ops (ADR-007 rule 2).
2954    /// Cascade and index cleanup target the RECORD's stored namespace, not the caller token's.
2955    pub async fn delete_note(
2956        &self,
2957        token: &NamespaceToken,
2958        id: Uuid,
2959        hard: bool,
2960    ) -> RuntimeResult<bool> {
2961        let note_store = self.notes(token)?;
2962        let note = if hard {
2963            match note_store.get_note_including_deleted(id).await? {
2964                Some(n) => n,
2965                None => return Ok(false),
2966            }
2967        } else {
2968            match note_store.get_note(id).await? {
2969                Some(n) => n,
2970                None => return Ok(false),
2971            }
2972        };
2973        let mode = if hard {
2974            DeleteMode::Hard
2975        } else {
2976            DeleteMode::Soft
2977        };
2978
2979        // Route index cleanup through the RECORD's namespace, not the caller's.
2980        let record_tok = NamespaceToken::for_namespace(
2981            khive_types::Namespace::parse(&note.namespace)
2982                .map_err(|e| RuntimeError::Internal(format!("note namespace invalid: {e}")))?,
2983        );
2984        let record_ns = note.namespace.clone();
2985
2986        // On hard delete, cascade-remove all incident edges (including soft-deleted) and clean up
2987        // indexes. Uses purge_incident_edges so that already-soft-deleted edges are also removed,
2988        // preventing dangling graph_edges rows (ADR-002 no-dangling-references).
2989        if hard {
2990            let graph = self.graph(&record_tok)?;
2991            graph.purge_incident_edges(id).await?;
2992            self.text_for_notes(&record_tok)?
2993                .delete_document(&record_ns, id)
2994                .await?;
2995            // Codex High 2 (PR #407): scoped delete — iterate over EVERY
2996            // registered embedding model's vector store so non-default vectors
2997            // don't orphan when the note is deleted.
2998            for model_name in self.registered_embedding_model_names() {
2999                self.vectors_for_model(&record_tok, &model_name)?
3000                    .delete(id)
3001                    .await?;
3002            }
3003        }
3004
3005        let deleted = note_store.delete_note(id, mode).await?;
3006        if !hard && deleted {
3007            self.text_for_notes(&record_tok)?
3008                .delete_document(&record_ns, id)
3009                .await?;
3010            for model_name in self.registered_embedding_model_names() {
3011                self.vectors_for_model(&record_tok, &model_name)?
3012                    .delete(id)
3013                    .await?;
3014            }
3015        }
3016        if deleted {
3017            let event_store = self.events(token)?;
3018            let event = khive_storage::event::Event::new(
3019                record_ns.clone(),
3020                "delete",
3021                EventKind::NoteDeleted,
3022                SubstrateKind::Note,
3023                "",
3024            )
3025            .with_target(id)
3026            .with_payload(serde_json::json!({"id": id, "namespace": record_ns, "hard": hard}));
3027            event_store.append_event(event).await.map_err(|e| {
3028                RuntimeError::Internal(format!("delete_note: event store write failed: {e}"))
3029            })?;
3030        }
3031        Ok(deleted)
3032    }
3033}
3034
3035/// Result of a GQL/SPARQL query with optional validation warnings.
3036#[derive(Clone, Debug, Serialize)]
3037pub struct QueryResult {
3038    pub rows: Vec<SqlRow>,
3039    #[serde(skip_serializing_if = "Vec::is_empty")]
3040    pub warnings: Vec<String>,
3041}
3042
3043impl KhiveRuntime {
3044    // ---- Query operations ----
3045
3046    /// Execute a GQL or SPARQL query string, returning raw SQL rows.
3047    ///
3048    /// The query is compiled to SQL with the namespace scope applied.
3049    /// GQL syntax: `MATCH (a:concept)-[e:extends]->(b) RETURN a, b LIMIT 10`
3050    /// SPARQL syntax: `SELECT ?a WHERE { ?a :kind "concept" . }`
3051    pub async fn query(&self, token: &NamespaceToken, query: &str) -> RuntimeResult<Vec<SqlRow>> {
3052        Ok(self
3053            .query_with_metadata(token, query, khive_query::CompileOptions::default())
3054            .await?
3055            .rows)
3056    }
3057
3058    /// Execute a GQL/SPARQL query, returning rows and any validation warnings.
3059    pub async fn query_with_metadata(
3060        &self,
3061        token: &NamespaceToken,
3062        query: &str,
3063        mut opts: khive_query::CompileOptions,
3064    ) -> RuntimeResult<QueryResult> {
3065        use khive_query::QueryValue;
3066        use khive_storage::types::SqlValue;
3067
3068        let ast = khive_query::parse_auto(query)?;
3069        opts.scopes = token
3070            .visible_namespaces()
3071            .iter()
3072            .map(|ns| ns.as_str().to_string())
3073            .collect();
3074        let compiled = khive_query::compile(&ast, &opts)?;
3075        let warnings = compiled.warnings;
3076
3077        // Convert QueryValue params (query-layer type) to SqlValue (storage-layer type)
3078        // at the query–storage boundary.
3079        let params: Vec<SqlValue> = compiled
3080            .params
3081            .into_iter()
3082            .map(|qv| match qv {
3083                QueryValue::Null => SqlValue::Null,
3084                QueryValue::Integer(n) => SqlValue::Integer(n),
3085                QueryValue::Float(f) => SqlValue::Float(f),
3086                QueryValue::Text(s) => SqlValue::Text(s),
3087                QueryValue::Blob(b) => SqlValue::Blob(b),
3088            })
3089            .collect();
3090
3091        let mut reader = self.sql().reader().await?;
3092        let stmt = SqlStatement {
3093            sql: compiled.sql,
3094            params,
3095            label: None,
3096        };
3097        let rows = reader.query_all(stmt).await?;
3098        Ok(QueryResult { rows, warnings })
3099    }
3100
3101    /// Delete an entity by ID (soft delete by default).
3102    ///
3103    /// On hard delete, cascades to remove all incident edges (both inbound and
3104    /// outbound) to prevent dangling references. Soft delete also cleans FTS
3105    /// and vector indexes; edges are left in place.
3106    ///
3107    /// Soft-delete or hard-delete an entity by ID.
3108    ///
3109    /// UUID v4 is globally unique — no namespace filter on by-ID ops (ADR-007 rule 2).
3110    pub async fn delete_entity(
3111        &self,
3112        token: &NamespaceToken,
3113        id: Uuid,
3114        hard: bool,
3115    ) -> RuntimeResult<bool> {
3116        let entity = if hard {
3117            match self
3118                .entities(token)?
3119                .get_entity_including_deleted(id)
3120                .await?
3121            {
3122                Some(e) => e,
3123                None => return Ok(false),
3124            }
3125        } else {
3126            match self.entities(token)?.get_entity(id).await? {
3127                Some(e) => e,
3128                None => return Ok(false),
3129            }
3130        };
3131        let mode = if hard {
3132            DeleteMode::Hard
3133        } else {
3134            DeleteMode::Soft
3135        };
3136
3137        // Route cascade and index cleanup through the RECORD's namespace, not the caller's.
3138        let record_tok = NamespaceToken::for_namespace(
3139            khive_types::Namespace::parse(&entity.namespace)
3140                .map_err(|e| RuntimeError::Internal(format!("entity namespace invalid: {e}")))?,
3141        );
3142
3143        // On hard delete, cascade-remove all incident edges (including soft-deleted) to prevent
3144        // dangling refs. Uses purge_incident_edges so that already-soft-deleted edges are also
3145        // removed (ADR-002 no-dangling-references).
3146        if hard {
3147            let graph = self.graph(&record_tok)?;
3148            graph.purge_incident_edges(id).await?;
3149            self.remove_from_indexes(&record_tok, id).await?;
3150        }
3151
3152        let deleted = self.entities(token)?.delete_entity(id, mode).await?;
3153        if !hard && deleted {
3154            self.remove_from_indexes(&record_tok, id).await?;
3155        }
3156        if deleted {
3157            let event_store = self.events(token)?;
3158            let ns = entity.namespace.clone();
3159            let event = khive_storage::event::Event::new(
3160                ns.clone(),
3161                "delete",
3162                EventKind::EntityDeleted,
3163                SubstrateKind::Entity,
3164                "",
3165            )
3166            .with_target(id)
3167            .with_payload(serde_json::json!({"id": id, "namespace": ns, "hard": hard}));
3168            event_store.append_event(event).await.map_err(|e| {
3169                RuntimeError::Internal(format!("delete_entity: event store write failed: {e}"))
3170            })?;
3171        }
3172        Ok(deleted)
3173    }
3174
3175    /// Count entities in a namespace, optionally filtered.
3176    pub async fn count_entities(
3177        &self,
3178        token: &NamespaceToken,
3179        kind: Option<&str>,
3180    ) -> RuntimeResult<u64> {
3181        let filter = EntityFilter {
3182            kinds: match kind {
3183                Some(k) => vec![k.to_string()],
3184                None => vec![],
3185            },
3186            ..Default::default()
3187        };
3188        Ok(self
3189            .entities(token)?
3190            .count_entities(token.namespace().as_str(), filter)
3191            .await?)
3192    }
3193
3194    // ---- Edge CRUD operations ----
3195
3196    /// Fetch a single edge by id.
3197    ///
3198    /// PR-A1: UUID v4 is globally unique — returns the edge regardless of which
3199    /// namespace the token carries. `Ok(None)` means the edge does not exist at all.
3200    pub async fn get_edge(
3201        &self,
3202        _token: &NamespaceToken,
3203        edge_id: Uuid,
3204    ) -> RuntimeResult<Option<Edge>> {
3205        let mut reader = self.sql().reader().await?;
3206        let record_ns = reader
3207            .query_scalar(SqlStatement {
3208                sql: "SELECT namespace FROM graph_edges \
3209                      WHERE id = ?1 AND deleted_at IS NULL LIMIT 1"
3210                    .into(),
3211                params: vec![SqlValue::Text(edge_id.to_string())],
3212                label: Some("get_edge_namespace".into()),
3213            })
3214            .await?;
3215
3216        let Some(SqlValue::Text(record_ns)) = record_ns else {
3217            return Ok(None);
3218        };
3219        // Route the storage fetch through the record's own namespace — the token is
3220        // just the caller context; by-ID ops cross namespace boundaries (ADR-007).
3221        let record_tok = NamespaceToken::for_namespace(
3222            khive_types::Namespace::parse(&record_ns)
3223                .map_err(|e| RuntimeError::Internal(format!("edge namespace invalid: {e}")))?,
3224        );
3225        Ok(self
3226            .graph(&record_tok)?
3227            .get_edge(LinkId::from(edge_id))
3228            .await?)
3229    }
3230
3231    /// Fetch a single edge by id.
3232    ///
3233    /// PR-A1: delegates to `get_edge` — visible-set check removed.  By-ID ops are
3234    /// namespace-agnostic; UUID v4 is globally unique (ADR-007 rule 2).
3235    pub async fn get_edge_visible(
3236        &self,
3237        token: &NamespaceToken,
3238        edge_id: Uuid,
3239    ) -> RuntimeResult<Option<Edge>> {
3240        self.get_edge(token, edge_id).await
3241    }
3242
3243    /// Fetch an edge by UUID including soft-deleted rows.
3244    ///
3245    /// PR-A1: returns the edge regardless of which namespace the token carries —
3246    /// UUID v4 is globally unique. Used by the hard-delete path so that a
3247    /// soft-deleted edge can still be purged via its edge ID.
3248    pub async fn get_edge_including_deleted(
3249        &self,
3250        _token: &NamespaceToken,
3251        edge_id: Uuid,
3252    ) -> RuntimeResult<Option<Edge>> {
3253        let mut reader = self.sql().reader().await?;
3254        let record_ns = reader
3255            .query_scalar(SqlStatement {
3256                sql: "SELECT namespace FROM graph_edges WHERE id = ?1 LIMIT 1".into(),
3257                params: vec![SqlValue::Text(edge_id.to_string())],
3258                label: Some("get_edge_including_deleted_namespace".into()),
3259            })
3260            .await?;
3261
3262        let Some(SqlValue::Text(record_ns)) = record_ns else {
3263            return Ok(None);
3264        };
3265        // Route through the record's own namespace store (no namespace equality check).
3266        let record_tok = NamespaceToken::for_namespace(
3267            khive_types::Namespace::parse(&record_ns)
3268                .map_err(|e| RuntimeError::Internal(format!("edge namespace invalid: {e}")))?,
3269        );
3270        Ok(self
3271            .graph(&record_tok)?
3272            .get_edge_including_deleted(LinkId::from(edge_id))
3273            .await?)
3274    }
3275
3276    /// List edges matching `filter`. `limit` is capped at 1000; defaults to 100.
3277    pub async fn list_edges(
3278        &self,
3279        token: &NamespaceToken,
3280        filter: crate::curation::EdgeListFilter,
3281        limit: u32,
3282    ) -> RuntimeResult<Vec<Edge>> {
3283        let limit = limit.clamp(1, 1000);
3284        let mut results = Vec::new();
3285        for ns in token.visible_namespaces() {
3286            let temp = NamespaceToken::for_namespace(ns.clone());
3287            let page = self
3288                .graph(&temp)?
3289                .query_edges(
3290                    filter.clone().into(),
3291                    vec![SortOrder {
3292                        field: EdgeSortField::CreatedAt,
3293                        direction: khive_storage::types::SortDirection::Asc,
3294                    }],
3295                    PageRequest { offset: 0, limit },
3296                )
3297                .await?;
3298            results.extend(page.items);
3299        }
3300        results.sort_by_key(|e| Uuid::from(e.id));
3301        results.dedup_by_key(|e| Uuid::from(e.id));
3302        results.truncate(limit as usize);
3303        Ok(results)
3304    }
3305
3306    /// Patch-style edge update. Only `Some(_)` fields are applied.
3307    ///
3308    /// When `relation` is `Some(new_rel)`, validates that the edge's existing endpoints
3309    /// are legal for `new_rel` before persisting. Weight-only updates (`relation = None`)
3310    /// skip validation. Returns `InvalidInput` if the new relation would violate the
3311    /// three-case endpoint contract; the edge is NOT mutated on error.
3312    ///
3313    /// For symmetric relations (`competes_with`, `composed_with`), endpoint order is
3314    /// canonicalised to `source_uuid < target_uuid` after validation. If a canonical
3315    /// row already exists at the target triple, the non-canonical edge is deleted and
3316    /// the existing canonical row is refreshed (DELETE + UPDATE pattern, mirroring
3317    /// `merge_entity_sql`).
3318    pub async fn update_edge(
3319        &self,
3320        token: &NamespaceToken,
3321        edge_id: Uuid,
3322        patch: crate::curation::EdgePatch,
3323    ) -> RuntimeResult<Edge> {
3324        // Fetch the edge by UUID — ID-only, no namespace check (PR-A1).
3325        // get_edge already uses the record's stored namespace internally (codex r1 fix).
3326        let graph_for_fetch = self.graph(token)?;
3327        let mut edge = graph_for_fetch
3328            .get_edge(LinkId::from(edge_id))
3329            .await?
3330            .ok_or_else(|| crate::RuntimeError::NotFound(format!("edge {edge_id}")))?;
3331
3332        // PR-A1 (codex r2): after fetching, all mutations and validation must use the
3333        // RECORD's namespace, not the caller's.  Derive record_tok from the stored edge
3334        // namespace so that endpoint validation, raw-SQL predicates, and graph routing
3335        // all address the correct backend partition.
3336        let record_ns: String = edge.namespace.clone();
3337        let record_tok = NamespaceToken::for_namespace(
3338            khive_types::Namespace::parse(&record_ns)
3339                .map_err(|e| RuntimeError::Internal(format!("edge namespace invalid: {e}")))?,
3340        );
3341        let graph = self.graph(&record_tok)?;
3342
3343        let mut changed_fields: Vec<&'static str> = Vec::new();
3344        if let Some(r) = patch.relation {
3345            // Validate before mutating — use the existing endpoints with the new relation.
3346            // Validate before mutating — use the existing endpoints with the new relation.
3347            // Use record_tok so that endpoint existence checks look in the edge's own namespace.
3348            self.validate_edge_relation_endpoints(&record_tok, edge.source_id, edge.target_id, r)
3349                .await?;
3350            edge.relation = r;
3351            changed_fields.push("relation");
3352        }
3353        if let Some(w) = patch.weight {
3354            // Reject non-finite or out-of-range weight explicitly; do not silently
3355            // clamp invalid caller input (coding-standards §608-622).
3356            if !w.is_finite() || !(0.0..=1.0).contains(&w) {
3357                return Err(RuntimeError::InvalidInput(format!(
3358                    "edge weight must be a finite value in [0.0, 1.0]; got {w}"
3359                )));
3360            }
3361            edge.weight = w;
3362            changed_fields.push("weight");
3363        }
3364        if let Some(props) = patch.properties {
3365            edge.metadata = Some(props);
3366        }
3367
3368        // For symmetric relations, canonicalise endpoint order and check
3369        // for natural-key conflicts regardless of whether endpoints were flipped.
3370        //
3371        // The raw-SQL path is used for ALL symmetric relations because `upsert_edge`
3372        // resolves ON CONFLICT(namespace,id) first and cannot detect a duplicate at
3373        // the natural key (namespace, source_id, target_id, relation) with a different
3374        // id. Bug-fix: this path must also run when endpoints are already canonical
3375        // (endpoints_flipped=false) to catch conflicts arising from a relation change
3376        // that collides with an existing canonical row.
3377        let (canon_src, canon_tgt) =
3378            canonical_edge_endpoints(edge.relation, edge.source_id, edge.target_id);
3379
3380        if edge.relation.is_symmetric() {
3381            // Raw-SQL path (mirrors merge_entity_sql).
3382            // Use record_ns (the stored edge namespace) — NOT token.namespace() — so that
3383            // WHERE namespace = ?N predicates match the actual row.
3384            let ns = record_ns.clone();
3385            let edge_id_str = edge_id.to_string();
3386            let relation_str = edge.relation.to_string();
3387            let canon_src_str = canon_src.to_string();
3388            let canon_tgt_str = canon_tgt.to_string();
3389            let weight = edge.weight;
3390            let metadata = edge
3391                .metadata
3392                .as_ref()
3393                .map(|v| serde_json::to_string(v).unwrap_or_default());
3394            let target_backend = edge.target_backend.clone();
3395
3396            let pool = self.backend().pool_arc();
3397
3398            // spawn_blocking returns Some(surviving_id) when a canonical conflict was
3399            // absorbed (the requested edge was deleted, existing canonical row refreshed),
3400            // or None when the requested edge was updated in-place.
3401            let surviving_id: Option<String> = tokio::task::spawn_blocking(move || {
3402                let guard = pool.writer()?;
3403                guard.transaction(|conn| {
3404                    let now_ts = chrono::Utc::now().timestamp();
3405
3406                    // Check for a conflicting canonical row (same namespace + natural key,
3407                    // different id). This catches conflicts whether or not endpoints were
3408                    // flipped — Bug 2 fix.
3409                    let conflict_id: Option<String> = conn
3410                        .query_row(
3411                            "SELECT id FROM graph_edges \
3412                             WHERE namespace = ?1 AND source_id = ?2 AND target_id = ?3 \
3413                             AND relation = ?4 AND id != ?5",
3414                            rusqlite::params![
3415                                &ns,
3416                                &canon_src_str,
3417                                &canon_tgt_str,
3418                                &relation_str,
3419                                &edge_id_str,
3420                            ],
3421                            |row| row.get(0),
3422                        )
3423                        .optional()
3424                        .map_err(SqliteError::Rusqlite)?;
3425
3426                    if let Some(existing_id) = conflict_id {
3427                        // Case (b): canonical row already exists — delete the non-canonical
3428                        // edge and refresh the existing canonical row. Return the surviving
3429                        // id so the caller can re-fetch it (Bug 1 fix: do not return the
3430                        // deleted edge's id).
3431                        conn.execute(
3432                            "DELETE FROM graph_edges WHERE namespace = ?1 AND id = ?2",
3433                            rusqlite::params![&ns, &edge_id_str],
3434                        )
3435                        .map_err(SqliteError::Rusqlite)?;
3436                        let affected = conn
3437                            .execute(
3438                                "UPDATE graph_edges SET \
3439                                 weight = ?1, updated_at = ?2, deleted_at = NULL, \
3440                                 target_backend = ?3, metadata = ?4 \
3441                                 WHERE namespace = ?5 AND id = ?6",
3442                                rusqlite::params![
3443                                    weight,
3444                                    now_ts,
3445                                    target_backend,
3446                                    metadata,
3447                                    &ns,
3448                                    &existing_id,
3449                                ],
3450                            )
3451                            .map_err(SqliteError::Rusqlite)?;
3452                        if affected == 0 {
3453                            return Err(SqliteError::InvalidData(format!(
3454                                "update_edge: surviving canonical row {existing_id} vanished during update"
3455                            )));
3456                        }
3457                        Ok(Some(existing_id))
3458                    } else {
3459                        // Case (a): no conflict — update source_id/target_id in-place,
3460                        // preserving the original edge UUID.
3461                        let affected = conn
3462                            .execute(
3463                                "UPDATE graph_edges SET \
3464                                 source_id = ?1, target_id = ?2, relation = ?3, \
3465                                 weight = ?4, updated_at = ?5, metadata = ?6 \
3466                                 WHERE namespace = ?7 AND id = ?8",
3467                                rusqlite::params![
3468                                    &canon_src_str,
3469                                    &canon_tgt_str,
3470                                    &relation_str,
3471                                    weight,
3472                                    now_ts,
3473                                    metadata,
3474                                    &ns,
3475                                    &edge_id_str,
3476                                ],
3477                            )
3478                            .map_err(SqliteError::Rusqlite)?;
3479                        if affected == 0 {
3480                            // The edge row was not found under the record's namespace.
3481                            // This must never happen because ns = record_ns (fetched above).
3482                            return Err(SqliteError::InvalidData(format!(
3483                                "update_edge: zero rows affected updating edge {edge_id_str} \
3484                                 in namespace {ns} — row vanished between fetch and update"
3485                            )));
3486                        }
3487                        Ok(None)
3488                    }
3489                })
3490            })
3491            .await
3492            .map_err(|e| RuntimeError::Internal(format!("update_edge: spawn_blocking join: {e}")))?
3493            .map_err(RuntimeError::Sqlite)?;
3494
3495            if let Some(sid) = surviving_id {
3496                // A conflict was absorbed: re-fetch the surviving canonical row so the
3497                // caller receives its real id (Bug 1 fix).
3498                // Use record_tok — the surviving row lives in the same namespace as the original.
3499                let surviving_uuid = Uuid::parse_str(&sid).map_err(|e| {
3500                    RuntimeError::Internal(format!("update_edge: surviving id parse failed: {e}"))
3501                })?;
3502                edge = self
3503                    .get_edge(&record_tok, surviving_uuid)
3504                    .await?
3505                    .ok_or_else(|| {
3506                        RuntimeError::Internal(format!(
3507                            "update_edge: surviving canonical row {surviving_uuid} vanished after update"
3508                        ))
3509                    })?;
3510            } else {
3511                // Reflect canonical endpoints in the returned edge (no conflict absorbed).
3512                edge.source_id = canon_src;
3513                edge.target_id = canon_tgt;
3514            }
3515        } else {
3516            // Non-symmetric: upsert_edge takes namespace from edge.namespace (not from the
3517            // graph store's routing namespace), so this is already record-namespace correct.
3518            // `graph` is already self.graph(&record_tok)?.
3519            graph.upsert_edge(edge.clone()).await?;
3520        }
3521
3522        // Audit event: use the record's namespace (record_ns) for the event payload.
3523        let event_store = self.events(&record_tok)?;
3524        let event = khive_storage::event::Event::new(
3525            record_ns.clone(),
3526            "update",
3527            EventKind::EdgeUpdated,
3528            SubstrateKind::Entity,
3529            "",
3530        )
3531        .with_target(edge_id)
3532        .with_payload(
3533            serde_json::json!({"id": edge_id, "namespace": record_ns, "changed_fields": changed_fields}),
3534        );
3535        event_store.append_event(event).await.map_err(|e| {
3536            RuntimeError::Internal(format!("update_edge: event store write failed: {e}"))
3537        })?;
3538
3539        Ok(edge)
3540    }
3541
3542    /// Hard-delete an edge by id.
3543    ///
3544    /// Cascades to remove any `annotates` edges whose target is the deleted edge
3545    /// (`annotates` is note → anything; deleting an edge target leaves annotation
3546    /// edges dangling if not cleaned up). Returns `true` if the primary
3547    /// edge was removed.
3548    ///
3549    /// If `edge_id` does not refer to an edge (e.g. the caller passes an entity or
3550    /// note UUID by mistake), this method returns `Ok(false)` immediately with no
3551    /// side effects — it does **not** cascade inbound edges of the non-edge record.
3552    pub async fn delete_edge(
3553        &self,
3554        token: &NamespaceToken,
3555        edge_id: Uuid,
3556        hard: bool,
3557    ) -> RuntimeResult<bool> {
3558        let mode = if hard {
3559            DeleteMode::Hard
3560        } else {
3561            DeleteMode::Soft
3562        };
3563
3564        // PR-A1: fetch the edge first to obtain the record's own namespace.
3565        // By-ID ops cross namespace boundaries; all graph routing and audit
3566        // events must use the record namespace, not the caller's (mirrors update_edge).
3567        // For hard delete we also check soft-deleted rows so a soft-deleted edge
3568        // can still be purged via its edge ID.
3569        let edge = if hard {
3570            self.get_edge_including_deleted(token, edge_id).await?
3571        } else {
3572            self.get_edge(token, edge_id).await?
3573        };
3574        let Some(edge) = edge else {
3575            return Ok(false);
3576        };
3577
3578        // Derive record_ns / record_tok from the fetched edge (mirrors update_edge at ~2762-2767).
3579        let record_ns: String = edge.namespace.clone();
3580        let record_tok = NamespaceToken::for_namespace(
3581            khive_types::Namespace::parse(&record_ns)
3582                .map_err(|e| RuntimeError::Internal(format!("edge namespace invalid: {e}")))?,
3583        );
3584        let graph = self.graph(&record_tok)?;
3585
3586        // Cascade: on hard delete, remove ALL annotates edges targeting this edge — including
3587        // already-soft-deleted ones — to prevent dangling graph_edges rows (ADR-002).
3588        // On soft delete the cascade is skipped (data-vs-view principle: soft-deleting the base
3589        // edge does not cascade to annotation edges; only a hard purge cleans up incident rows).
3590        if hard {
3591            graph.purge_incident_edges(edge_id).await?;
3592        }
3593
3594        let deleted = graph.delete_edge(LinkId::from(edge_id), mode).await?;
3595        if deleted {
3596            // Audit event: use the record's namespace (record_ns), not the caller's namespace.
3597            let event_store = self.events(&record_tok)?;
3598            let event = khive_storage::event::Event::new(
3599                record_ns.clone(),
3600                "delete",
3601                EventKind::EdgeDeleted,
3602                SubstrateKind::Entity,
3603                "",
3604            )
3605            .with_target(edge_id)
3606            .with_payload(serde_json::json!({"id": edge_id, "namespace": record_ns, "hard": hard}));
3607            event_store.append_event(event).await.map_err(|e| {
3608                RuntimeError::Internal(format!("delete_edge: event store write failed: {e}"))
3609            })?;
3610        }
3611        Ok(deleted)
3612    }
3613
3614    /// Count edges matching `filter`.
3615    pub async fn count_edges(
3616        &self,
3617        token: &NamespaceToken,
3618        filter: crate::curation::EdgeListFilter,
3619    ) -> RuntimeResult<u64> {
3620        Ok(self.graph(token)?.count_edges(filter.into()).await?)
3621    }
3622
3623    /// Validate and construct an edge from a [`LinkSpec`] without writing to storage.
3624    ///
3625    /// Applies the full edge contract (endpoint validation, symmetric
3626    /// canonicalization, `dependency_kind` inference and metadata validation).
3627    /// Returns the constructed `Edge` on success; the caller is responsible for
3628    /// persisting it (e.g. via `upsert_edge` or `link_many`).
3629    ///
3630    /// The `token` must be a pre-authorized namespace token from the dispatch
3631    /// layer. If `spec.namespace` is set it must match `token.namespace()`;
3632    /// a mismatch returns `RuntimeError::InvalidInput`.
3633    pub async fn build_edge(&self, token: &NamespaceToken, spec: &LinkSpec) -> RuntimeResult<Edge> {
3634        let ns_str = match &spec.namespace {
3635            Some(s) => {
3636                let spec_ns = crate::Namespace::parse(s)
3637                    .map_err(|e| RuntimeError::InvalidInput(format!("invalid namespace: {e}")))?;
3638                if &spec_ns != token.namespace() {
3639                    return Err(RuntimeError::InvalidInput(
3640                        "LinkSpec namespace does not match token namespace".into(),
3641                    ));
3642                }
3643                s.as_str()
3644            }
3645            None => token.namespace().as_str(),
3646        };
3647        self.validate_edge_relation_endpoints(token, spec.source_id, spec.target_id, spec.relation)
3648            .await?;
3649        let (source_id, target_id) =
3650            canonical_edge_endpoints(spec.relation, spec.source_id, spec.target_id);
3651        let metadata = if spec.relation == EdgeRelation::DependsOn {
3652            match (
3653                self.resolve(token, source_id).await?,
3654                self.resolve(token, target_id).await?,
3655            ) {
3656                (Some(Resolved::Entity(src_e)), Some(Resolved::Entity(tgt_e))) => {
3657                    merge_dependency_kind(&src_e.kind, &tgt_e.kind, spec.metadata.clone())
3658                }
3659                _ => spec.metadata.clone(),
3660            }
3661        } else {
3662            spec.metadata.clone()
3663        };
3664        validate_edge_metadata(spec.relation, metadata.as_ref())?;
3665        let now = chrono::Utc::now();
3666        Ok(Edge {
3667            id: LinkId::from(Uuid::new_v4()),
3668            namespace: ns_str.to_string(),
3669            source_id,
3670            target_id,
3671            relation: spec.relation,
3672            weight: spec.weight,
3673            created_at: now,
3674            updated_at: now,
3675            deleted_at: None,
3676            metadata,
3677            target_backend: None,
3678        })
3679    }
3680
3681    /// Validate and atomically upsert a batch of edges.
3682    ///
3683    /// All edges are validated and constructed with `build_edge` before any
3684    /// write. If validation fails for any entry the entire batch is rejected
3685    /// (no writes occur). On success, all edges are persisted in a single
3686    /// atomic transaction via `upsert_edges`.
3687    ///
3688    /// After the bulk upsert, each edge is read back by its natural key
3689    /// (namespace, source_id, target_id, relation) so that the returned IDs
3690    /// are always the persisted row IDs, not the locally-generated UUIDs that
3691    /// may have been displaced by an ON CONFLICT DO UPDATE. This mirrors the
3692    /// H1 fix applied to singleton `link()` and prevents phantom-ID exposure
3693    /// when callers upsert overlapping triples with `verbose=true`.
3694    ///
3695    /// All specs must share the same namespace; the namespace is taken from
3696    /// `token` (or validated against it if `spec.namespace` is set).
3697    pub async fn link_many(
3698        &self,
3699        token: &NamespaceToken,
3700        specs: Vec<LinkSpec>,
3701    ) -> RuntimeResult<Vec<Edge>> {
3702        if specs.is_empty() {
3703            return Ok(vec![]);
3704        }
3705        let mut edges = Vec::with_capacity(specs.len());
3706        for spec in &specs {
3707            edges.push(self.build_edge(token, spec).await?);
3708        }
3709        self.graph(token)?.upsert_edges(edges.clone()).await?;
3710
3711        // H1-bulk fix: read back each persisted edge by natural key so callers
3712        // always receive the stored row ID, not the pre-upsert generated UUID.
3713        let mut persisted = Vec::with_capacity(edges.len());
3714        for edge in &edges {
3715            let row = self
3716                .list_edges(
3717                    token,
3718                    crate::curation::EdgeListFilter {
3719                        source_id: Some(edge.source_id),
3720                        target_id: Some(edge.target_id),
3721                        relations: vec![edge.relation],
3722                        ..Default::default()
3723                    },
3724                    1,
3725                )
3726                .await?
3727                .into_iter()
3728                .next()
3729                .ok_or_else(|| {
3730                    crate::RuntimeError::Internal(format!(
3731                        "upsert_edges succeeded but natural-key lookup for ({}, {}, {}) returned nothing",
3732                        edge.source_id, edge.target_id, edge.relation.as_str()
3733                    ))
3734                })?;
3735            persisted.push(row);
3736        }
3737        Ok(persisted)
3738    }
3739
3740    /// Create a batch of entities atomically.
3741    ///
3742    /// All specs are validated before any write. If ANY spec fails validation
3743    /// (unknown kind, empty name, secret-gate violation), the method returns
3744    /// that error and no entities are written.
3745    ///
3746    /// Storage writes are issued as ONE `upsert_entities` call followed by ONE
3747    /// `upsert_documents` call — the same primitives that the single-entity path
3748    /// uses, but batched. Embedding is intentionally skipped: bulk structural
3749    /// ingest is the expected use-case, and dense vectors are backfilled later
3750    /// via a `reindex` call. Callers that need immediate vector search
3751    /// immediately after creation should use per-entity `create_entity` instead.
3752    ///
3753    /// On FTS failure, every newly written entity row is hard-deleted to maintain
3754    /// consistency (mirrors the single-entity rollback in `create_entity`).
3755    pub async fn create_many(
3756        &self,
3757        token: &NamespaceToken,
3758        specs: Vec<EntityCreateSpec>,
3759    ) -> RuntimeResult<Vec<Entity>> {
3760        if specs.is_empty() {
3761            return Ok(vec![]);
3762        }
3763        let ns = token.namespace().as_str();
3764
3765        // Phase 1: validate ALL specs before any write (ADR-004).
3766        // Includes entity-type validation via the pack-installed validator when available.
3767        // Any validation failure here guarantees zero rows are written.
3768        let mut entities = Vec::with_capacity(specs.len());
3769        for spec in &specs {
3770            self.validate_entity_kind(&spec.kind)?;
3771            // High-2: validate entity_type at the runtime layer via pack-installed callback.
3772            // When no validator is installed (bare runtime, unit tests without packs),
3773            // the type passes through unchanged — same skip-when-absent pattern as
3774            // validate_entity_kind. The handler layer remains the primary enforcement point.
3775            let validated_type =
3776                self.validate_entity_type_for_kind(&spec.kind, spec.entity_type.as_deref())?;
3777            if spec.name.trim().is_empty() {
3778                return Err(RuntimeError::InvalidInput("name must not be empty".into()));
3779            }
3780            crate::secret_gate::check(&spec.name)?;
3781            if let Some(d) = &spec.description {
3782                crate::secret_gate::check(d)?;
3783            }
3784            if let Some(ref p) = spec.properties {
3785                crate::secret_gate::check_json(p)?;
3786            }
3787            crate::secret_gate::check_tags(&spec.tags)?;
3788
3789            let mut entity =
3790                Entity::new(ns, &spec.kind, &spec.name).with_entity_type(validated_type.as_deref());
3791            if let Some(d) = &spec.description {
3792                entity = entity.with_description(d);
3793            }
3794            if let Some(p) = spec.properties.clone() {
3795                entity = entity.with_properties(p);
3796            }
3797            if !spec.tags.is_empty() {
3798                entity = entity.with_tags(spec.tags.clone());
3799            }
3800            entities.push(entity);
3801        }
3802
3803        // Phase 2: single bulk entity write.
3804        // High-1: capture the BatchWriteSummary to detect partial failures.
3805        // The store commits the transaction even when some rows fail (per-row error
3806        // isolation). If any row failed, compensate by hard-deleting the rows that DID
3807        // land, then return Err so the caller sees zero net writes.
3808        //
3809        // NOTE: this compensation path (delete-on-partial-failure) is a stopgap until
3810        // a true single-transaction bulk primitive is available in the entity store.
3811        // That primitive (writing entity rows and FTS rows in one SQL transaction) is
3812        // tracked as a follow-up issue.
3813        let entity_summary = self
3814            .entities(token)?
3815            .upsert_entities(entities.clone())
3816            .await?;
3817
3818        if entity_summary.failed > 0 {
3819            // Compensate: hard-delete any entity rows that did land.
3820            if let Ok(store) = self.entities(token) {
3821                for entity in &entities {
3822                    if let Err(ce) = store.delete_entity(entity.id, DeleteMode::Hard).await {
3823                        tracing::error!(
3824                            error = %ce,
3825                            id = %entity.id,
3826                            "create_many: failed to roll back entity row after partial entity write"
3827                        );
3828                    }
3829                }
3830            }
3831            return Err(RuntimeError::Internal(format!(
3832                "create_many: {}/{} entity rows failed to write (first error: {}); \
3833                 all rows rolled back",
3834                entity_summary.failed, entity_summary.attempted, entity_summary.first_error
3835            )));
3836        }
3837
3838        // Phase 3: single bulk FTS write.
3839        //
3840        // The FTS store commits partial batches and signals per-document failures
3841        // via BatchWriteSummary.failed (same as the entity store in Phase 2).
3842        // We must capture the summary and treat failed > 0 as an error.
3843        //
3844        // Compensation is symmetric: on any FTS failure (Err or failed > 0),
3845        // we first delete any FTS documents that may have landed, then
3846        // hard-delete the entity rows.  This order matters: the entity delete
3847        // is the authoritative write; FTS is a derived index.  Cleaning FTS
3848        // first avoids a window where entity rows are gone but stale FTS rows
3849        // survive.
3850        let docs: Vec<_> = entities.iter().map(entity_fts_document).collect();
3851
3852        #[cfg(any(test, feature = "fault-injection"))]
3853        let fts_many_inject = {
3854            let mut g = FTS_FAIL_MANY_NS.lock().unwrap();
3855            if g.as_deref() == Some(ns) {
3856                *g = None;
3857                true
3858            } else {
3859                false
3860            }
3861        };
3862        #[cfg(not(any(test, feature = "fault-injection")))]
3863        let fts_many_inject = false;
3864
3865        // Partial-failure seam: returns Ok(summary) with failed > 0 so the
3866        // `summary.failed > 0` rollback branch is exercised in tests.
3867        #[cfg(any(test, feature = "fault-injection"))]
3868        let fts_many_inject_partial = {
3869            let mut g = FTS_FAIL_MANY_PARTIAL_NS.lock().unwrap();
3870            if g.as_deref() == Some(ns) {
3871                *g = None;
3872                true
3873            } else {
3874                false
3875            }
3876        };
3877        #[cfg(not(any(test, feature = "fault-injection")))]
3878        let fts_many_inject_partial = false;
3879
3880        let fts_summary_result: RuntimeResult<BatchWriteSummary> = if fts_many_inject {
3881            Err(RuntimeError::Internal(
3882                "injected FTS failure for create_many".to_string(),
3883            ))
3884        } else if fts_many_inject_partial {
3885            Ok(BatchWriteSummary {
3886                attempted: docs.len() as u64,
3887                affected: docs.len().saturating_sub(1) as u64,
3888                failed: 1,
3889                first_error: "injected partial FTS failure for create_many".to_string(),
3890            })
3891        } else {
3892            match self.text(token) {
3893                Ok(fts) => fts.upsert_documents(docs).await.map_err(RuntimeError::from),
3894                Err(e) => Err(e),
3895            }
3896        };
3897
3898        let fts_err: Option<RuntimeError> = match fts_summary_result {
3899            Err(e) => Some(e),
3900            Ok(summary) if summary.failed > 0 => Some(RuntimeError::Internal(format!(
3901                "create_many: {}/{} FTS rows failed to index (first error: {}); \
3902                 all rows rolled back",
3903                summary.failed, summary.attempted, summary.first_error
3904            ))),
3905            Ok(_) => None,
3906        };
3907
3908        if let Some(e) = fts_err {
3909            // Clean up any FTS docs that landed before deleting entity rows.
3910            if let Ok(fts) = self.text(token) {
3911                for entity in &entities {
3912                    if let Err(ce) = fts.delete_document(ns, entity.id).await {
3913                        tracing::error!(
3914                            error = %ce,
3915                            id = %entity.id,
3916                            "create_many: failed to remove FTS doc during rollback"
3917                        );
3918                    }
3919                }
3920            }
3921            if let Ok(store) = self.entities(token) {
3922                for entity in &entities {
3923                    if let Err(ce) = store.delete_entity(entity.id, DeleteMode::Hard).await {
3924                        tracing::error!(
3925                            error = %ce,
3926                            id = %entity.id,
3927                            "create_many: failed to roll back entity row after FTS failure"
3928                        );
3929                    }
3930                }
3931            }
3932            return Err(e);
3933        }
3934
3935        // Embedding is skipped intentionally — see doc comment above.
3936        Ok(entities)
3937    }
3938}
3939
3940/// Fully specified edge creation request — input to [`KhiveRuntime::build_edge`]
3941/// and [`KhiveRuntime::link_many`].
3942#[derive(Clone, Debug)]
3943pub struct LinkSpec {
3944    pub namespace: Option<String>,
3945    pub source_id: Uuid,
3946    pub target_id: Uuid,
3947    pub relation: EdgeRelation,
3948    pub weight: f64,
3949    pub metadata: Option<serde_json::Value>,
3950}
3951
3952/// Fully specified entity creation request — input to [`KhiveRuntime::create_many`].
3953///
3954/// `entity_type` is validated at the runtime layer by the pack-installed
3955/// entity-type validator (ADR-004 §runtime-layer validation). When a validator
3956/// is installed (e.g. by `KgPack`), unknown types are rejected with the valid
3957/// set listed. When no validator is installed (bare runtime without packs),
3958/// the value passes through — the handler layer is the primary enforcement point.
3959#[derive(Clone, Debug)]
3960pub struct EntityCreateSpec {
3961    pub kind: String,
3962    pub entity_type: Option<String>,
3963    pub name: String,
3964    pub description: Option<String>,
3965    pub properties: Option<serde_json::Value>,
3966    pub tags: Vec<String>,
3967}
3968
3969// INLINE TEST JUSTIFICATION: tests here exercise private helpers (canonical_edge_endpoints,
3970// validate_edge_metadata, merge_dependency_kind, link-fail injection) and runtime methods
3971// that require pub(crate) KhiveRuntime construction. Moving them to tests/ would require
3972// pub-exporting those private helpers, which would widen the crate's public API surface
3973// undesirably. Broad behavioral tests live in tests/integration.rs.
3974#[cfg(test)]
3975mod tests {
3976    use super::*;
3977    use crate::curation::EdgeListFilter;
3978    use crate::embedder_registry::EmbedderProvider;
3979    use crate::error::RuntimeError;
3980    use crate::runtime::{KhiveRuntime, NamespaceToken};
3981    use crate::Namespace;
3982    use async_trait::async_trait;
3983    use khive_storage::types::PathNode;
3984    use lattice_embed::{EmbedError, EmbeddingModel, EmbeddingService};
3985    use std::sync::atomic::{AtomicUsize, Ordering};
3986    use std::sync::Arc;
3987
3988    fn rt() -> KhiveRuntime {
3989        KhiveRuntime::memory().unwrap()
3990    }
3991
3992    // ── Fix-1 regression (codex High #1, PR #444) ────────────────────────────
3993    // A runtime with no `config.embedding_model` but a custom registered
3994    // embedder must fan out create_note through that embedder and store a
3995    // vector so recall can find the note.
3996
3997    /// Trivial constant-vector embedding service.  The model argument is ignored;
3998    /// the service always returns a synthetic `dims × 1.0f32` vector.
3999    struct ConstVecService {
4000        dims: usize,
4001    }
4002
4003    #[async_trait]
4004    impl EmbeddingService for ConstVecService {
4005        async fn embed(
4006            &self,
4007            texts: &[String],
4008            _model: EmbeddingModel,
4009        ) -> std::result::Result<Vec<Vec<f32>>, EmbedError> {
4010            Ok(texts.iter().map(|_| vec![1.0_f32; self.dims]).collect())
4011        }
4012
4013        fn supports_model(&self, _model: EmbeddingModel) -> bool {
4014            true
4015        }
4016
4017        fn name(&self) -> &'static str {
4018            "const-vec"
4019        }
4020    }
4021
4022    struct ConstVecProvider {
4023        provider_name: String,
4024        dims: usize,
4025        pub build_count: Arc<AtomicUsize>,
4026    }
4027
4028    impl ConstVecProvider {
4029        fn new(name: &str, dims: usize) -> (Self, Arc<AtomicUsize>) {
4030            let counter = Arc::new(AtomicUsize::new(0));
4031            let provider = Self {
4032                provider_name: name.to_owned(),
4033                dims,
4034                build_count: Arc::clone(&counter),
4035            };
4036            (provider, counter)
4037        }
4038    }
4039
4040    #[async_trait]
4041    impl EmbedderProvider for ConstVecProvider {
4042        fn name(&self) -> &str {
4043            &self.provider_name
4044        }
4045
4046        fn dimensions(&self) -> usize {
4047            self.dims
4048        }
4049
4050        async fn build(&self) -> crate::error::RuntimeResult<Arc<dyn EmbeddingService>> {
4051            self.build_count.fetch_add(1, Ordering::SeqCst);
4052            Ok(Arc::new(ConstVecService { dims: self.dims }))
4053        }
4054    }
4055
4056    /// Fix 1 regression: custom embedder with no lattice model in config must
4057    /// participate in fan-out.
4058    ///
4059    /// This test was previously broken because the fan-out gate checked
4060    /// `config().embedding_model.is_some()`.  With only a custom provider
4061    /// registered and `embedding_model = None` in config, the gate fell through
4062    /// to `vec![]` and no vector was written.  After the fix the gate checks
4063    /// `registered_embedding_model_names()` instead.
4064    #[tokio::test]
4065    async fn custom_embedder_only_runtime_fanout_stores_vector() {
4066        const MODEL_NAME: &str = "test-custom-encoder";
4067        const DIMS: usize = 8;
4068
4069        // Build a runtime with no lattice embedding_model.
4070        let rt = KhiveRuntime::memory().unwrap();
4071
4072        // Register the custom provider — this is the only embedder configured.
4073        let (provider, _counter) = ConstVecProvider::new(MODEL_NAME, DIMS);
4074        rt.register_embedder(provider);
4075
4076        // Sanity: config.embedding_model is None, but the registry has one entry.
4077        assert!(rt.config().embedding_model.is_none());
4078        assert_eq!(rt.registered_embedding_model_names(), vec![MODEL_NAME]);
4079
4080        let tok = NamespaceToken::local();
4081
4082        // create_note should fan out to the custom embedder and store a vector.
4083        let note = rt
4084            .create_note(
4085                &tok,
4086                "memory",
4087                None,
4088                "custom embedder integration test content",
4089                Some(0.7),
4090                None,
4091                vec![],
4092            )
4093            .await
4094            .expect("create_note with custom-only embedder must succeed");
4095
4096        // Verify: a vector was written in the custom model's store.
4097        use khive_storage::types::VectorSearchRequest;
4098        let query_vec = vec![1.0_f32; DIMS];
4099        let hits = rt
4100            .vectors_for_model(&tok, MODEL_NAME)
4101            .expect("vector store for custom model must be accessible")
4102            .search(VectorSearchRequest {
4103                query_vectors: vec![query_vec],
4104                top_k: 5,
4105                namespace: Some(tok.namespace().as_str().to_string()),
4106                kind: Some(khive_types::SubstrateKind::Note),
4107                embedding_model: Some(MODEL_NAME.to_string()),
4108                filter: None,
4109                backend_hints: None,
4110            })
4111            .await
4112            .expect("vector search succeeds");
4113
4114        assert!(
4115            hits.iter().any(|h| h.subject_id == note.id),
4116            "custom embedder must have written a vector for note {}: hits={hits:?}",
4117            note.id
4118        );
4119    }
4120
4121    /// Fix 1 regression (recall path): custom-only embedder participates in
4122    /// embed_with_model so recall fan-out also works.
4123    ///
4124    /// Previously `embed_with_model` called `resolve_embedding_model` which
4125    /// required a lattice alias; custom provider names were rejected with
4126    /// `UnknownModel`.  After the fix, the lattice alias parse is optional
4127    /// and the embedder registry is consulted directly.
4128    #[tokio::test]
4129    async fn embed_with_model_accepts_custom_provider_name() {
4130        const MODEL_NAME: &str = "my-custom-enc";
4131        const DIMS: usize = 4;
4132
4133        let rt = KhiveRuntime::memory().unwrap();
4134        let (provider, _counter) = ConstVecProvider::new(MODEL_NAME, DIMS);
4135        rt.register_embedder(provider);
4136
4137        let result = rt
4138            .embed_with_model(MODEL_NAME, "hello world")
4139            .await
4140            .expect("embed_with_model must accept custom provider names");
4141
4142        assert_eq!(
4143            result.len(),
4144            DIMS,
4145            "embedding dimension must match provider"
4146        );
4147        assert!(
4148            result.iter().all(|&v| (v - 1.0_f32).abs() < 1e-6),
4149            "ConstVecService must produce all-ones vector; got: {result:?}"
4150        );
4151    }
4152
4153    /// Fix 1 regression: embed_with_model must still reject names that are not
4154    /// in the registry (neither lattice aliases nor custom providers).
4155    #[tokio::test]
4156    async fn embed_with_model_rejects_unregistered_name() {
4157        let rt = KhiveRuntime::memory().unwrap();
4158        let result = rt.embed_with_model("nonexistent-model", "hello").await;
4159        assert!(
4160            matches!(result.unwrap_err(), RuntimeError::UnknownModel(ref n) if n == "nonexistent-model"),
4161            "unregistered model name must return UnknownModel"
4162        );
4163    }
4164
4165    #[tokio::test]
4166    async fn update_edge_changes_weight() {
4167        let rt = rt();
4168        let tok = NamespaceToken::local();
4169        let a = rt
4170            .create_entity(&tok, "concept", None, "A", None, None, vec![])
4171            .await
4172            .unwrap();
4173        let b = rt
4174            .create_entity(&tok, "concept", None, "B", None, None, vec![])
4175            .await
4176            .unwrap();
4177        let edge = rt
4178            .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
4179            .await
4180            .unwrap();
4181        let edge_id: Uuid = edge.id.into();
4182
4183        let updated = rt
4184            .update_edge(
4185                &tok,
4186                edge_id,
4187                crate::curation::EdgePatch {
4188                    weight: Some(0.5),
4189                    ..Default::default()
4190                },
4191            )
4192            .await
4193            .unwrap();
4194        assert!((updated.weight - 0.5).abs() < 0.001);
4195    }
4196
4197    #[tokio::test]
4198    async fn update_edge_changes_relation() {
4199        let rt = rt();
4200        let tok = NamespaceToken::local();
4201        let a = rt
4202            .create_entity(&tok, "concept", None, "A", None, None, vec![])
4203            .await
4204            .unwrap();
4205        let b = rt
4206            .create_entity(&tok, "concept", None, "B", None, None, vec![])
4207            .await
4208            .unwrap();
4209        let edge = rt
4210            .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
4211            .await
4212            .unwrap();
4213        let edge_id: Uuid = edge.id.into();
4214
4215        let updated = rt
4216            .update_edge(
4217                &tok,
4218                edge_id,
4219                crate::curation::EdgePatch {
4220                    relation: Some(EdgeRelation::VariantOf),
4221                    ..Default::default()
4222                },
4223            )
4224            .await
4225            .unwrap();
4226        assert_eq!(updated.relation, EdgeRelation::VariantOf);
4227    }
4228
4229    // ---- Round-5 tests: update_edge endpoint validation (bypass fix) ----
4230
4231    // update_edge: note→entity annotates → set relation=Supersedes → InvalidInput (crossing).
4232    // Edge must NOT be mutated in the store.
4233    #[tokio::test]
4234    async fn update_edge_annotates_note_to_entity_set_supersedes_returns_invalid_input() {
4235        let rt = rt();
4236        let tok = NamespaceToken::local();
4237        let note = rt
4238            .create_note(&tok, "observation", None, "a note", Some(0.5), None, vec![])
4239            .await
4240            .unwrap();
4241        let entity = rt
4242            .create_entity(&tok, "concept", None, "E", None, None, vec![])
4243            .await
4244            .unwrap();
4245        // Create a valid note→entity annotates edge.
4246        let edge = rt
4247            .link(&tok, note.id, entity.id, EdgeRelation::Annotates, 1.0, None)
4248            .await
4249            .unwrap();
4250        let edge_id: Uuid = edge.id.into();
4251
4252        // Attempt to change relation to Supersedes (crossing substrates → invalid).
4253        let result = rt
4254            .update_edge(
4255                &tok,
4256                edge_id,
4257                crate::curation::EdgePatch {
4258                    relation: Some(EdgeRelation::Supersedes),
4259                    ..Default::default()
4260                },
4261            )
4262            .await;
4263        assert!(
4264            matches!(result, Err(RuntimeError::InvalidInput(_))),
4265            "update to Supersedes on note→entity edge must return InvalidInput, got {result:?}"
4266        );
4267
4268        // Edge must NOT be mutated — re-fetch and verify relation unchanged.
4269        let fetched = rt.get_edge(&tok, edge_id).await.unwrap().unwrap();
4270        assert_eq!(
4271            fetched.relation,
4272            EdgeRelation::Annotates,
4273            "edge relation must be unchanged after failed update"
4274        );
4275    }
4276
4277    // update_edge: entity→entity extends → set relation=Annotates → InvalidInput
4278    // (annotates source must be a note).
4279    #[tokio::test]
4280    async fn update_edge_entity_to_entity_set_annotates_returns_invalid_input() {
4281        let rt = rt();
4282        let tok = NamespaceToken::local();
4283        let a = rt
4284            .create_entity(&tok, "concept", None, "A", None, None, vec![])
4285            .await
4286            .unwrap();
4287        let b = rt
4288            .create_entity(&tok, "concept", None, "B", None, None, vec![])
4289            .await
4290            .unwrap();
4291        let edge = rt
4292            .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
4293            .await
4294            .unwrap();
4295        let edge_id: Uuid = edge.id.into();
4296
4297        let result = rt
4298            .update_edge(
4299                &tok,
4300                edge_id,
4301                crate::curation::EdgePatch {
4302                    relation: Some(EdgeRelation::Annotates),
4303                    ..Default::default()
4304                },
4305            )
4306            .await;
4307        assert!(
4308            matches!(result, Err(RuntimeError::InvalidInput(_))),
4309            "update to Annotates on entity→entity edge must return InvalidInput, got {result:?}"
4310        );
4311    }
4312
4313    // update_edge: entity→entity extends → set relation=Supersedes → Ok
4314    // (entity→entity is valid for supersedes).
4315    #[tokio::test]
4316    async fn update_edge_entity_to_entity_set_supersedes_succeeds() {
4317        let rt = rt();
4318        let tok = NamespaceToken::local();
4319        let a = rt
4320            .create_entity(&tok, "concept", None, "A", None, None, vec![])
4321            .await
4322            .unwrap();
4323        let b = rt
4324            .create_entity(&tok, "concept", None, "B", None, None, vec![])
4325            .await
4326            .unwrap();
4327        let edge = rt
4328            .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
4329            .await
4330            .unwrap();
4331        let edge_id: Uuid = edge.id.into();
4332
4333        let updated = rt
4334            .update_edge(
4335                &tok,
4336                edge_id,
4337                crate::curation::EdgePatch {
4338                    relation: Some(EdgeRelation::Supersedes),
4339                    ..Default::default()
4340                },
4341            )
4342            .await
4343            .unwrap();
4344        assert_eq!(updated.relation, EdgeRelation::Supersedes);
4345
4346        // Verify persisted.
4347        let fetched = rt.get_edge(&tok, edge_id).await.unwrap().unwrap();
4348        assert_eq!(fetched.relation, EdgeRelation::Supersedes);
4349    }
4350
4351    // update_edge: weight-only (relation = None) → Ok, no validation, unchanged relation.
4352    #[tokio::test]
4353    async fn update_edge_weight_only_skips_validation() {
4354        let rt = rt();
4355        let tok = NamespaceToken::local();
4356        let a = rt
4357            .create_entity(&tok, "concept", None, "A", None, None, vec![])
4358            .await
4359            .unwrap();
4360        let b = rt
4361            .create_entity(&tok, "concept", None, "B", None, None, vec![])
4362            .await
4363            .unwrap();
4364        let edge = rt
4365            .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
4366            .await
4367            .unwrap();
4368        let edge_id: Uuid = edge.id.into();
4369
4370        let updated = rt
4371            .update_edge(
4372                &tok,
4373                edge_id,
4374                crate::curation::EdgePatch {
4375                    weight: Some(0.3),
4376                    ..Default::default()
4377                },
4378            )
4379            .await
4380            .unwrap();
4381        assert_eq!(updated.relation, EdgeRelation::Extends);
4382        assert!((updated.weight - 0.3).abs() < 0.001);
4383    }
4384
4385    // update_edge: entity→entity extends → set relation=VariantOf (same class) → Ok.
4386    #[tokio::test]
4387    async fn update_edge_same_class_relation_change_succeeds() {
4388        let rt = rt();
4389        let tok = NamespaceToken::local();
4390        let a = rt
4391            .create_entity(&tok, "concept", None, "A", None, None, vec![])
4392            .await
4393            .unwrap();
4394        let b = rt
4395            .create_entity(&tok, "concept", None, "B", None, None, vec![])
4396            .await
4397            .unwrap();
4398        let edge = rt
4399            .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
4400            .await
4401            .unwrap();
4402        let edge_id: Uuid = edge.id.into();
4403
4404        let updated = rt
4405            .update_edge(
4406                &tok,
4407                edge_id,
4408                crate::curation::EdgePatch {
4409                    relation: Some(EdgeRelation::VariantOf),
4410                    ..Default::default()
4411                },
4412            )
4413            .await
4414            .unwrap();
4415        assert_eq!(updated.relation, EdgeRelation::VariantOf);
4416    }
4417
4418    #[tokio::test]
4419    async fn list_edges_filters_by_relation() {
4420        let rt = rt();
4421        let tok = NamespaceToken::local();
4422        let a = rt
4423            .create_entity(&tok, "concept", None, "A", None, None, vec![])
4424            .await
4425            .unwrap();
4426        let b = rt
4427            .create_entity(&tok, "concept", None, "B", None, None, vec![])
4428            .await
4429            .unwrap();
4430        let c = rt
4431            .create_entity(&tok, "concept", None, "C", None, None, vec![])
4432            .await
4433            .unwrap();
4434
4435        rt.link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
4436            .await
4437            .unwrap();
4438        rt.link(&tok, a.id, c.id, EdgeRelation::Enables, 1.0, None)
4439            .await
4440            .unwrap();
4441
4442        let filter = EdgeListFilter {
4443            relations: vec![EdgeRelation::Extends],
4444            ..Default::default()
4445        };
4446        let edges = rt.list_edges(&tok, filter, 100).await.unwrap();
4447        assert_eq!(edges.len(), 1);
4448        assert_eq!(edges[0].relation, EdgeRelation::Extends);
4449    }
4450
4451    #[tokio::test]
4452    async fn list_edges_filters_by_source() {
4453        let rt = rt();
4454        let tok = NamespaceToken::local();
4455        let a = rt
4456            .create_entity(&tok, "concept", None, "A", None, None, vec![])
4457            .await
4458            .unwrap();
4459        let b = rt
4460            .create_entity(&tok, "concept", None, "B", None, None, vec![])
4461            .await
4462            .unwrap();
4463        let c = rt
4464            .create_entity(&tok, "concept", None, "C", None, None, vec![])
4465            .await
4466            .unwrap();
4467        let d = rt
4468            .create_entity(&tok, "concept", None, "D", None, None, vec![])
4469            .await
4470            .unwrap();
4471
4472        rt.link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
4473            .await
4474            .unwrap();
4475        rt.link(&tok, c.id, d.id, EdgeRelation::Extends, 1.0, None)
4476            .await
4477            .unwrap();
4478
4479        let filter = EdgeListFilter {
4480            source_id: Some(a.id),
4481            ..Default::default()
4482        };
4483        let edges = rt.list_edges(&tok, filter, 100).await.unwrap();
4484        assert_eq!(edges.len(), 1);
4485        let src: Uuid = edges[0].source_id;
4486        assert_eq!(src, a.id);
4487    }
4488
4489    #[tokio::test]
4490    async fn delete_edge_removes_from_storage() {
4491        let rt = rt();
4492        let tok = NamespaceToken::local();
4493        let a = rt
4494            .create_entity(&tok, "concept", None, "A", None, None, vec![])
4495            .await
4496            .unwrap();
4497        let b = rt
4498            .create_entity(&tok, "concept", None, "B", None, None, vec![])
4499            .await
4500            .unwrap();
4501        let edge = rt
4502            .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
4503            .await
4504            .unwrap();
4505        let edge_id: Uuid = edge.id.into();
4506
4507        let deleted = rt.delete_edge(&tok, edge_id, true).await.unwrap();
4508        assert!(deleted);
4509
4510        let fetched = rt.get_edge(&tok, edge_id).await.unwrap();
4511        assert!(fetched.is_none(), "edge should be gone after delete");
4512    }
4513
4514    #[tokio::test]
4515    async fn count_edges_matches_filter() {
4516        let rt = rt();
4517        let tok = NamespaceToken::local();
4518        let a = rt
4519            .create_entity(&tok, "concept", None, "A", None, None, vec![])
4520            .await
4521            .unwrap();
4522        let b = rt
4523            .create_entity(&tok, "concept", None, "B", None, None, vec![])
4524            .await
4525            .unwrap();
4526        let c = rt
4527            .create_entity(&tok, "concept", None, "C", None, None, vec![])
4528            .await
4529            .unwrap();
4530
4531        rt.link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
4532            .await
4533            .unwrap();
4534        rt.link(&tok, a.id, c.id, EdgeRelation::Enables, 1.0, None)
4535            .await
4536            .unwrap();
4537
4538        let all = rt
4539            .count_edges(&tok, EdgeListFilter::default())
4540            .await
4541            .unwrap();
4542        assert_eq!(all, 2);
4543
4544        let just_extends = rt
4545            .count_edges(
4546                &tok,
4547                EdgeListFilter {
4548                    relations: vec![EdgeRelation::Extends],
4549                    ..Default::default()
4550                },
4551            )
4552            .await
4553            .unwrap();
4554        assert_eq!(just_extends, 1);
4555    }
4556
4557    // ---- Finding 4 regression: substrate_exists_in_ns must use get_edge_visible ----
4558
4559    /// An edge owned by a visible (non-primary) namespace must be found by
4560    /// `substrate_exists_in_ns` and therefore usable as a graph root in
4561    /// `neighbors` and `traverse`.
4562    #[tokio::test]
4563    async fn edge_in_visible_namespace_reachable_as_graph_root() {
4564        let rt = rt();
4565        let ns_a = Namespace::parse("vis-edge-a").unwrap();
4566        let ns_b = Namespace::parse("vis-edge-b").unwrap();
4567
4568        // Create two entities and an edge in namespace B.
4569        let tok_b = NamespaceToken::for_namespace(ns_b.clone());
4570        let src = rt
4571            .create_entity(&tok_b, "concept", None, "SrcB", None, None, vec![])
4572            .await
4573            .unwrap();
4574        let tgt = rt
4575            .create_entity(&tok_b, "concept", None, "TgtB", None, None, vec![])
4576            .await
4577            .unwrap();
4578        let edge = rt
4579            .link(&tok_b, src.id, tgt.id, EdgeRelation::Extends, 1.0, None)
4580            .await
4581            .unwrap();
4582
4583        // Namespace A with B in its visible set should be able to get the
4584        // edge and use it as a traverse root.
4585        let tok_a_vis = rt
4586            .authorize_with_visibility(ns_a.clone(), vec![ns_b.clone()])
4587            .unwrap();
4588
4589        // Direct get of the edge must succeed (visible namespace).
4590        let got = rt.get_edge_visible(&tok_a_vis, edge.id.0).await.unwrap();
4591        assert!(
4592            got.is_some(),
4593            "edge in visible namespace must be retrievable via get_edge_visible"
4594        );
4595
4596        // neighbors/traverse use substrate_exists_in_ns which now calls
4597        // get_edge_visible — they must not return empty for a visible-ns edge root.
4598        let neighbors = rt
4599            .neighbors(&tok_a_vis, src.id, Direction::Out, Some(16), None)
4600            .await
4601            .unwrap();
4602        assert!(
4603            neighbors.iter().any(|h| h.node_id == tgt.id),
4604            "neighbors of visible-ns node must include its visible-ns neighbor; got: {neighbors:?}"
4605        );
4606    }
4607
4608    // ADR-007 PR-A1: by-ID ops no longer enforce namespace isolation.
4609    // Shared-brain OSS model: UUID is globally unique; get/update/delete
4610    // find the record regardless of caller's token namespace.
4611    #[tokio::test]
4612    async fn get_entity_cross_namespace_no_longer_denied() {
4613        let rt = rt();
4614        let ns_a = NamespaceToken::for_namespace(Namespace::parse("ns-a").unwrap());
4615        let ns_b = NamespaceToken::for_namespace(Namespace::parse("ns-b").unwrap());
4616        let entity = rt
4617            .create_entity(&ns_a, "concept", None, "Alpha", None, None, vec![])
4618            .await
4619            .unwrap();
4620
4621        // Same namespace: still works.
4622        let found = rt.get_entity(&ns_a, entity.id).await;
4623        assert!(found.is_ok(), "same-namespace get must succeed");
4624
4625        // Different namespace: now also returns the entity (shared brain, ADR-007).
4626        let cross = rt.get_entity(&ns_b, entity.id).await;
4627        assert!(
4628            cross.is_ok(),
4629            "cross-namespace get must succeed in shared-brain OSS (ADR-007 rule 2)"
4630        );
4631        assert_eq!(cross.unwrap().id, entity.id);
4632    }
4633
4634    #[tokio::test]
4635    async fn delete_entity_cross_namespace_no_longer_denied() {
4636        let rt = rt();
4637        let ns_a = NamespaceToken::for_namespace(Namespace::parse("ns-a").unwrap());
4638        let ns_b = NamespaceToken::for_namespace(Namespace::parse("ns-b").unwrap());
4639        let entity = rt
4640            .create_entity(&ns_a, "concept", None, "Beta", None, None, vec![])
4641            .await
4642            .unwrap();
4643
4644        // ADR-007 PR-A1: cross-namespace delete now succeeds (shared brain).
4645        let cross_ns_result = rt.delete_entity(&ns_b, entity.id, true).await;
4646        assert!(
4647            cross_ns_result.is_ok(),
4648            "cross-namespace delete must succeed in shared-brain OSS; got {:?}",
4649            cross_ns_result
4650        );
4651        assert!(cross_ns_result.unwrap(), "delete must return true");
4652
4653        // Entity is gone — even from the original namespace.
4654        let gone = rt.get_entity(&ns_a, entity.id).await;
4655        assert!(gone.is_err(), "entity must be gone after delete");
4656    }
4657
4658    // ---- Note annotation tests ----
4659
4660    #[tokio::test]
4661    async fn create_note_indexes_into_fts5() {
4662        let rt = rt();
4663        let tok = NamespaceToken::local();
4664        let note = rt
4665            .create_note(
4666                &tok,
4667                "observation",
4668                None,
4669                "FlashAttention reduces memory by using tiling",
4670                Some(0.8),
4671                None,
4672                vec![],
4673            )
4674            .await
4675            .unwrap();
4676
4677        // FTS5 should have indexed the note content.
4678        let ns = tok.namespace().as_str().to_string();
4679        let hits = rt
4680            .text_for_notes(&tok)
4681            .unwrap()
4682            .search(khive_storage::types::TextSearchRequest {
4683                query: "FlashAttention".to_string(),
4684                mode: khive_storage::types::TextQueryMode::Plain,
4685                filter: Some(khive_storage::types::TextFilter {
4686                    namespaces: vec![ns],
4687                    ..Default::default()
4688                }),
4689                top_k: 10,
4690                snippet_chars: 100,
4691            })
4692            .await
4693            .unwrap();
4694
4695        assert!(
4696            hits.iter().any(|h| h.subject_id == note.id),
4697            "note should be indexed in FTS5 after create"
4698        );
4699    }
4700
4701    #[tokio::test]
4702    async fn create_note_with_properties() {
4703        let rt = rt();
4704        let tok = NamespaceToken::local();
4705        let props = serde_json::json!({"source": "arxiv:2205.14135"});
4706        let note = rt
4707            .create_note(
4708                &tok,
4709                "insight",
4710                None,
4711                "FlashAttention is IO-aware",
4712                Some(0.9),
4713                Some(props.clone()),
4714                vec![],
4715            )
4716            .await
4717            .unwrap();
4718
4719        assert_eq!(note.properties.as_ref().unwrap(), &props);
4720    }
4721
4722    #[tokio::test]
4723    async fn create_note_creates_annotates_edges() {
4724        let rt = rt();
4725        let tok = NamespaceToken::local();
4726        let entity = rt
4727            .create_entity(&tok, "concept", None, "FlashAttention", None, None, vec![])
4728            .await
4729            .unwrap();
4730
4731        let note = rt
4732            .create_note(
4733                &tok,
4734                "observation",
4735                None,
4736                "FlashAttention uses SRAM tiling for memory efficiency",
4737                Some(0.9),
4738                None,
4739                vec![entity.id],
4740            )
4741            .await
4742            .unwrap();
4743
4744        // The note should have an outbound `annotates` edge to the entity.
4745        let out_neighbors = rt
4746            .neighbors(
4747                &tok,
4748                note.id,
4749                Direction::Out,
4750                None,
4751                Some(vec![EdgeRelation::Annotates]),
4752            )
4753            .await
4754            .unwrap();
4755        assert_eq!(out_neighbors.len(), 1);
4756        assert_eq!(out_neighbors[0].node_id, entity.id);
4757        assert_eq!(out_neighbors[0].relation, EdgeRelation::Annotates);
4758
4759        // The entity should have an inbound `annotates` edge from the note.
4760        let in_neighbors = rt
4761            .neighbors(
4762                &tok,
4763                entity.id,
4764                Direction::In,
4765                None,
4766                Some(vec![EdgeRelation::Annotates]),
4767            )
4768            .await
4769            .unwrap();
4770        assert_eq!(in_neighbors.len(), 1);
4771        assert_eq!(in_neighbors[0].node_id, note.id);
4772    }
4773
4774    #[tokio::test]
4775    async fn neighbors_without_relation_filter_returns_all() {
4776        let rt = rt();
4777        let tok = NamespaceToken::local();
4778        let a = rt
4779            .create_entity(&tok, "concept", None, "A", None, None, vec![])
4780            .await
4781            .unwrap();
4782        let b = rt
4783            .create_entity(&tok, "concept", None, "B", None, None, vec![])
4784            .await
4785            .unwrap();
4786        let c = rt
4787            .create_entity(&tok, "concept", None, "C", None, None, vec![])
4788            .await
4789            .unwrap();
4790
4791        rt.link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
4792            .await
4793            .unwrap();
4794        rt.link(&tok, a.id, c.id, EdgeRelation::Enables, 1.0, None)
4795            .await
4796            .unwrap();
4797
4798        let all = rt
4799            .neighbors(&tok, a.id, Direction::Out, None, None)
4800            .await
4801            .unwrap();
4802        assert_eq!(all.len(), 2);
4803    }
4804
4805    #[tokio::test]
4806    async fn neighbors_with_relation_filter_returns_subset() {
4807        let rt = rt();
4808        let tok = NamespaceToken::local();
4809        let a = rt
4810            .create_entity(&tok, "concept", None, "A", None, None, vec![])
4811            .await
4812            .unwrap();
4813        let b = rt
4814            .create_entity(&tok, "concept", None, "B", None, None, vec![])
4815            .await
4816            .unwrap();
4817        let c = rt
4818            .create_entity(&tok, "concept", None, "C", None, None, vec![])
4819            .await
4820            .unwrap();
4821
4822        rt.link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
4823            .await
4824            .unwrap();
4825        rt.link(&tok, a.id, c.id, EdgeRelation::Enables, 1.0, None)
4826            .await
4827            .unwrap();
4828
4829        let filtered = rt
4830            .neighbors(
4831                &tok,
4832                a.id,
4833                Direction::Out,
4834                None,
4835                Some(vec![EdgeRelation::Extends]),
4836            )
4837            .await
4838            .unwrap();
4839        assert_eq!(filtered.len(), 1);
4840        assert_eq!(filtered[0].node_id, b.id);
4841        assert_eq!(filtered[0].relation, EdgeRelation::Extends);
4842    }
4843
4844    #[tokio::test]
4845    async fn search_notes_returns_relevant_note() {
4846        let rt = rt();
4847        let tok = NamespaceToken::local();
4848        rt.create_note(
4849            &tok,
4850            "observation",
4851            None,
4852            "GQA reduces KV cache memory for large models",
4853            Some(0.8),
4854            None,
4855            vec![],
4856        )
4857        .await
4858        .unwrap();
4859
4860        let results = rt
4861            .search_notes(&tok, "GQA KV cache", None, 10, None, false, &[], None)
4862            .await
4863            .unwrap();
4864
4865        assert!(!results.is_empty(), "search should return the indexed note");
4866        let hit = &results[0];
4867        assert!(
4868            hit.title.is_some(),
4869            "note hit title should be populated (falls back to content)"
4870        );
4871        assert!(
4872            hit.snippet.is_some(),
4873            "note hit snippet should be populated"
4874        );
4875    }
4876
4877    #[tokio::test]
4878    async fn search_notes_excludes_soft_deleted() {
4879        let rt = rt();
4880        let tok = NamespaceToken::local();
4881        let note = rt
4882            .create_note(
4883                &tok,
4884                "observation",
4885                None,
4886                "RoPE positional encoding rotary embeddings",
4887                Some(0.7),
4888                None,
4889                vec![],
4890            )
4891            .await
4892            .unwrap();
4893
4894        // Soft-delete the note.
4895        rt.notes(&tok)
4896            .unwrap()
4897            .delete_note(note.id, DeleteMode::Soft)
4898            .await
4899            .unwrap();
4900
4901        let results = rt
4902            .search_notes(
4903                &tok,
4904                "RoPE rotary positional",
4905                None,
4906                10,
4907                None,
4908                false,
4909                &[],
4910                None,
4911            )
4912            .await
4913            .unwrap();
4914
4915        assert!(
4916            results.iter().all(|h| h.note_id != note.id),
4917            "soft-deleted note should be excluded from search"
4918        );
4919    }
4920
4921    // ---- issue #225 regression: predicate pushdown before truncation (note branch) ----
4922
4923    /// Regression test for issue #225 (note branch, tag filter).
4924    ///
4925    /// Notes store tags inside `properties["tags"]` — there is no separate tags column.
4926    /// Without pushdown, the tag filter is applied after `hits.truncate(limit)`, so a
4927    /// tag-matching note ranked beyond `limit` in the raw RRF fusion is silently dropped.
4928    ///
4929    /// Scenario: `limit=1`, tags_any=["note-target-tag"]. Two notes are inserted:
4930    ///   - decoy: high FTS rank (repeats query terms), NO target tag.
4931    ///   - target: lower FTS rank, HAS "note-target-tag" in `properties["tags"]`.
4932    ///
4933    /// Without pushdown: decoy occupies the slot, target is dropped.
4934    /// With pushdown: decoy is excluded in the alive-note loop, target survives, returned.
4935    ///
4936    /// Isomorphism: removing the `tags_any` check from the alive-note loop in
4937    /// `search_notes` re-breaks this test.
4938    #[tokio::test]
4939    async fn search_notes_tag_filter_pushed_before_truncation() {
4940        let rt = rt();
4941        let tok = NamespaceToken::local();
4942
4943        // Decoy note: repeats query tokens → higher FTS rank. No target tag.
4944        rt.create_note(
4945            &tok,
4946            "observation",
4947            None,
4948            "kappa lambda mu note decoy kappa lambda mu note decoy kappa lambda mu",
4949            Some(0.5),
4950            Some(serde_json::json!({"tags": ["other-note-tag"]})),
4951            vec![],
4952        )
4953        .await
4954        .unwrap();
4955
4956        // Target note: fewer query tokens → lower FTS rank. Has the target tag.
4957        let target = rt
4958            .create_note(
4959                &tok,
4960                "observation",
4961                None,
4962                "kappa lambda mu note target",
4963                Some(0.5),
4964                Some(serde_json::json!({"tags": ["note-target-tag"]})),
4965                vec![],
4966            )
4967            .await
4968            .unwrap();
4969
4970        // With limit=1 and tags_any, the fix must return the target note despite the
4971        // decoy ranking higher in raw FTS.
4972        let hits = rt
4973            .search_notes(
4974                &tok,
4975                "kappa lambda mu note",
4976                None,
4977                1,
4978                None,
4979                false,
4980                &["note-target-tag".to_string()],
4981                None,
4982            )
4983            .await
4984            .unwrap();
4985
4986        assert_eq!(
4987            hits.len(),
4988            1,
4989            "exactly one hit expected (tag-matching note)"
4990        );
4991        assert_eq!(
4992            hits[0].note_id, target.id,
4993            "tag-filtered note must be returned even when ranked below limit in raw fusion"
4994        );
4995    }
4996
4997    /// Regression test for issue #225 (note branch, properties filter).
4998    ///
4999    /// Without pushdown, the properties filter is applied after truncation; a matching
5000    /// note ranked beyond `limit` is silently dropped.
5001    ///
5002    /// Scenario: `limit=1`, properties_filter={{"source": "target"}}. Two notes:
5003    ///   - decoy: high FTS rank, properties {{"source": "other"}}.
5004    ///   - target: lower FTS rank, properties {{"source": "target"}}.
5005    #[tokio::test]
5006    async fn search_notes_props_filter_pushed_before_truncation() {
5007        let rt = rt();
5008        let tok = NamespaceToken::local();
5009
5010        rt.create_note(
5011            &tok,
5012            "observation",
5013            None,
5014            "nu xi omicron note decoy nu xi omicron note decoy nu xi omicron",
5015            Some(0.5),
5016            Some(serde_json::json!({"source": "other"})),
5017            vec![],
5018        )
5019        .await
5020        .unwrap();
5021
5022        let target = rt
5023            .create_note(
5024                &tok,
5025                "observation",
5026                None,
5027                "nu xi omicron note target",
5028                Some(0.5),
5029                Some(serde_json::json!({"source": "target"})),
5030                vec![],
5031            )
5032            .await
5033            .unwrap();
5034
5035        let filter = serde_json::json!({"source": "target"});
5036        let hits = rt
5037            .search_notes(
5038                &tok,
5039                "nu xi omicron note",
5040                None,
5041                1,
5042                None,
5043                false,
5044                &[],
5045                Some(&filter),
5046            )
5047            .await
5048            .unwrap();
5049
5050        assert_eq!(
5051            hits.len(),
5052            1,
5053            "exactly one hit expected (properties-matching note)"
5054        );
5055        assert_eq!(
5056            hits[0].note_id, target.id,
5057            "properties-filtered note must be returned even when ranked below limit"
5058        );
5059    }
5060
5061    #[tokio::test]
5062    async fn resolve_returns_entity() {
5063        let rt = rt();
5064        let tok = NamespaceToken::local();
5065        let entity = rt
5066            .create_entity(&tok, "concept", None, "LoRA", None, None, vec![])
5067            .await
5068            .unwrap();
5069
5070        let resolved = rt.resolve(&tok, entity.id).await.unwrap();
5071        match resolved {
5072            Some(Resolved::Entity(e)) => assert_eq!(e.id, entity.id),
5073            other => panic!("expected Resolved::Entity, got {:?}", other),
5074        }
5075    }
5076
5077    #[tokio::test]
5078    async fn resolve_returns_note() {
5079        let rt = rt();
5080        let tok = NamespaceToken::local();
5081        let note = rt
5082            .create_note(
5083                &tok,
5084                "observation",
5085                None,
5086                "LoRA fine-tunes LLMs with low-rank adapters",
5087                Some(0.85),
5088                None,
5089                vec![],
5090            )
5091            .await
5092            .unwrap();
5093
5094        let resolved = rt.resolve(&tok, note.id).await.unwrap();
5095        match resolved {
5096            Some(Resolved::Note(n)) => assert_eq!(n.id, note.id),
5097            other => panic!("expected Resolved::Note, got {:?}", other),
5098        }
5099    }
5100
5101    #[tokio::test]
5102    async fn resolve_returns_none_for_unknown_uuid() {
5103        let rt = rt();
5104        let tok = NamespaceToken::local();
5105        let unknown = Uuid::new_v4();
5106        let resolved = rt.resolve(&tok, unknown).await.unwrap();
5107        assert!(resolved.is_none(), "unknown UUID should resolve to None");
5108    }
5109
5110    #[tokio::test]
5111    async fn resolve_prefix_finds_entity_in_own_namespace() {
5112        let rt = rt();
5113        let tok = NamespaceToken::local();
5114        let entity = rt
5115            .create_entity(&tok, "concept", None, "PrefixTest", None, None, vec![])
5116            .await
5117            .unwrap();
5118        let prefix = &entity.id.to_string()[..8];
5119
5120        let resolved = rt.resolve_prefix(&tok, prefix).await.unwrap();
5121        assert_eq!(resolved, Some(entity.id));
5122    }
5123
5124    #[tokio::test]
5125    async fn resolve_prefix_invisible_across_namespaces() {
5126        let rt = rt();
5127        let ns_a = NamespaceToken::for_namespace(Namespace::parse("ns-a").unwrap());
5128        let ns_b = NamespaceToken::for_namespace(Namespace::parse("ns-b").unwrap());
5129        let entity = rt
5130            .create_entity(&ns_a, "concept", None, "Invisible", None, None, vec![])
5131            .await
5132            .unwrap();
5133        let prefix = &entity.id.to_string()[..8];
5134
5135        // From ns_b, the entity in ns_a should not be visible.
5136        let resolved = rt.resolve_prefix(&ns_b, prefix).await.unwrap();
5137        assert_eq!(resolved, None);
5138    }
5139
5140    #[tokio::test]
5141    async fn resolve_prefix_ambiguous_same_namespace() {
5142        use khive_storage::entity::Entity;
5143
5144        let rt = rt();
5145        let tok = NamespaceToken::local();
5146        // Two entities with UUIDs sharing the same 8-char prefix "aabbccdd".
5147        let id_a = Uuid::parse_str("aabbccdd-1111-4000-8000-000000000001").unwrap();
5148        let id_b = Uuid::parse_str("aabbccdd-2222-4000-8000-000000000002").unwrap();
5149
5150        let mut entity_a = Entity::new("local", "concept", "AmbigA");
5151        entity_a.id = id_a;
5152        let mut entity_b = Entity::new("local", "concept", "AmbigB");
5153        entity_b.id = id_b;
5154
5155        let store = rt.entities(&tok).unwrap();
5156        store.upsert_entity(entity_a).await.unwrap();
5157        store.upsert_entity(entity_b).await.unwrap();
5158
5159        let err = rt.resolve_prefix(&tok, "aabbccdd").await.unwrap_err();
5160        assert!(
5161            matches!(
5162                err,
5163                RuntimeError::AmbiguousPrefix { ref prefix, ref matches }
5164                    if prefix == "aabbccdd" && matches.len() == 2
5165            ),
5166            "shared 8-char prefix must return AmbiguousPrefix; got {err:?}"
5167        );
5168    }
5169
5170    // ---- Event resolution tests (issue #30) ----
5171    //
5172    // resolve_prefix and handle_get already include events; these tests are
5173    // regression coverage confirming event UUIDs are resolvable and that get()
5174    // returns kind="event".
5175
5176    #[tokio::test]
5177    async fn resolve_finds_event_by_full_uuid() {
5178        use khive_storage::Event;
5179        use khive_types::{EventKind, SubstrateKind};
5180
5181        let rt = rt();
5182        let tok = NamespaceToken::local();
5183        let ns = tok.namespace().as_str();
5184        let event = Event::new(
5185            ns,
5186            "test_verb",
5187            EventKind::Audit,
5188            SubstrateKind::Entity,
5189            "actor",
5190        );
5191        let event_id = event.id;
5192        rt.events(&tok).unwrap().append_event(event).await.unwrap();
5193
5194        let resolved = rt.resolve(&tok, event_id).await.unwrap();
5195        assert!(
5196            matches!(resolved, Some(Resolved::Event(_))),
5197            "event UUID must resolve to Resolved::Event, got {resolved:?}"
5198        );
5199    }
5200
5201    #[tokio::test]
5202    async fn resolve_prefix_finds_event() {
5203        use khive_storage::Event;
5204        use khive_types::{EventKind, SubstrateKind};
5205
5206        let rt = rt();
5207        let tok = NamespaceToken::local();
5208        let ns = tok.namespace().as_str();
5209        let event = Event::new(
5210            ns,
5211            "test_verb",
5212            EventKind::Audit,
5213            SubstrateKind::Entity,
5214            "actor",
5215        );
5216        let event_id = event.id;
5217        rt.events(&tok).unwrap().append_event(event).await.unwrap();
5218
5219        let prefix = &event_id.to_string()[..8];
5220        let resolved = rt.resolve_prefix(&tok, prefix).await.unwrap();
5221        assert_eq!(
5222            resolved,
5223            Some(event_id),
5224            "resolve_prefix must return event UUID for 8-char prefix"
5225        );
5226    }
5227
5228    // ---- Referential integrity tests (fix/link-referential-integrity) ----
5229
5230    #[tokio::test]
5231    async fn link_phantom_source_returns_not_found() {
5232        let rt = rt();
5233        let tok = NamespaceToken::local();
5234        let b = rt
5235            .create_entity(&tok, "concept", None, "B", None, None, vec![])
5236            .await
5237            .unwrap();
5238        let phantom = Uuid::new_v4();
5239
5240        let result = rt
5241            .link(&tok, phantom, b.id, EdgeRelation::Extends, 1.0, None)
5242            .await;
5243        match result {
5244            Err(RuntimeError::NotFound(msg)) => {
5245                assert!(
5246                    msg.contains("source"),
5247                    "error message must name 'source': {msg}"
5248                );
5249            }
5250            other => panic!("expected NotFound for phantom source, got {other:?}"),
5251        }
5252    }
5253
5254    #[tokio::test]
5255    async fn link_phantom_target_returns_not_found() {
5256        let rt = rt();
5257        let tok = NamespaceToken::local();
5258        let a = rt
5259            .create_entity(&tok, "concept", None, "A", None, None, vec![])
5260            .await
5261            .unwrap();
5262        let phantom = Uuid::new_v4();
5263
5264        let result = rt
5265            .link(&tok, a.id, phantom, EdgeRelation::Extends, 1.0, None)
5266            .await;
5267        match result {
5268            Err(RuntimeError::NotFound(msg)) => {
5269                assert!(
5270                    msg.contains("target"),
5271                    "error message must name 'target': {msg}"
5272                );
5273            }
5274            other => panic!("expected NotFound for phantom target, got {other:?}"),
5275        }
5276    }
5277
5278    #[tokio::test]
5279    async fn link_real_entities_succeeds() {
5280        let rt = rt();
5281        let tok = NamespaceToken::local();
5282        let a = rt
5283            .create_entity(&tok, "concept", None, "A", None, None, vec![])
5284            .await
5285            .unwrap();
5286        let b = rt
5287            .create_entity(&tok, "concept", None, "B", None, None, vec![])
5288            .await
5289            .unwrap();
5290
5291        let edge = rt
5292            .link(&tok, a.id, b.id, EdgeRelation::Extends, 0.8, None)
5293            .await
5294            .unwrap();
5295        assert_eq!(edge.source_id, a.id);
5296        assert_eq!(edge.target_id, b.id);
5297        assert_eq!(edge.relation, EdgeRelation::Extends);
5298    }
5299
5300    #[tokio::test]
5301    async fn create_note_annotates_phantom_returns_not_found() {
5302        let rt = rt();
5303        let tok = NamespaceToken::local();
5304        let phantom = Uuid::new_v4();
5305
5306        let result = rt
5307            .create_note(
5308                &tok,
5309                "observation",
5310                None,
5311                "some content",
5312                Some(0.5),
5313                None,
5314                vec![phantom],
5315            )
5316            .await;
5317        assert!(
5318            matches!(result, Err(RuntimeError::NotFound(_))),
5319            "annotates with phantom uuid must return NotFound, got {result:?}"
5320        );
5321    }
5322
5323    #[tokio::test]
5324    async fn create_note_annotates_real_entity_succeeds() {
5325        let rt = rt();
5326        let tok = NamespaceToken::local();
5327        let entity = rt
5328            .create_entity(&tok, "concept", None, "RealTarget", None, None, vec![])
5329            .await
5330            .unwrap();
5331
5332        let note = rt
5333            .create_note(
5334                &tok,
5335                "observation",
5336                None,
5337                "content",
5338                Some(0.5),
5339                None,
5340                vec![entity.id],
5341            )
5342            .await
5343            .unwrap();
5344
5345        let neighbors = rt
5346            .neighbors(
5347                &tok,
5348                note.id,
5349                Direction::Out,
5350                None,
5351                Some(vec![EdgeRelation::Annotates]),
5352            )
5353            .await
5354            .unwrap();
5355        assert_eq!(neighbors.len(), 1);
5356        assert_eq!(neighbors[0].node_id, entity.id);
5357    }
5358
5359    // Atomicity: multi-target annotates golden path — all edges created, note present.
5360    #[tokio::test]
5361    async fn create_note_multi_annotates_creates_all_edges() {
5362        let rt = rt();
5363        let tok = NamespaceToken::local();
5364        let t1 = rt
5365            .create_entity(&tok, "concept", None, "Target1", None, None, vec![])
5366            .await
5367            .unwrap();
5368        let t2 = rt
5369            .create_entity(&tok, "concept", None, "Target2", None, None, vec![])
5370            .await
5371            .unwrap();
5372
5373        let note = rt
5374            .create_note(
5375                &tok,
5376                "observation",
5377                None,
5378                "content",
5379                Some(0.5),
5380                None,
5381                vec![t1.id, t2.id],
5382            )
5383            .await
5384            .unwrap();
5385
5386        let neighbors = rt
5387            .neighbors(
5388                &tok,
5389                note.id,
5390                Direction::Out,
5391                None,
5392                Some(vec![EdgeRelation::Annotates]),
5393            )
5394            .await
5395            .unwrap();
5396        assert_eq!(
5397            neighbors.len(),
5398            2,
5399            "multi-annotates note must have exactly 2 outbound annotates edges"
5400        );
5401        let target_ids: Vec<Uuid> = neighbors.iter().map(|n| n.node_id).collect();
5402        assert!(target_ids.contains(&t1.id));
5403        assert!(target_ids.contains(&t2.id));
5404    }
5405
5406    #[tokio::test]
5407    async fn link_target_in_different_namespace_returns_not_found() {
5408        let rt = rt();
5409        let ns_a = NamespaceToken::for_namespace(Namespace::parse("ns-a").unwrap());
5410        let ns_b = NamespaceToken::for_namespace(Namespace::parse("ns-b").unwrap());
5411        let a = rt
5412            .create_entity(&ns_a, "concept", None, "A", None, None, vec![])
5413            .await
5414            .unwrap();
5415        let b = rt
5416            .create_entity(&ns_b, "concept", None, "B", None, None, vec![])
5417            .await
5418            .unwrap();
5419
5420        // Linking from ns-a: target b lives in ns-b — must be treated as not found.
5421        let result = rt
5422            .link(&ns_a, a.id, b.id, EdgeRelation::Extends, 1.0, None)
5423            .await;
5424        assert!(
5425            matches!(result, Err(RuntimeError::NotFound(_))),
5426            "target in different namespace must return NotFound (fail-closed), got {result:?}"
5427        );
5428    }
5429
5430    #[tokio::test]
5431    async fn link_phantom_self_loop_returns_invalid_input() {
5432        let rt = rt();
5433        let tok = NamespaceToken::local();
5434        let phantom = Uuid::new_v4();
5435
5436        let result = rt
5437            .link(&tok, phantom, phantom, EdgeRelation::Extends, 1.0, None)
5438            .await;
5439        match result {
5440            Err(RuntimeError::InvalidInput(msg)) => {
5441                assert!(
5442                    msg.contains("self-loop"),
5443                    "self-loop must be rejected with self-loop message: {msg}"
5444                );
5445            }
5446            other => panic!("expected InvalidInput for self-loop, got {other:?}"),
5447        }
5448    }
5449
5450    // ---- Round-2 tests: edge target coverage + atomicity ----
5451
5452    #[tokio::test]
5453    async fn link_note_to_edge_annotates_succeeds() {
5454        let rt = rt();
5455        let tok = NamespaceToken::local();
5456        let a = rt
5457            .create_entity(&tok, "concept", None, "A", None, None, vec![])
5458            .await
5459            .unwrap();
5460        let b = rt
5461            .create_entity(&tok, "concept", None, "B", None, None, vec![])
5462            .await
5463            .unwrap();
5464        // Create a real edge between a and b, capture its UUID.
5465        let edge = rt
5466            .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
5467            .await
5468            .unwrap();
5469        let edge_uuid: Uuid = edge.id.into();
5470
5471        // Create a note and annotate the edge itself (edge is a valid substrate target for annotates).
5472        let note = rt
5473            .create_note(
5474                &tok,
5475                "observation",
5476                None,
5477                "edge note",
5478                Some(0.5),
5479                None,
5480                vec![],
5481            )
5482            .await
5483            .unwrap();
5484
5485        let result = rt
5486            .link(&tok, note.id, edge_uuid, EdgeRelation::Annotates, 1.0, None)
5487            .await;
5488        assert!(
5489            result.is_ok(),
5490            "note→edge Annotates must succeed, got {result:?}"
5491        );
5492    }
5493
5494    #[tokio::test]
5495    async fn create_note_annotates_real_edge_succeeds() {
5496        let rt = rt();
5497        let tok = NamespaceToken::local();
5498        let a = rt
5499            .create_entity(&tok, "concept", None, "A", None, None, vec![])
5500            .await
5501            .unwrap();
5502        let b = rt
5503            .create_entity(&tok, "concept", None, "B", None, None, vec![])
5504            .await
5505            .unwrap();
5506        let edge = rt
5507            .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
5508            .await
5509            .unwrap();
5510        let edge_uuid: Uuid = edge.id.into();
5511
5512        let note = rt
5513            .create_note(
5514                &tok,
5515                "observation",
5516                None,
5517                "annotating an edge",
5518                Some(0.5),
5519                None,
5520                vec![edge_uuid],
5521            )
5522            .await
5523            .unwrap();
5524
5525        let neighbors = rt
5526            .neighbors(
5527                &tok,
5528                note.id,
5529                Direction::Out,
5530                None,
5531                Some(vec![EdgeRelation::Annotates]),
5532            )
5533            .await
5534            .unwrap();
5535        assert_eq!(neighbors.len(), 1);
5536        assert_eq!(neighbors[0].node_id, edge_uuid);
5537    }
5538
5539    #[tokio::test]
5540    async fn create_note_annotates_phantom_is_atomic_no_note_persisted() {
5541        let rt = rt();
5542        let tok = NamespaceToken::local();
5543        let phantom = Uuid::new_v4();
5544
5545        let before_count = rt.list_notes(&tok, None, 1000, 0).await.unwrap().len();
5546
5547        let result = rt
5548            .create_note(
5549                &tok,
5550                "observation",
5551                None,
5552                "should not persist",
5553                Some(0.5),
5554                None,
5555                vec![phantom],
5556            )
5557            .await;
5558        assert!(
5559            matches!(result, Err(RuntimeError::NotFound(_))),
5560            "phantom annotates target must return NotFound, got {result:?}"
5561        );
5562
5563        // Atomicity: the note row must NOT have been written.
5564        let after_count = rt.list_notes(&tok, None, 1000, 0).await.unwrap().len();
5565        assert_eq!(
5566            before_count, after_count,
5567            "failed create_note must not persist any note row (atomicity)"
5568        );
5569
5570        // FTS must not contain the content either.
5571        let search_hits = rt
5572            .search_notes(&tok, "should not persist", None, 10, None, false, &[], None)
5573            .await
5574            .unwrap();
5575        assert!(
5576            search_hits.is_empty(),
5577            "failed create_note must not index into FTS (atomicity)"
5578        );
5579        // Vector-store row: only written when an embedding model is configured; the rt()
5580        // harness has none, so no vector assertion is needed here.
5581    }
5582
5583    // ---- Round-3 tests: relation-aware endpoint contract ----
5584
5585    // Test #2: entity→entity with non-annotates rejects an edge UUID as target.
5586    #[tokio::test]
5587    async fn link_entity_to_edge_uuid_non_annotates_returns_invalid_input() {
5588        let rt = rt();
5589        let tok = NamespaceToken::local();
5590        let a = rt
5591            .create_entity(&tok, "concept", None, "A", None, None, vec![])
5592            .await
5593            .unwrap();
5594        let b = rt
5595            .create_entity(&tok, "concept", None, "B", None, None, vec![])
5596            .await
5597            .unwrap();
5598        // Create a real edge; capture its UUID as the bad target.
5599        let edge = rt
5600            .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
5601            .await
5602            .unwrap();
5603        let edge_uuid: Uuid = edge.id.into();
5604
5605        let result = rt
5606            .link(&tok, a.id, edge_uuid, EdgeRelation::Extends, 1.0, None)
5607            .await;
5608        match result {
5609            Err(RuntimeError::InvalidInput(msg)) => {
5610                assert!(
5611                    msg.contains("target"),
5612                    "error message must name 'target': {msg}"
5613                );
5614            }
5615            other => {
5616                panic!("expected InvalidInput for edge-uuid target with Extends, got {other:?}")
5617            }
5618        }
5619    }
5620
5621    // Test #3: non-annotates rejects a note UUID as source.
5622    #[tokio::test]
5623    async fn link_note_as_source_non_annotates_returns_invalid_input() {
5624        let rt = rt();
5625        let tok = NamespaceToken::local();
5626        let note = rt
5627            .create_note(&tok, "observation", None, "a note", Some(0.5), None, vec![])
5628            .await
5629            .unwrap();
5630        let entity = rt
5631            .create_entity(&tok, "concept", None, "E", None, None, vec![])
5632            .await
5633            .unwrap();
5634
5635        let result = rt
5636            .link(&tok, note.id, entity.id, EdgeRelation::DependsOn, 1.0, None)
5637            .await;
5638        match result {
5639            Err(RuntimeError::InvalidInput(msg)) => {
5640                assert!(
5641                    msg.contains("source"),
5642                    "error message must name 'source': {msg}"
5643                );
5644            }
5645            other => panic!("expected InvalidInput for note source with DependsOn, got {other:?}"),
5646        }
5647    }
5648
5649    // Test #4: annotates rejects entity as source (source must be a note).
5650    #[tokio::test]
5651    async fn link_entity_as_annotates_source_returns_invalid_input() {
5652        let rt = rt();
5653        let tok = NamespaceToken::local();
5654        let a = rt
5655            .create_entity(&tok, "concept", None, "A", None, None, vec![])
5656            .await
5657            .unwrap();
5658        let b = rt
5659            .create_entity(&tok, "concept", None, "B", None, None, vec![])
5660            .await
5661            .unwrap();
5662
5663        let result = rt
5664            .link(&tok, a.id, b.id, EdgeRelation::Annotates, 1.0, None)
5665            .await;
5666        match result {
5667            Err(RuntimeError::InvalidInput(msg)) => {
5668                assert!(
5669                    msg.contains("source") && msg.contains("note"),
5670                    "error must say source must be a note: {msg}"
5671                );
5672            }
5673            other => {
5674                panic!("expected InvalidInput for entity source with Annotates, got {other:?}")
5675            }
5676        }
5677    }
5678
5679    #[tokio::test]
5680    async fn link_edge_as_annotates_source_returns_invalid_input() {
5681        let rt = rt();
5682        let tok = NamespaceToken::local();
5683        let a = rt
5684            .create_entity(&tok, "concept", None, "A", None, None, vec![])
5685            .await
5686            .unwrap();
5687        let b = rt
5688            .create_entity(&tok, "concept", None, "B", None, None, vec![])
5689            .await
5690            .unwrap();
5691        let edge = rt
5692            .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
5693            .await
5694            .unwrap();
5695        let edge_uuid: Uuid = edge.id.into();
5696
5697        // An existing edge used as an annotates source: wrong kind, not absent.
5698        let result = rt
5699            .link(&tok, edge_uuid, a.id, EdgeRelation::Annotates, 1.0, None)
5700            .await;
5701        match result {
5702            Err(RuntimeError::InvalidInput(msg)) => {
5703                assert!(
5704                    msg.contains("source") && msg.contains("note"),
5705                    "edge-as-annotates-source must report wrong kind, not NotFound: {msg}"
5706                );
5707            }
5708            other => panic!("expected InvalidInput for edge source with Annotates, got {other:?}"),
5709        }
5710    }
5711
5712    // Test #5: note→event with annotates succeeds (event is a valid annotates target).
5713    #[tokio::test]
5714    async fn link_note_to_event_annotates_succeeds() {
5715        use khive_storage::Event;
5716        use khive_types::{EventKind, SubstrateKind};
5717
5718        let rt = rt();
5719        let tok = NamespaceToken::local();
5720        let note = rt
5721            .create_note(
5722                &tok,
5723                "observation",
5724                None,
5725                "observing an event",
5726                Some(0.6),
5727                None,
5728                vec![],
5729            )
5730            .await
5731            .unwrap();
5732
5733        // Build an event directly via the store (no runtime create_event exists).
5734        let ns = tok.namespace().as_str();
5735        let event = Event::new(
5736            ns,
5737            "test_verb",
5738            EventKind::Audit,
5739            SubstrateKind::Entity,
5740            "test_actor",
5741        );
5742        let event_id = event.id;
5743        rt.events(&tok).unwrap().append_event(event).await.unwrap();
5744
5745        let result = rt
5746            .link(&tok, note.id, event_id, EdgeRelation::Annotates, 1.0, None)
5747            .await;
5748        assert!(
5749            result.is_ok(),
5750            "note→event Annotates must succeed, got {result:?}"
5751        );
5752    }
5753
5754    // Test #6: create_note with event as annotates target succeeds.
5755    #[tokio::test]
5756    async fn create_note_annotates_event_succeeds() {
5757        use khive_storage::Event;
5758        use khive_types::{EventKind, SubstrateKind};
5759
5760        let rt = rt();
5761        let tok = NamespaceToken::local();
5762        let ns = tok.namespace().as_str();
5763        let event = Event::new(
5764            ns,
5765            "test_verb",
5766            EventKind::Audit,
5767            SubstrateKind::Entity,
5768            "test_actor",
5769        );
5770        let event_id = event.id;
5771        rt.events(&tok).unwrap().append_event(event).await.unwrap();
5772
5773        let result = rt
5774            .create_note(
5775                &tok,
5776                "observation",
5777                None,
5778                "note annotating an event",
5779                Some(0.5),
5780                None,
5781                vec![event_id],
5782            )
5783            .await;
5784        assert!(
5785            result.is_ok(),
5786            "create_note with event annotates target must succeed, got {result:?}"
5787        );
5788        // Verify the annotates edge was created.
5789        let note = result.unwrap();
5790        let neighbors = rt
5791            .neighbors(
5792                &tok,
5793                note.id,
5794                Direction::Out,
5795                None,
5796                Some(vec![EdgeRelation::Annotates]),
5797            )
5798            .await
5799            .unwrap();
5800        assert_eq!(neighbors.len(), 1);
5801        assert_eq!(neighbors[0].node_id, event_id);
5802    }
5803
5804    // ---- Round-4 tests: supersedes same-substrate contract ----
5805
5806    // Headline regression: note→note supersedes must succeed (was wrongly rejected before this fix).
5807    #[tokio::test]
5808    async fn link_supersedes_note_to_note_succeeds() {
5809        let rt = rt();
5810        let tok = NamespaceToken::local();
5811        let old_note = rt
5812            .create_note(
5813                &tok,
5814                "observation",
5815                None,
5816                "old observation",
5817                Some(0.7),
5818                None,
5819                vec![],
5820            )
5821            .await
5822            .unwrap();
5823        let new_note = rt
5824            .create_note(
5825                &tok,
5826                "observation",
5827                None,
5828                "revised observation superseding the old one",
5829                Some(0.9),
5830                None,
5831                vec![],
5832            )
5833            .await
5834            .unwrap();
5835
5836        let result = rt
5837            .link(
5838                &tok,
5839                new_note.id,
5840                old_note.id,
5841                EdgeRelation::Supersedes,
5842                1.0,
5843                None,
5844            )
5845            .await;
5846        assert!(
5847            result.is_ok(),
5848            "note→note Supersedes must succeed (note supersession), got {result:?}"
5849        );
5850    }
5851
5852    #[tokio::test]
5853    async fn link_supersedes_entity_to_entity_succeeds() {
5854        let rt = rt();
5855        let tok = NamespaceToken::local();
5856        let old_entity = rt
5857            .create_entity(&tok, "concept", None, "OldConcept", None, None, vec![])
5858            .await
5859            .unwrap();
5860        let new_entity = rt
5861            .create_entity(&tok, "concept", None, "NewConcept", None, None, vec![])
5862            .await
5863            .unwrap();
5864
5865        let result = rt
5866            .link(
5867                &tok,
5868                new_entity.id,
5869                old_entity.id,
5870                EdgeRelation::Supersedes,
5871                1.0,
5872                None,
5873            )
5874            .await;
5875        assert!(
5876            result.is_ok(),
5877            "entity→entity Supersedes must succeed, got {result:?}"
5878        );
5879    }
5880
5881    #[tokio::test]
5882    async fn link_supersedes_note_to_entity_returns_invalid_input() {
5883        let rt = rt();
5884        let tok = NamespaceToken::local();
5885        let note = rt
5886            .create_note(&tok, "observation", None, "a note", Some(0.5), None, vec![])
5887            .await
5888            .unwrap();
5889        let entity = rt
5890            .create_entity(&tok, "concept", None, "SomeEntity", None, None, vec![])
5891            .await
5892            .unwrap();
5893
5894        let result = rt
5895            .link(
5896                &tok,
5897                note.id,
5898                entity.id,
5899                EdgeRelation::Supersedes,
5900                1.0,
5901                None,
5902            )
5903            .await;
5904        match result {
5905            Err(RuntimeError::InvalidInput(msg)) => {
5906                assert!(
5907                    msg.contains("same substrate") || msg.contains("same-substrate"),
5908                    "error must name the same-substrate rule: {msg}"
5909                );
5910            }
5911            other => panic!(
5912                "expected InvalidInput for note→entity Supersedes (cross-substrate), got {other:?}"
5913            ),
5914        }
5915    }
5916
5917    #[tokio::test]
5918    async fn link_supersedes_entity_to_note_returns_invalid_input() {
5919        let rt = rt();
5920        let tok = NamespaceToken::local();
5921        let entity = rt
5922            .create_entity(&tok, "concept", None, "SomeEntity", None, None, vec![])
5923            .await
5924            .unwrap();
5925        let note = rt
5926            .create_note(&tok, "observation", None, "a note", Some(0.5), None, vec![])
5927            .await
5928            .unwrap();
5929
5930        let result = rt
5931            .link(
5932                &tok,
5933                entity.id,
5934                note.id,
5935                EdgeRelation::Supersedes,
5936                1.0,
5937                None,
5938            )
5939            .await;
5940        match result {
5941            Err(RuntimeError::InvalidInput(msg)) => {
5942                assert!(
5943                    msg.contains("same substrate") || msg.contains("same-substrate"),
5944                    "error must name the same-substrate rule: {msg}"
5945                );
5946            }
5947            other => panic!(
5948                "expected InvalidInput for entity→note Supersedes (cross-substrate), got {other:?}"
5949            ),
5950        }
5951    }
5952
5953    #[tokio::test]
5954    async fn link_supersedes_event_source_returns_invalid_input() {
5955        use khive_storage::Event;
5956        use khive_types::{EventKind, SubstrateKind};
5957
5958        let rt = rt();
5959        let tok = NamespaceToken::local();
5960        let ns = tok.namespace().as_str();
5961        let event = Event::new(
5962            ns,
5963            "test_verb",
5964            EventKind::Audit,
5965            SubstrateKind::Entity,
5966            "test_actor",
5967        );
5968        let event_id = event.id;
5969        rt.events(&tok).unwrap().append_event(event).await.unwrap();
5970
5971        let entity = rt
5972            .create_entity(&tok, "concept", None, "SomeEntity", None, None, vec![])
5973            .await
5974            .unwrap();
5975
5976        let result = rt
5977            .link(
5978                &tok,
5979                event_id,
5980                entity.id,
5981                EdgeRelation::Supersedes,
5982                1.0,
5983                None,
5984            )
5985            .await;
5986        match result {
5987            Err(RuntimeError::InvalidInput(msg)) => {
5988                assert!(msg.contains("event"), "error must mention 'event': {msg}");
5989            }
5990            other => {
5991                panic!("expected InvalidInput for event source with Supersedes, got {other:?}")
5992            }
5993        }
5994    }
5995
5996    #[tokio::test]
5997    async fn link_supersedes_event_target_returns_invalid_input() {
5998        use khive_storage::Event;
5999        use khive_types::{EventKind, SubstrateKind};
6000
6001        let rt = rt();
6002        let tok = NamespaceToken::local();
6003        let ns = tok.namespace().as_str();
6004        let event = Event::new(
6005            ns,
6006            "test_verb",
6007            EventKind::Audit,
6008            SubstrateKind::Entity,
6009            "test_actor",
6010        );
6011        let event_id = event.id;
6012        rt.events(&tok).unwrap().append_event(event).await.unwrap();
6013
6014        let entity = rt
6015            .create_entity(&tok, "concept", None, "SomeEntity", None, None, vec![])
6016            .await
6017            .unwrap();
6018
6019        let result = rt
6020            .link(
6021                &tok,
6022                entity.id,
6023                event_id,
6024                EdgeRelation::Supersedes,
6025                1.0,
6026                None,
6027            )
6028            .await;
6029        match result {
6030            Err(RuntimeError::InvalidInput(msg)) => {
6031                assert!(msg.contains("event"), "error must mention 'event': {msg}");
6032            }
6033            other => {
6034                panic!("expected InvalidInput for event target with Supersedes, got {other:?}")
6035            }
6036        }
6037    }
6038
6039    #[tokio::test]
6040    async fn link_supersedes_edge_source_returns_invalid_input() {
6041        let rt = rt();
6042        let tok = NamespaceToken::local();
6043        let a = rt
6044            .create_entity(&tok, "concept", None, "A", None, None, vec![])
6045            .await
6046            .unwrap();
6047        let b = rt
6048            .create_entity(&tok, "concept", None, "B", None, None, vec![])
6049            .await
6050            .unwrap();
6051        let edge = rt
6052            .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
6053            .await
6054            .unwrap();
6055        let edge_uuid: Uuid = edge.id.into();
6056
6057        let result = rt
6058            .link(&tok, edge_uuid, a.id, EdgeRelation::Supersedes, 1.0, None)
6059            .await;
6060        match result {
6061            Err(RuntimeError::InvalidInput(msg)) => {
6062                assert!(msg.contains("source"), "error must name 'source': {msg}");
6063            }
6064            other => {
6065                panic!("expected InvalidInput for edge-uuid source with Supersedes, got {other:?}")
6066            }
6067        }
6068    }
6069
6070    #[tokio::test]
6071    async fn link_supersedes_edge_target_returns_invalid_input() {
6072        let rt = rt();
6073        let tok = NamespaceToken::local();
6074        let a = rt
6075            .create_entity(&tok, "concept", None, "A", None, None, vec![])
6076            .await
6077            .unwrap();
6078        let b = rt
6079            .create_entity(&tok, "concept", None, "B", None, None, vec![])
6080            .await
6081            .unwrap();
6082        let edge = rt
6083            .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
6084            .await
6085            .unwrap();
6086        let edge_uuid: Uuid = edge.id.into();
6087
6088        let result = rt
6089            .link(&tok, a.id, edge_uuid, EdgeRelation::Supersedes, 1.0, None)
6090            .await;
6091        match result {
6092            Err(RuntimeError::InvalidInput(msg)) => {
6093                assert!(msg.contains("target"), "error must name 'target': {msg}");
6094            }
6095            other => {
6096                panic!("expected InvalidInput for edge-uuid target with Supersedes, got {other:?}")
6097            }
6098        }
6099    }
6100
6101    #[tokio::test]
6102    async fn link_supersedes_phantom_source_returns_not_found() {
6103        let rt = rt();
6104        let tok = NamespaceToken::local();
6105        let note = rt
6106            .create_note(
6107                &tok,
6108                "observation",
6109                None,
6110                "existing note",
6111                Some(0.5),
6112                None,
6113                vec![],
6114            )
6115            .await
6116            .unwrap();
6117        let phantom = Uuid::new_v4();
6118
6119        let result = rt
6120            .link(&tok, phantom, note.id, EdgeRelation::Supersedes, 1.0, None)
6121            .await;
6122        match result {
6123            Err(RuntimeError::NotFound(msg)) => {
6124                assert!(msg.contains("source"), "error must name 'source': {msg}");
6125            }
6126            other => panic!("expected NotFound for phantom source with Supersedes, got {other:?}"),
6127        }
6128    }
6129
6130    #[tokio::test]
6131    async fn link_supersedes_phantom_target_returns_not_found() {
6132        let rt = rt();
6133        let tok = NamespaceToken::local();
6134        let note = rt
6135            .create_note(
6136                &tok,
6137                "observation",
6138                None,
6139                "existing note",
6140                Some(0.5),
6141                None,
6142                vec![],
6143            )
6144            .await
6145            .unwrap();
6146        let phantom = Uuid::new_v4();
6147
6148        let result = rt
6149            .link(&tok, note.id, phantom, EdgeRelation::Supersedes, 1.0, None)
6150            .await;
6151        match result {
6152            Err(RuntimeError::NotFound(msg)) => {
6153                assert!(msg.contains("target"), "error must name 'target': {msg}");
6154            }
6155            other => panic!("expected NotFound for phantom target with Supersedes, got {other:?}"),
6156        }
6157    }
6158
6159    #[tokio::test]
6160    async fn link_supersedes_cross_namespace_source_returns_not_found() {
6161        let rt = rt();
6162        let ns_a = NamespaceToken::for_namespace(Namespace::parse("ns-a").unwrap());
6163        let ns_b = NamespaceToken::for_namespace(Namespace::parse("ns-b").unwrap());
6164        let note_a = rt
6165            .create_note(
6166                &ns_a,
6167                "observation",
6168                None,
6169                "note in ns-a",
6170                Some(0.5),
6171                None,
6172                vec![],
6173            )
6174            .await
6175            .unwrap();
6176        let note_b = rt
6177            .create_note(
6178                &ns_b,
6179                "observation",
6180                None,
6181                "note in ns-b",
6182                Some(0.5),
6183                None,
6184                vec![],
6185            )
6186            .await
6187            .unwrap();
6188
6189        // From ns-a perspective, note_b is in a different namespace — treated as not found.
6190        let result = rt
6191            .link(
6192                &ns_a,
6193                note_b.id,
6194                note_a.id,
6195                EdgeRelation::Supersedes,
6196                1.0,
6197                None,
6198            )
6199            .await;
6200        assert!(
6201            matches!(result, Err(RuntimeError::NotFound(_))),
6202            "cross-namespace source with Supersedes must return NotFound (fail-closed), got {result:?}"
6203        );
6204    }
6205
6206    // Sanity: extends (non-annotates, non-supersedes) still requires entity→entity.
6207    #[tokio::test]
6208    async fn link_extends_note_source_still_returns_invalid_input() {
6209        let rt = rt();
6210        let tok = NamespaceToken::local();
6211        let note = rt
6212            .create_note(
6213                &tok,
6214                "observation",
6215                None,
6216                "a note that cannot be an extends source",
6217                Some(0.5),
6218                None,
6219                vec![],
6220            )
6221            .await
6222            .unwrap();
6223        let entity = rt
6224            .create_entity(&tok, "concept", None, "E", None, None, vec![])
6225            .await
6226            .unwrap();
6227
6228        let result = rt
6229            .link(&tok, note.id, entity.id, EdgeRelation::Extends, 1.0, None)
6230            .await;
6231        assert!(
6232            matches!(result, Err(RuntimeError::InvalidInput(_))),
6233            "note source with Extends must still return InvalidInput after this fix, got {result:?}"
6234        );
6235    }
6236
6237    // Sanity: annotates note→edge still succeeds (unchanged path not broken by this fix).
6238    #[tokio::test]
6239    async fn link_annotates_note_to_edge_still_succeeds_after_fix() {
6240        let rt = rt();
6241        let tok = NamespaceToken::local();
6242        let a = rt
6243            .create_entity(&tok, "concept", None, "A", None, None, vec![])
6244            .await
6245            .unwrap();
6246        let b = rt
6247            .create_entity(&tok, "concept", None, "B", None, None, vec![])
6248            .await
6249            .unwrap();
6250        let edge = rt
6251            .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
6252            .await
6253            .unwrap();
6254        let edge_uuid: Uuid = edge.id.into();
6255
6256        let note = rt
6257            .create_note(
6258                &tok,
6259                "observation",
6260                None,
6261                "annotating an edge",
6262                Some(0.5),
6263                None,
6264                vec![],
6265            )
6266            .await
6267            .unwrap();
6268
6269        let result = rt
6270            .link(&tok, note.id, edge_uuid, EdgeRelation::Annotates, 1.0, None)
6271            .await;
6272        assert!(
6273            result.is_ok(),
6274            "note→edge Annotates must still succeed after supersedes fix, got {result:?}"
6275        );
6276    }
6277
6278    // ---- Compensation-path rollback (fix/annotates) ----
6279
6280    // The compensation branch in `create_note_inner` (operations.rs) rolls back
6281    // a partial write — note row + first edge + FTS + vector — when a subsequent
6282    // link call fails. The failure trigger is a storage error (e.g. I/O failure)
6283    // that cannot occur in the in-memory runtime; this test instead exercises the
6284    // exact cleanup operations that the compensation branch performs, starting from
6285    // a manually-constructed partial state, and verifies the post-cleanup invariants.
6286    //
6287    // What this covers: the cleanup sequence (delete_edge, delete_note hard, FTS
6288    // index clean) is correct and leaves the DB in a pristine state. What it does
6289    // not cover: the trigger condition (second link failure). Storage-error injection
6290    // would require a mock GraphStore, which is beyond the current test infrastructure.
6291    #[tokio::test]
6292    async fn create_note_multi_annotates_compensation_cleanup_restores_pristine_state() {
6293        let rt = rt();
6294        let tok = NamespaceToken::local();
6295        let t1 = rt
6296            .create_entity(&tok, "concept", None, "T1", None, None, vec![])
6297            .await
6298            .unwrap();
6299
6300        // Construct the partial state that the compensation branch would encounter:
6301        // note persisted + first annotates edge created.
6302        let note = rt
6303            .create_note(
6304                &tok,
6305                "observation",
6306                None,
6307                "partial note",
6308                Some(0.5),
6309                None,
6310                vec![t1.id],
6311            )
6312            .await
6313            .unwrap();
6314
6315        // Confirm the partial state exists before compensation.
6316        let before_notes = rt.list_notes(&tok, None, 1000, 0).await.unwrap();
6317        assert_eq!(before_notes.len(), 1, "note must be present before cleanup");
6318        let before_edges = rt
6319            .neighbors(
6320                &tok,
6321                note.id,
6322                Direction::Out,
6323                None,
6324                Some(vec![EdgeRelation::Annotates]),
6325            )
6326            .await
6327            .unwrap();
6328        assert_eq!(
6329            before_edges.len(),
6330            1,
6331            "one annotates edge must exist before cleanup"
6332        );
6333        let edge_id: Uuid = before_edges[0].edge_id;
6334
6335        // Execute the same cleanup sequence that `create_note_inner`'s Err branch runs.
6336        rt.delete_edge(&tok, edge_id, true).await.unwrap();
6337        rt.delete_note(&tok, note.id, true /* hard */)
6338            .await
6339            .unwrap();
6340
6341        // Post-compensation invariants:
6342        let after_notes = rt.list_notes(&tok, None, 1000, 0).await.unwrap();
6343        assert!(
6344            after_notes.is_empty(),
6345            "compensation must remove the note row; got {after_notes:?}"
6346        );
6347        let search_hits = rt
6348            .search_notes(&tok, "partial note", None, 10, None, false, &[], None)
6349            .await
6350            .unwrap();
6351        assert!(
6352            search_hits.is_empty(),
6353            "compensation must clean the FTS index; got {search_hits:?}"
6354        );
6355        let after_edges = rt
6356            .neighbors(&tok, note.id, Direction::Out, None, None)
6357            .await
6358            .unwrap();
6359        assert!(
6360            after_edges.is_empty(),
6361            "compensation must remove all partial edges; got {after_edges:?}"
6362        );
6363    }
6364
6365    // ---- Hard-delete cascade for note and edge annotation targets (fix/annotates) ----
6366
6367    // annotates is note → ANYTHING (entity, note, edge, event);
6368    // targets may be entity, edge, event, or note.
6369    // Hard-deleting any of those targets must cascade incident annotates edges.
6370    // Soft deletes leave edges (data-vs-view rule).
6371
6372    #[tokio::test]
6373    async fn annotated_entity_hard_delete_cascades_annotate_edge() {
6374        let rt = rt();
6375        let tok = NamespaceToken::local();
6376        let entity = rt
6377            .create_entity(&tok, "concept", None, "E", None, None, vec![])
6378            .await
6379            .unwrap();
6380        let note = rt
6381            .create_note(
6382                &tok,
6383                "observation",
6384                None,
6385                "note about entity",
6386                Some(0.5),
6387                None,
6388                vec![entity.id],
6389            )
6390            .await
6391            .unwrap();
6392
6393        // Confirm edge exists before delete.
6394        let before = rt
6395            .neighbors(
6396                &tok,
6397                note.id,
6398                Direction::Out,
6399                None,
6400                Some(vec![EdgeRelation::Annotates]),
6401            )
6402            .await
6403            .unwrap();
6404        assert_eq!(
6405            before.len(),
6406            1,
6407            "annotates edge must exist before entity delete"
6408        );
6409
6410        // Hard delete the entity.
6411        let deleted = rt.delete_entity(&tok, entity.id, true).await.unwrap();
6412        assert!(deleted, "entity hard delete must return true");
6413
6414        // Annotates edge must be gone.
6415        let after = rt
6416            .neighbors(
6417                &tok,
6418                note.id,
6419                Direction::Out,
6420                None,
6421                Some(vec![EdgeRelation::Annotates]),
6422            )
6423            .await
6424            .unwrap();
6425        assert!(
6426            after.is_empty(),
6427            "annotates edge must be cascaded on entity hard delete; got {after:?}"
6428        );
6429    }
6430
6431    #[tokio::test]
6432    async fn annotated_note_hard_delete_cascades_annotate_edge() {
6433        let rt = rt();
6434        let tok = NamespaceToken::local();
6435        // note_target is the thing being annotated (a note itself).
6436        let note_target = rt
6437            .create_note(
6438                &tok,
6439                "observation",
6440                None,
6441                "target note",
6442                Some(0.5),
6443                None,
6444                vec![],
6445            )
6446            .await
6447            .unwrap();
6448        // note_source annotates note_target.
6449        let note_source = rt
6450            .create_note(
6451                &tok,
6452                "insight",
6453                None,
6454                "annotation",
6455                Some(0.5),
6456                None,
6457                vec![note_target.id],
6458            )
6459            .await
6460            .unwrap();
6461
6462        let before = rt
6463            .neighbors(
6464                &tok,
6465                note_source.id,
6466                Direction::Out,
6467                None,
6468                Some(vec![EdgeRelation::Annotates]),
6469            )
6470            .await
6471            .unwrap();
6472        assert_eq!(
6473            before.len(),
6474            1,
6475            "annotates edge must exist before note delete"
6476        );
6477
6478        // Hard delete the annotation TARGET note.
6479        let deleted = rt.delete_note(&tok, note_target.id, true).await.unwrap();
6480        assert!(deleted, "note hard delete must return true");
6481
6482        // The annotates edge targeting note_target must be gone.
6483        let after = rt
6484            .neighbors(
6485                &tok,
6486                note_source.id,
6487                Direction::Out,
6488                None,
6489                Some(vec![EdgeRelation::Annotates]),
6490            )
6491            .await
6492            .unwrap();
6493        assert!(
6494            after.is_empty(),
6495            "annotates edge must be cascaded on note-target hard delete; got {after:?}"
6496        );
6497    }
6498
6499    #[tokio::test]
6500    async fn annotated_edge_delete_cascades_annotate_edge() {
6501        let rt = rt();
6502        let tok = NamespaceToken::local();
6503        let a = rt
6504            .create_entity(&tok, "concept", None, "A", None, None, vec![])
6505            .await
6506            .unwrap();
6507        let b = rt
6508            .create_entity(&tok, "concept", None, "B", None, None, vec![])
6509            .await
6510            .unwrap();
6511        // Create an edge to annotate.
6512        let base_edge = rt
6513            .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
6514            .await
6515            .unwrap();
6516        let base_edge_uuid: Uuid = base_edge.id.into();
6517
6518        // Create a note that annotates the edge.
6519        let note = rt
6520            .create_note(
6521                &tok,
6522                "observation",
6523                None,
6524                "note about edge",
6525                Some(0.5),
6526                None,
6527                vec![base_edge_uuid],
6528            )
6529            .await
6530            .unwrap();
6531
6532        let before = rt
6533            .neighbors(
6534                &tok,
6535                note.id,
6536                Direction::Out,
6537                None,
6538                Some(vec![EdgeRelation::Annotates]),
6539            )
6540            .await
6541            .unwrap();
6542        assert_eq!(
6543            before.len(),
6544            1,
6545            "annotates edge must exist before base edge delete"
6546        );
6547
6548        // Delete the base edge.
6549        let deleted = rt.delete_edge(&tok, base_edge_uuid, true).await.unwrap();
6550        assert!(deleted, "edge delete must return true");
6551
6552        // The annotates edge targeting base_edge must be gone.
6553        let after = rt
6554            .neighbors(
6555                &tok,
6556                note.id,
6557                Direction::Out,
6558                None,
6559                Some(vec![EdgeRelation::Annotates]),
6560            )
6561            .await
6562            .unwrap();
6563        assert!(
6564            after.is_empty(),
6565            "annotates edge must be cascaded on base edge delete; got {after:?}"
6566        );
6567    }
6568
6569    #[tokio::test]
6570    async fn mixed_multi_annotates_partial_target_hard_delete_leaves_remaining_edges() {
6571        let rt = rt();
6572        let tok = NamespaceToken::local();
6573        let t1 = rt
6574            .create_entity(&tok, "concept", None, "T1", None, None, vec![])
6575            .await
6576            .unwrap();
6577        let t2 = rt
6578            .create_entity(&tok, "concept", None, "T2", None, None, vec![])
6579            .await
6580            .unwrap();
6581
6582        // Note annotates both t1 and t2.
6583        let note = rt
6584            .create_note(
6585                &tok,
6586                "observation",
6587                None,
6588                "multi-target note",
6589                Some(0.5),
6590                None,
6591                vec![t1.id, t2.id],
6592            )
6593            .await
6594            .unwrap();
6595
6596        let before = rt
6597            .neighbors(
6598                &tok,
6599                note.id,
6600                Direction::Out,
6601                None,
6602                Some(vec![EdgeRelation::Annotates]),
6603            )
6604            .await
6605            .unwrap();
6606        assert_eq!(
6607            before.len(),
6608            2,
6609            "must have 2 annotates edges before any delete"
6610        );
6611
6612        // Hard delete only t1.
6613        rt.delete_entity(&tok, t1.id, true).await.unwrap();
6614
6615        // Edge to t1 must be gone, edge to t2 must remain.
6616        let after = rt
6617            .neighbors(
6618                &tok,
6619                note.id,
6620                Direction::Out,
6621                None,
6622                Some(vec![EdgeRelation::Annotates]),
6623            )
6624            .await
6625            .unwrap();
6626        assert_eq!(
6627            after.len(),
6628            1,
6629            "only the edge to t1 must be cascaded; t2 edge must remain"
6630        );
6631        assert_eq!(
6632            after[0].node_id, t2.id,
6633            "remaining annotates edge must point to t2"
6634        );
6635    }
6636
6637    #[tokio::test]
6638    async fn annotated_note_soft_delete_preserves_annotate_edge() {
6639        let rt = rt();
6640        let tok = NamespaceToken::local();
6641        let note_target = rt
6642            .create_note(&tok, "observation", None, "target", Some(0.5), None, vec![])
6643            .await
6644            .unwrap();
6645        let note_source = rt
6646            .create_note(
6647                &tok,
6648                "insight",
6649                None,
6650                "annotation",
6651                Some(0.5),
6652                None,
6653                vec![note_target.id],
6654            )
6655            .await
6656            .unwrap();
6657
6658        let before = rt
6659            .neighbors(
6660                &tok,
6661                note_source.id,
6662                Direction::Out,
6663                None,
6664                Some(vec![EdgeRelation::Annotates]),
6665            )
6666            .await
6667            .unwrap();
6668        assert_eq!(before.len(), 1);
6669
6670        // Soft delete must NOT cascade edges (data-vs-view principle).
6671        let deleted = rt.delete_note(&tok, note_target.id, false).await.unwrap();
6672        assert!(deleted, "soft delete must return true");
6673
6674        let after = rt
6675            .neighbors(
6676                &tok,
6677                note_source.id,
6678                Direction::Out,
6679                None,
6680                Some(vec![EdgeRelation::Annotates]),
6681            )
6682            .await
6683            .unwrap();
6684        assert_eq!(
6685            after.len(),
6686            1,
6687            "soft delete must NOT cascade edges; got {after:?}"
6688        );
6689    }
6690
6691    // ---- delete_edge public-API safety (fix/annotates round-3) ----
6692
6693    // Passing an entity/note UUID to `delete_edge` must return Ok(false) with no
6694    // side effects — it must NOT delete inbound annotates edges targeting that record.
6695    // Without the get_edge guard, the old code would cascade inbound edges before
6696    // returning false.
6697    #[tokio::test]
6698    async fn delete_edge_non_edge_uuid_has_no_side_effects() {
6699        let rt = rt();
6700        let tok = NamespaceToken::local();
6701
6702        // Create an entity that has an inbound annotates edge.
6703        let entity = rt
6704            .create_entity(&tok, "concept", None, "Target", None, None, vec![])
6705            .await
6706            .unwrap();
6707        let note = rt
6708            .create_note(
6709                &tok,
6710                "observation",
6711                None,
6712                "annotates the entity",
6713                Some(0.5),
6714                None,
6715                vec![entity.id],
6716            )
6717            .await
6718            .unwrap();
6719
6720        // Confirm the annotates edge exists.
6721        let before = rt
6722            .neighbors(
6723                &tok,
6724                note.id,
6725                Direction::Out,
6726                None,
6727                Some(vec![EdgeRelation::Annotates]),
6728            )
6729            .await
6730            .unwrap();
6731        assert_eq!(before.len(), 1, "annotates edge must exist before test");
6732        let annotates_edge_id: Uuid = before[0].edge_id;
6733
6734        // Call delete_edge with the entity UUID (NOT an edge UUID).
6735        let result = rt.delete_edge(&tok, entity.id, true).await;
6736        assert!(
6737            result.is_ok(),
6738            "delete_edge must not error on a non-edge UUID"
6739        );
6740        assert!(
6741            !result.unwrap(),
6742            "delete_edge must return false for a non-edge UUID"
6743        );
6744
6745        // The inbound annotates edge to the entity must still exist — no side effects.
6746        let after = rt
6747            .neighbors(
6748                &tok,
6749                note.id,
6750                Direction::Out,
6751                None,
6752                Some(vec![EdgeRelation::Annotates]),
6753            )
6754            .await
6755            .unwrap();
6756        assert_eq!(
6757            after.len(),
6758            1,
6759            "delete_edge with a non-edge UUID must not touch inbound annotates edges"
6760        );
6761        assert_eq!(
6762            after[0].edge_id, annotates_edge_id,
6763            "the original annotates edge must be unchanged"
6764        );
6765    }
6766
6767    // ---- create_note compensation branch (fix/annotates round-3) ----
6768
6769    // This test injects a deterministic failure on the second `link` call inside
6770    // `create_note_inner` (the one that would create the second annotates edge).
6771    // It verifies that the compensation branch is wired — i.e. this test would
6772    // fail if the `Err(e)` rollback arm at operations.rs were deleted.
6773    //
6774    // Injection mechanism: LINK_FAIL_AFTER thread-local (ops.rs, cfg(test) only).
6775    // Setting it to 2 forces the 2nd link call to return an error.  The counter is
6776    // reset to 0 once triggered, so no other test is affected.
6777    #[tokio::test]
6778    async fn create_note_multi_annotates_second_link_failure_rolls_back_partial_write() {
6779        let rt = rt();
6780        let tok = NamespaceToken::local();
6781        let t1 = rt
6782            .create_entity(&tok, "concept", None, "T1", None, None, vec![])
6783            .await
6784            .unwrap();
6785        let t2 = rt
6786            .create_entity(&tok, "concept", None, "T2", None, None, vec![])
6787            .await
6788            .unwrap();
6789
6790        // Arm the injection: fail on the 2nd link (link_idx+1 == 2).
6791        LINK_FAIL_AFTER.with(|cell| cell.set(2));
6792
6793        let result = rt
6794            .create_note(
6795                &tok,
6796                "observation",
6797                None,
6798                "rollback target",
6799                Some(0.5),
6800                None,
6801                vec![t1.id, t2.id],
6802            )
6803            .await;
6804
6805        // The call must fail with the injected error.
6806        assert!(
6807            result.is_err(),
6808            "create_note must propagate the injected link failure"
6809        );
6810        let err_msg = result.unwrap_err().to_string();
6811        assert!(
6812            err_msg.contains("injected link failure"),
6813            "error must carry injection message; got: {err_msg}"
6814        );
6815
6816        // Compensation must have removed the note row.
6817        let notes = rt.list_notes(&tok, None, 1000, 0).await.unwrap();
6818        assert!(
6819            notes.is_empty(),
6820            "compensation must remove the note row; got {notes:?}"
6821        );
6822
6823        // FTS must have no hit for the content.
6824        let hits = rt
6825            .search_notes(&tok, "rollback target", None, 10, None, false, &[], None)
6826            .await
6827            .unwrap();
6828        assert!(
6829            hits.is_empty(),
6830            "compensation must clean FTS index; got {hits:?}"
6831        );
6832
6833        // No partial annotates edges must remain (first edge must have been deleted).
6834        let edges_from_t1 = rt
6835            .neighbors(
6836                &tok,
6837                t1.id,
6838                Direction::In,
6839                None,
6840                Some(vec![EdgeRelation::Annotates]),
6841            )
6842            .await
6843            .unwrap();
6844        let edges_from_t2 = rt
6845            .neighbors(
6846                &tok,
6847                t2.id,
6848                Direction::In,
6849                None,
6850                Some(vec![EdgeRelation::Annotates]),
6851            )
6852            .await
6853            .unwrap();
6854        assert!(
6855            edges_from_t1.is_empty(),
6856            "compensation must delete the first annotates edge; got {edges_from_t1:?}"
6857        );
6858        assert!(
6859            edges_from_t2.is_empty(),
6860            "no second annotates edge must exist; got {edges_from_t2:?}"
6861        );
6862    }
6863
6864    // Inject an FTS failure after the note row is committed and assert the note
6865    // row is removed (no stranded row).  arm_fts_fail() arms the flag before
6866    // the call and it resets automatically after one trigger.
6867    #[tokio::test]
6868    async fn create_note_fts_failure_rolls_back_note_row() {
6869        let rt = rt();
6870        // Unique namespace: the process-global FTS_FAIL_NS one-shot flag armed
6871        // below must be consumable only by THIS test's create_note. Sharing the
6872        // "local" namespace let a concurrent "local" create_note consume the
6873        // armed flag, flaking this test and its victim under parallel
6874        // `cargo test` (latent since #129; surfaced by #131 CI timing).
6875        let ns = Namespace::parse("fault-fts-rollback").unwrap();
6876        let tok = NamespaceToken::for_namespace(ns.clone());
6877
6878        arm_fts_fail(ns.as_str());
6879
6880        let result = rt
6881            .create_note(
6882                &tok,
6883                "observation",
6884                None,
6885                "fts-fail rollback target",
6886                None,
6887                None,
6888                vec![],
6889            )
6890            .await;
6891
6892        assert!(
6893            result.is_err(),
6894            "create_note must propagate the injected FTS failure"
6895        );
6896        let err_msg = result.unwrap_err().to_string();
6897        assert!(
6898            err_msg.contains("injected FTS failure"),
6899            "error must carry injection message; got: {err_msg}"
6900        );
6901
6902        // Compensation must have removed the note row.
6903        let notes = rt.list_notes(&tok, None, 1000, 0).await.unwrap();
6904        assert!(
6905            notes.is_empty(),
6906            "compensation must remove the note row after FTS failure; got {notes:?}"
6907        );
6908    }
6909
6910    // Inject a vector insertion failure after note row + FTS commit and assert
6911    // both the note row and the FTS document are removed (no stranded rows).
6912    // Uses a unique namespace (see create_note_fts_failure_rolls_back_note_row)
6913    // so the process-global VECTOR_FAIL_NS flag is consumed only by this test.
6914    // Since the single registered provider fires embed_document before the
6915    // injection check, the injection converts the successful embedding into an
6916    // error just before the VectorStore insert, then disarms.
6917    #[tokio::test]
6918    async fn create_note_vector_failure_rolls_back_note_row_and_fts() {
6919        const MODEL: &str = "test-vec-inject";
6920        const DIMS: usize = 4;
6921
6922        let rt = KhiveRuntime::memory().unwrap();
6923        let (provider, _counter) = ConstVecProvider::new(MODEL, DIMS);
6924        rt.register_embedder(provider);
6925
6926        let ns = Namespace::parse("fault-vec-rollback").unwrap();
6927        let tok = NamespaceToken::for_namespace(ns.clone());
6928
6929        arm_vector_fail(ns.as_str());
6930
6931        let result = rt
6932            .create_note(
6933                &tok,
6934                "observation",
6935                None,
6936                "vec-fail rollback target",
6937                None,
6938                None,
6939                vec![],
6940            )
6941            .await;
6942
6943        assert!(
6944            result.is_err(),
6945            "create_note must propagate the injected vector failure"
6946        );
6947        let err_msg = result.unwrap_err().to_string();
6948        assert!(
6949            err_msg.contains("injected vector failure"),
6950            "error must carry injection message; got: {err_msg}"
6951        );
6952
6953        // Compensation must have removed the note row.
6954        let notes = rt.list_notes(&tok, None, 1000, 0).await.unwrap();
6955        assert!(
6956            notes.is_empty(),
6957            "compensation must remove note row after vector failure; got {notes:?}"
6958        );
6959    }
6960
6961    // ---- #232 soft-delete index cleanup tests ----
6962
6963    #[tokio::test]
6964    async fn soft_delete_entity_removes_indexes() {
6965        let rt = rt();
6966        let tok = NamespaceToken::local();
6967        let entity = rt
6968            .create_entity(
6969                &tok,
6970                "concept",
6971                None,
6972                "QuantumEntanglement",
6973                Some("unique FTS term xzqjwv for soft delete test"),
6974                None,
6975                vec![],
6976            )
6977            .await
6978            .unwrap();
6979
6980        let ns = tok.namespace().as_str().to_string();
6981
6982        let before = rt
6983            .text(&tok)
6984            .unwrap()
6985            .search(TextSearchRequest {
6986                query: "xzqjwv".to_string(),
6987                mode: TextQueryMode::Plain,
6988                filter: Some(TextFilter {
6989                    namespaces: vec![ns.clone()],
6990                    ..Default::default()
6991                }),
6992                top_k: 10,
6993                snippet_chars: 100,
6994            })
6995            .await
6996            .unwrap();
6997        assert!(
6998            before.iter().any(|h| h.subject_id == entity.id),
6999            "entity must be in FTS before soft-delete"
7000        );
7001
7002        let deleted = rt.delete_entity(&tok, entity.id, false).await.unwrap();
7003        assert!(deleted, "soft delete must return true");
7004
7005        let after = rt
7006            .text(&tok)
7007            .unwrap()
7008            .search(TextSearchRequest {
7009                query: "xzqjwv".to_string(),
7010                mode: TextQueryMode::Plain,
7011                filter: Some(TextFilter {
7012                    namespaces: vec![ns],
7013                    ..Default::default()
7014                }),
7015                top_k: 10,
7016                snippet_chars: 100,
7017            })
7018            .await
7019            .unwrap();
7020        assert!(
7021            after.iter().all(|h| h.subject_id != entity.id),
7022            "soft-deleted entity must be removed from FTS index"
7023        );
7024    }
7025
7026    #[tokio::test]
7027    async fn soft_delete_note_removes_indexes() {
7028        let rt = rt();
7029        let tok = NamespaceToken::local();
7030        let note = rt
7031            .create_note(
7032                &tok,
7033                "observation",
7034                None,
7035                "SpectralDecomposition unique term yvwkqz for soft delete test",
7036                Some(0.7),
7037                None,
7038                vec![],
7039            )
7040            .await
7041            .unwrap();
7042
7043        let before = rt
7044            .search_notes(&tok, "yvwkqz", None, 10, None, false, &[], None)
7045            .await
7046            .unwrap();
7047        assert!(
7048            before.iter().any(|h| h.note_id == note.id),
7049            "note must be in FTS before soft-delete"
7050        );
7051
7052        let deleted = rt.delete_note(&tok, note.id, false).await.unwrap();
7053        assert!(deleted, "soft delete must return true");
7054
7055        let after = rt
7056            .search_notes(&tok, "yvwkqz", None, 10, None, false, &[], None)
7057            .await
7058            .unwrap();
7059        assert!(
7060            after.iter().all(|h| h.note_id != note.id),
7061            "soft-deleted note must be removed from FTS index"
7062        );
7063    }
7064
7065    // F010 (CRIT): base endpoint allowlist — unlisted triples must fail closed.
7066    // Document->Document Extends is not in the allowlist; current generic fallthrough accepts it.
7067    #[tokio::test]
7068    async fn link_extends_document_to_document_returns_invalid_input() {
7069        let rt = rt();
7070        let tok = NamespaceToken::local();
7071        let d1 = rt
7072            .create_entity(&tok, "document", None, "DocA", None, None, vec![])
7073            .await
7074            .unwrap();
7075        let d2 = rt
7076            .create_entity(&tok, "document", None, "DocB", None, None, vec![])
7077            .await
7078            .unwrap();
7079        let result = rt
7080            .link(&tok, d1.id, d2.id, EdgeRelation::Extends, 1.0, None)
7081            .await;
7082        assert!(
7083            result.is_err(),
7084            "F010: document->document Extends must be rejected by the base allowlist; \
7085             current generic entity fallthrough incorrectly accepts it"
7086        );
7087    }
7088
7089    // F010 happy path: Concept->Concept Extends is in the base allowlist and must succeed.
7090    #[tokio::test]
7091    async fn link_extends_concept_to_concept_succeeds() {
7092        let rt = rt();
7093        let tok = NamespaceToken::local();
7094        let a = rt
7095            .create_entity(&tok, "concept", None, "CA", None, None, vec![])
7096            .await
7097            .unwrap();
7098        let b = rt
7099            .create_entity(&tok, "concept", None, "CB", None, None, vec![])
7100            .await
7101            .unwrap();
7102        let result = rt
7103            .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
7104            .await;
7105        assert!(
7106            result.is_ok(),
7107            "F010: concept->concept Extends must be allowed (base allowlist)"
7108        );
7109    }
7110
7111    // F012 (CRIT): CompetesWith is symmetric; reversed pair must deduplicate to one canonical row.
7112    // Current code stores both directions as distinct rows (no canonicalization).
7113    #[tokio::test]
7114    async fn link_symmetric_relation_canonicalizes_endpoint_order() {
7115        use khive_storage::EdgeFilter;
7116        let rt = rt();
7117        let tok = NamespaceToken::local();
7118        let a = rt
7119            .create_entity(&tok, "concept", None, "ConceptP", None, None, vec![])
7120            .await
7121            .unwrap();
7122        let b = rt
7123            .create_entity(&tok, "concept", None, "ConceptQ", None, None, vec![])
7124            .await
7125            .unwrap();
7126        // Link A->B then B->A with the same symmetric relation.
7127        rt.link(&tok, a.id, b.id, EdgeRelation::CompetesWith, 1.0, None)
7128            .await
7129            .unwrap();
7130        rt.link(&tok, b.id, a.id, EdgeRelation::CompetesWith, 1.0, None)
7131            .await
7132            .unwrap();
7133        let count = rt
7134            .graph(&tok)
7135            .unwrap()
7136            .count_edges(EdgeFilter::default())
7137            .await
7138            .unwrap();
7139        assert_eq!(
7140            count,
7141            1,
7142            "F012: CompetesWith is symmetric; A->B and B->A must deduplicate to one canonical row; \
7143             found {count} rows (canonicalization not yet implemented)"
7144        );
7145    }
7146
7147    // F010: Supersedes — positive tests for all 5 allowed entity kinds.
7148    #[tokio::test]
7149    async fn f010_supersedes_document_to_document_allowed() {
7150        let rt = rt();
7151        let tok = NamespaceToken::local();
7152        let a = rt
7153            .create_entity(&tok, "document", None, "DocA", None, None, vec![])
7154            .await
7155            .unwrap();
7156        let b = rt
7157            .create_entity(&tok, "document", None, "DocB", None, None, vec![])
7158            .await
7159            .unwrap();
7160        let result = rt
7161            .link(&tok, b.id, a.id, EdgeRelation::Supersedes, 1.0, None)
7162            .await;
7163        assert!(
7164            result.is_ok(),
7165            "document->document Supersedes must be allowed (allowlist), got {result:?}"
7166        );
7167    }
7168
7169    #[tokio::test]
7170    async fn f010_supersedes_artifact_to_artifact_allowed() {
7171        let rt = rt();
7172        let tok = NamespaceToken::local();
7173        let a = rt
7174            .create_entity(&tok, "artifact", None, "ArtA", None, None, vec![])
7175            .await
7176            .unwrap();
7177        let b = rt
7178            .create_entity(&tok, "artifact", None, "ArtB", None, None, vec![])
7179            .await
7180            .unwrap();
7181        let result = rt
7182            .link(&tok, b.id, a.id, EdgeRelation::Supersedes, 1.0, None)
7183            .await;
7184        assert!(
7185            result.is_ok(),
7186            "artifact->artifact Supersedes must be allowed (allowlist), got {result:?}"
7187        );
7188    }
7189
7190    #[tokio::test]
7191    async fn f010_supersedes_service_to_service_allowed() {
7192        let rt = rt();
7193        let tok = NamespaceToken::local();
7194        let a = rt
7195            .create_entity(&tok, "service", None, "SvcA", None, None, vec![])
7196            .await
7197            .unwrap();
7198        let b = rt
7199            .create_entity(&tok, "service", None, "SvcB", None, None, vec![])
7200            .await
7201            .unwrap();
7202        let result = rt
7203            .link(&tok, b.id, a.id, EdgeRelation::Supersedes, 1.0, None)
7204            .await;
7205        assert!(
7206            result.is_ok(),
7207            "service->service Supersedes must be allowed (allowlist), got {result:?}"
7208        );
7209    }
7210
7211    #[tokio::test]
7212    async fn f010_supersedes_dataset_to_dataset_allowed() {
7213        let rt = rt();
7214        let tok = NamespaceToken::local();
7215        let a = rt
7216            .create_entity(&tok, "dataset", None, "DataA", None, None, vec![])
7217            .await
7218            .unwrap();
7219        let b = rt
7220            .create_entity(&tok, "dataset", None, "DataB", None, None, vec![])
7221            .await
7222            .unwrap();
7223        let result = rt
7224            .link(&tok, b.id, a.id, EdgeRelation::Supersedes, 1.0, None)
7225            .await;
7226        assert!(
7227            result.is_ok(),
7228            "dataset->dataset Supersedes must be allowed (allowlist), got {result:?}"
7229        );
7230    }
7231
7232    // F010: Supersedes — negative tests for rejected entity kinds.
7233    #[tokio::test]
7234    async fn f010_supersedes_project_to_project_rejected() {
7235        let rt = rt();
7236        let tok = NamespaceToken::local();
7237        let a = rt
7238            .create_entity(&tok, "project", None, "ProjA", None, None, vec![])
7239            .await
7240            .unwrap();
7241        let b = rt
7242            .create_entity(&tok, "project", None, "ProjB", None, None, vec![])
7243            .await
7244            .unwrap();
7245        let result = rt
7246            .link(&tok, b.id, a.id, EdgeRelation::Supersedes, 1.0, None)
7247            .await;
7248        assert!(
7249            matches!(result, Err(RuntimeError::InvalidInput(_))),
7250            "project->project Supersedes must be rejected (not in allowlist), got {result:?}"
7251        );
7252    }
7253
7254    #[tokio::test]
7255    async fn f010_supersedes_person_to_person_rejected() {
7256        let rt = rt();
7257        let tok = NamespaceToken::local();
7258        let a = rt
7259            .create_entity(&tok, "person", None, "Alice", None, None, vec![])
7260            .await
7261            .unwrap();
7262        let b = rt
7263            .create_entity(&tok, "person", None, "Bob", None, None, vec![])
7264            .await
7265            .unwrap();
7266        let result = rt
7267            .link(&tok, b.id, a.id, EdgeRelation::Supersedes, 1.0, None)
7268            .await;
7269        assert!(
7270            matches!(result, Err(RuntimeError::InvalidInput(_))),
7271            "person->person Supersedes must be rejected (not in allowlist), got {result:?}"
7272        );
7273    }
7274
7275    #[tokio::test]
7276    async fn f010_supersedes_org_to_org_rejected() {
7277        let rt = rt();
7278        let tok = NamespaceToken::local();
7279        let a = rt
7280            .create_entity(&tok, "org", None, "OrgA", None, None, vec![])
7281            .await
7282            .unwrap();
7283        let b = rt
7284            .create_entity(&tok, "org", None, "OrgB", None, None, vec![])
7285            .await
7286            .unwrap();
7287        let result = rt
7288            .link(&tok, b.id, a.id, EdgeRelation::Supersedes, 1.0, None)
7289            .await;
7290        assert!(
7291            matches!(result, Err(RuntimeError::InvalidInput(_))),
7292            "org->org Supersedes must be rejected (not in allowlist), got {result:?}"
7293        );
7294    }
7295
7296    // Fix 1: Supersedes entity→entity — same kind (concept→concept) must be allowed.
7297    #[tokio::test]
7298    async fn f010_supersedes_same_kind_entity_allowed() {
7299        let rt = rt();
7300        let tok = NamespaceToken::local();
7301        let a = rt
7302            .create_entity(&tok, "concept", None, "OldV", None, None, vec![])
7303            .await
7304            .unwrap();
7305        let b = rt
7306            .create_entity(&tok, "concept", None, "NewV", None, None, vec![])
7307            .await
7308            .unwrap();
7309        let result = rt
7310            .link(&tok, b.id, a.id, EdgeRelation::Supersedes, 1.0, None)
7311            .await;
7312        assert!(
7313            result.is_ok(),
7314            "concept->concept Supersedes must be allowed by the base allowlist, got {result:?}"
7315        );
7316    }
7317
7318    // F161: target_backend invariant — all edges written through link() must have
7319    // target_backend = None because validate_edge_relation_endpoints already ensured the
7320    // target exists locally.
7321    #[tokio::test]
7322    async fn f161_link_always_writes_null_target_backend() {
7323        let rt = rt();
7324        let tok = NamespaceToken::local();
7325        let a = rt
7326            .create_entity(&tok, "concept", None, "A", None, None, vec![])
7327            .await
7328            .unwrap();
7329        let b = rt
7330            .create_entity(&tok, "concept", None, "B", None, None, vec![])
7331            .await
7332            .unwrap();
7333        let edge = rt
7334            .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
7335            .await
7336            .unwrap();
7337        assert!(
7338            edge.target_backend.is_none(),
7339            "F161: target_backend must be None for locally-routed edges; got {:?}",
7340            edge.target_backend
7341        );
7342    }
7343
7344    // F161: link_many must also write null target_backend for all local edges.
7345    #[tokio::test]
7346    async fn f161_link_many_always_writes_null_target_backend() {
7347        let rt = rt();
7348        let tok = NamespaceToken::local();
7349        let a = rt
7350            .create_entity(&tok, "concept", None, "A", None, None, vec![])
7351            .await
7352            .unwrap();
7353        let b = rt
7354            .create_entity(&tok, "concept", None, "B", None, None, vec![])
7355            .await
7356            .unwrap();
7357        let c = rt
7358            .create_entity(&tok, "concept", None, "C", None, None, vec![])
7359            .await
7360            .unwrap();
7361        let specs = vec![
7362            LinkSpec {
7363                namespace: None,
7364                source_id: a.id,
7365                target_id: b.id,
7366                relation: EdgeRelation::Extends,
7367                weight: 1.0,
7368                metadata: None,
7369            },
7370            LinkSpec {
7371                namespace: None,
7372                source_id: a.id,
7373                target_id: c.id,
7374                relation: EdgeRelation::Enables,
7375                weight: 1.0,
7376                metadata: None,
7377            },
7378        ];
7379        let edges = rt.link_many(&tok, specs).await.unwrap();
7380        for edge in &edges {
7381            assert!(
7382                edge.target_backend.is_none(),
7383                "F161: target_backend must be None for locally-routed edges in link_many; got {:?}",
7384                edge.target_backend
7385            );
7386        }
7387    }
7388
7389    // F012: symmetric relation neighbors — competes_with queried from the non-canonical
7390    // endpoint must still return results when direction=Out is requested.
7391    #[tokio::test]
7392    async fn f012_symmetric_neighbors_visible_from_both_endpoints() {
7393        let rt = rt();
7394        let tok = NamespaceToken::local();
7395        let a = rt
7396            .create_entity(&tok, "concept", None, "A", None, None, vec![])
7397            .await
7398            .unwrap();
7399        let b = rt
7400            .create_entity(&tok, "concept", None, "B", None, None, vec![])
7401            .await
7402            .unwrap();
7403        // Link A→B competes_with; if A.id > B.id the edge is stored as B→A (canonical).
7404        rt.link(&tok, a.id, b.id, EdgeRelation::CompetesWith, 1.0, None)
7405            .await
7406            .unwrap();
7407        // Both endpoints should see the edge regardless of direction=Out.
7408        let from_a = rt
7409            .neighbors(
7410                &tok,
7411                a.id,
7412                Direction::Out,
7413                None,
7414                Some(vec![EdgeRelation::CompetesWith]),
7415            )
7416            .await
7417            .unwrap();
7418        let from_b = rt
7419            .neighbors(
7420                &tok,
7421                b.id,
7422                Direction::Out,
7423                None,
7424                Some(vec![EdgeRelation::CompetesWith]),
7425            )
7426            .await
7427            .unwrap();
7428        assert_eq!(
7429            from_a.len(),
7430            1,
7431            "node A must see competes_with neighbor from Direction::Out (F012); got {from_a:?}"
7432        );
7433        assert_eq!(
7434            from_b.len(),
7435            1,
7436            "node B must see competes_with neighbor from Direction::Out (F012); got {from_b:?}"
7437        );
7438    }
7439
7440    // Fix 1: Supersedes entity→entity — cross-kind (concept→document) must be rejected.
7441    #[tokio::test]
7442    async fn f010_supersedes_cross_kind_entity_rejected() {
7443        let rt = rt();
7444        let tok = NamespaceToken::local();
7445        let concept = rt
7446            .create_entity(&tok, "concept", None, "MyConcept", None, None, vec![])
7447            .await
7448            .unwrap();
7449        let doc = rt
7450            .create_entity(&tok, "document", None, "MyDoc", None, None, vec![])
7451            .await
7452            .unwrap();
7453        let result = rt
7454            .link(
7455                &tok,
7456                concept.id,
7457                doc.id,
7458                EdgeRelation::Supersedes,
7459                1.0,
7460                None,
7461            )
7462            .await;
7463        assert!(
7464            matches!(result, Err(RuntimeError::InvalidInput(_))),
7465            "concept->document Supersedes must be rejected by the base allowlist, got {result:?}"
7466        );
7467    }
7468
7469    // PR-A1: cross-namespace delete_note now succeeds (UUID v4 is globally unique,
7470    // no namespace isolation on by-ID ops — ADR-007 rule 2).
7471    #[tokio::test]
7472    async fn delete_note_cross_namespace_succeeds() {
7473        let rt = rt();
7474        let ns_a = NamespaceToken::for_namespace(Namespace::parse("ns-a").unwrap());
7475        let ns_b = NamespaceToken::for_namespace(Namespace::parse("ns-b").unwrap());
7476        let note = rt
7477            .create_note(
7478                &ns_a,
7479                "observation",
7480                None,
7481                "note in ns-a",
7482                Some(0.8),
7483                None,
7484                vec![],
7485            )
7486            .await
7487            .unwrap();
7488
7489        // Delete from a different namespace must now SUCCEED.
7490        let result = rt.delete_note(&ns_b, note.id, false).await;
7491        assert!(
7492            result.unwrap(),
7493            "cross-namespace delete_note (soft) must return Ok(true)"
7494        );
7495
7496        // Note must be gone from ns-a storage after the cross-ns soft delete.
7497        let note_store = rt.notes(&ns_a).unwrap();
7498        let gone = note_store.get_note(note.id).await.unwrap();
7499        assert!(
7500            gone.is_none(),
7501            "note must be soft-deleted in its home namespace after cross-ns delete"
7502        );
7503
7504        // Hard-delete path: create a fresh note and hard-delete from foreign token.
7505        let note2 = rt
7506            .create_note(
7507                &ns_a,
7508                "observation",
7509                None,
7510                "note2 in ns-a",
7511                Some(0.5),
7512                None,
7513                vec![],
7514            )
7515            .await
7516            .unwrap();
7517        let hard_result = rt.delete_note(&ns_b, note2.id, true).await;
7518        assert!(
7519            hard_result.unwrap(),
7520            "cross-namespace hard delete_note must return Ok(true)"
7521        );
7522        let gone2 = rt
7523            .get_note_including_deleted(&ns_a, note2.id)
7524            .await
7525            .unwrap();
7526        assert!(
7527            gone2.is_none(),
7528            "hard-deleted note must not appear even in including_deleted query"
7529        );
7530    }
7531
7532    // H1-bulk regression: parallel link_many calls with overlapping triples must
7533    // return the identical persisted edge ID, not locally-generated phantom IDs.
7534    //
7535    // Sequence:
7536    //   1. First link_many creates the A→B Extends edge (persisted with ID₁).
7537    //   2. Second link_many upserts the same triple (ON CONFLICT DO UPDATE keeps ID₁).
7538    //   3. Both callers must see ID₁ in their returned Vec<Edge>.
7539    #[tokio::test]
7540    async fn link_many_overlapping_triple_returns_persisted_ids() {
7541        let rt = rt();
7542        let tok = NamespaceToken::local();
7543        let a = rt
7544            .create_entity(&tok, "concept", None, "A", None, None, vec![])
7545            .await
7546            .unwrap();
7547        let b = rt
7548            .create_entity(&tok, "concept", None, "B", None, None, vec![])
7549            .await
7550            .unwrap();
7551
7552        let spec = || LinkSpec {
7553            namespace: None,
7554            source_id: a.id,
7555            target_id: b.id,
7556            relation: EdgeRelation::Extends,
7557            weight: 1.0,
7558            metadata: None,
7559        };
7560
7561        // First call — creates the edge.
7562        let first = rt.link_many(&tok, vec![spec()]).await.unwrap();
7563        assert_eq!(first.len(), 1);
7564        let persisted_id: Uuid = first[0].id.into();
7565
7566        // Second call — same natural-key triple; ON CONFLICT updates, preserving the
7567        // existing row ID. link_many must read back the row and return that same ID.
7568        let second = rt.link_many(&tok, vec![spec()]).await.unwrap();
7569        assert_eq!(second.len(), 1);
7570        let second_id: Uuid = second[0].id.into();
7571
7572        assert_eq!(
7573            persisted_id, second_id,
7574            "link_many with an existing triple must return the persisted row ID ({persisted_id}), \
7575             not a new phantom ID ({second_id})"
7576        );
7577
7578        // Confirm only one edge row exists in the graph store.
7579        let count = rt
7580            .count_edges(&tok, crate::curation::EdgeListFilter::default())
7581            .await
7582            .unwrap();
7583        assert_eq!(count, 1, "upsert must not duplicate the edge row");
7584    }
7585
7586    // ── create_many: batch entity creation ───────────────────────────────────
7587
7588    #[tokio::test]
7589    async fn create_many_persists_all_entities() {
7590        let rt = rt();
7591        let tok = NamespaceToken::local();
7592
7593        let specs: Vec<EntityCreateSpec> = (0..5)
7594            .map(|i| EntityCreateSpec {
7595                kind: "concept".into(),
7596                entity_type: None,
7597                name: format!("BulkConcept-{i}"),
7598                description: Some(format!("desc {i}")),
7599                properties: None,
7600                tags: vec!["bulk-test".into()],
7601            })
7602            .collect();
7603
7604        let entities = rt.create_many(&tok, specs).await.unwrap();
7605        assert_eq!(entities.len(), 5, "all 5 entities must be returned");
7606
7607        // Verify each one is retrievable from storage.
7608        for entity in &entities {
7609            let fetched = rt.get_entity(&tok, entity.id).await.unwrap();
7610            assert_eq!(fetched.id, entity.id);
7611        }
7612    }
7613
7614    #[tokio::test]
7615    async fn create_many_empty_name_rejects_atomically() {
7616        let rt = rt();
7617        let tok = NamespaceToken::local();
7618
7619        let specs = vec![
7620            EntityCreateSpec {
7621                kind: "concept".into(),
7622                entity_type: None,
7623                name: "ValidEntity".into(),
7624                description: None,
7625                properties: None,
7626                tags: vec![],
7627            },
7628            EntityCreateSpec {
7629                kind: "concept".into(),
7630                entity_type: None,
7631                name: "".into(), // invalid — triggers atomic rejection
7632                description: None,
7633                properties: None,
7634                tags: vec![],
7635            },
7636        ];
7637
7638        let result = rt.create_many(&tok, specs).await;
7639        assert!(
7640            matches!(result, Err(RuntimeError::InvalidInput(_))),
7641            "empty name must produce InvalidInput error"
7642        );
7643
7644        // Nothing must have been written — list_entities returns 0 items.
7645        let rows = rt.list_entities(&tok, None, None, 100, 0).await.unwrap();
7646        assert_eq!(
7647            rows.len(),
7648            0,
7649            "atomic rejection must leave storage unchanged"
7650        );
7651    }
7652
7653    // High-2: entity_type validated at runtime layer when validator is installed.
7654    #[tokio::test]
7655    async fn create_many_rejects_unknown_entity_type_when_validator_installed() {
7656        let rt = rt();
7657        let tok = NamespaceToken::local();
7658
7659        // Install a mock validator that only accepts "algorithm" for "concept".
7660        rt.install_entity_type_validator(Arc::new(|kind, entity_type| {
7661            let Some(raw) = entity_type else {
7662                return Ok(None);
7663            };
7664            if kind == "concept" && raw == "algorithm" {
7665                return Ok(Some("algorithm".to_string()));
7666            }
7667            Err(RuntimeError::InvalidInput(format!(
7668                "unknown entity_type {raw:?} for {kind:?}; valid: algorithm"
7669            )))
7670        }));
7671
7672        let bad_spec = vec![EntityCreateSpec {
7673            kind: "concept".into(),
7674            entity_type: Some("not_a_registered_type".into()),
7675            name: "ShouldNotLand".into(),
7676            description: None,
7677            properties: None,
7678            tags: vec![],
7679        }];
7680
7681        let result = rt.create_many(&tok, bad_spec).await;
7682        assert!(
7683            matches!(result, Err(RuntimeError::InvalidInput(_))),
7684            "unknown entity_type must be rejected by the runtime-layer validator; got {result:?}"
7685        );
7686
7687        // Zero rows written — validator fires before any storage call.
7688        let rows = rt.list_entities(&tok, None, None, 100, 0).await.unwrap();
7689        assert_eq!(
7690            rows.len(),
7691            0,
7692            "validator rejection must leave storage empty"
7693        );
7694    }
7695
7696    // High-2: valid entity_type passes through and is normalised by the validator.
7697    #[tokio::test]
7698    async fn create_many_accepts_valid_entity_type_via_validator() {
7699        let rt = rt();
7700        let tok = NamespaceToken::local();
7701
7702        rt.install_entity_type_validator(Arc::new(|kind, entity_type| {
7703            let Some(raw) = entity_type else {
7704                return Ok(None);
7705            };
7706            if kind == "concept" && raw == "algorithm" {
7707                return Ok(Some("algorithm".to_string()));
7708            }
7709            Err(RuntimeError::InvalidInput(format!(
7710                "unknown entity_type {raw:?} for {kind:?}"
7711            )))
7712        }));
7713
7714        let specs = vec![EntityCreateSpec {
7715            kind: "concept".into(),
7716            entity_type: Some("algorithm".into()),
7717            name: "BubbleSort".into(),
7718            description: None,
7719            properties: None,
7720            tags: vec![],
7721        }];
7722
7723        let entities = rt.create_many(&tok, specs).await.unwrap();
7724        assert_eq!(entities.len(), 1, "valid entity_type must be accepted");
7725        assert_eq!(
7726            entities[0].entity_type.as_deref(),
7727            Some("algorithm"),
7728            "entity_type must be stored as returned by the validator"
7729        );
7730    }
7731
7732    // High-1: FTS failure in create_many rolls back both substrates.
7733    //
7734    // Arm `arm_fts_fail_many` before the call; the FTS phase returns an injected
7735    // error; the test asserts zero rows in both `entities` and `fts_entities`.
7736    #[tokio::test]
7737    async fn create_many_fts_failure_rolls_back_both_substrates() {
7738        // Use a unique namespace so the process-global one-shot is unaffected by
7739        // other concurrent tests.
7740        let ns = format!("fts-fail-many-{}", uuid::Uuid::new_v4().as_simple());
7741        let rt = rt();
7742        let tok = NamespaceToken::for_namespace(Namespace::parse(&ns).unwrap());
7743
7744        let specs = vec![
7745            EntityCreateSpec {
7746                kind: "concept".into(),
7747                entity_type: None,
7748                name: "FtsRollbackA".into(),
7749                description: None,
7750                properties: None,
7751                tags: vec![],
7752            },
7753            EntityCreateSpec {
7754                kind: "concept".into(),
7755                entity_type: None,
7756                name: "FtsRollbackB".into(),
7757                description: None,
7758                properties: None,
7759                tags: vec![],
7760            },
7761        ];
7762
7763        arm_fts_fail_many(&ns);
7764        let result = rt.create_many(&tok, specs).await;
7765
7766        assert!(
7767            result.is_err(),
7768            "create_many must return Err when FTS write fails"
7769        );
7770
7771        // Entity substrate must be empty — entity rows must have been rolled back.
7772        let entity_rows = rt.list_entities(&tok, None, None, 100, 0).await.unwrap();
7773        assert_eq!(
7774            entity_rows.len(),
7775            0,
7776            "entity rows must be rolled back on FTS failure; found {entity_rows:?}"
7777        );
7778
7779        // FTS substrate must be empty — no stale fts_entities rows.
7780        let fts = rt.text(&tok).unwrap();
7781        let fts_count = fts
7782            .count(TextFilter {
7783                ids: vec![],
7784                kinds: vec![],
7785                namespaces: vec![ns.clone()],
7786            })
7787            .await
7788            .unwrap();
7789        assert_eq!(
7790            fts_count, 0,
7791            "fts_entities must be empty after FTS-failure rollback; found {fts_count}"
7792        );
7793    }
7794
7795    // Round-3 Medium: FTS partial-failure (Ok(summary) with summary.failed > 0)
7796    // rolls back both substrates.
7797    //
7798    // The production code has a distinct arm:
7799    //   Ok(summary) if summary.failed > 0 => return Err(...)
7800    // This test exercises that arm by arming `arm_fts_fail_many_partial`, which
7801    // returns Ok(BatchWriteSummary { failed: 1, ... }) instead of a hard Err.
7802    // Both entity rows and FTS rows must be empty after rollback.
7803    #[tokio::test]
7804    async fn create_many_fts_partial_failure_rolls_back_both_substrates() {
7805        let ns = format!("fts-fail-partial-{}", uuid::Uuid::new_v4().as_simple());
7806        let rt = rt();
7807        let tok = NamespaceToken::for_namespace(Namespace::parse(&ns).unwrap());
7808
7809        let specs = vec![
7810            EntityCreateSpec {
7811                kind: "concept".into(),
7812                entity_type: None,
7813                name: "PartialRollbackA".into(),
7814                description: None,
7815                properties: None,
7816                tags: vec![],
7817            },
7818            EntityCreateSpec {
7819                kind: "concept".into(),
7820                entity_type: None,
7821                name: "PartialRollbackB".into(),
7822                description: None,
7823                properties: None,
7824                tags: vec![],
7825            },
7826        ];
7827
7828        arm_fts_fail_many_partial(&ns);
7829        let result = rt.create_many(&tok, specs).await;
7830
7831        assert!(
7832            result.is_err(),
7833            "create_many must return Err when FTS summary.failed > 0"
7834        );
7835
7836        // Entity substrate must be empty — entity rows must have been rolled back.
7837        let entity_rows = rt.list_entities(&tok, None, None, 100, 0).await.unwrap();
7838        assert_eq!(
7839            entity_rows.len(),
7840            0,
7841            "entity rows must be rolled back when FTS summary.failed > 0; found {entity_rows:?}"
7842        );
7843
7844        // FTS substrate must be empty — no stale fts_entities rows.
7845        let fts = rt.text(&tok).unwrap();
7846        let fts_count = fts
7847            .count(TextFilter {
7848                ids: vec![],
7849                kinds: vec![],
7850                namespaces: vec![ns.clone()],
7851            })
7852            .await
7853            .unwrap();
7854        assert_eq!(
7855            fts_count, 0,
7856            "fts_entities must be empty after partial-FTS-failure rollback; found {fts_count}"
7857        );
7858    }
7859
7860    // ── PR-A1: cross-namespace get_edge now succeeds (UUID v4 is globally unique) ──
7861
7862    #[tokio::test]
7863    async fn get_edge_cross_namespace_succeeds() {
7864        let rt = rt();
7865        let ns_a = NamespaceToken::for_namespace(Namespace::parse("ns-a").unwrap());
7866        let ns_b = NamespaceToken::for_namespace(Namespace::parse("ns-b").unwrap());
7867
7868        let src = rt
7869            .create_entity(&ns_a, "concept", None, "Src", None, None, vec![])
7870            .await
7871            .unwrap();
7872        let tgt = rt
7873            .create_entity(&ns_a, "concept", None, "Tgt", None, None, vec![])
7874            .await
7875            .unwrap();
7876        let edge = rt
7877            .link(&ns_a, src.id, tgt.id, EdgeRelation::Extends, 1.0, None)
7878            .await
7879            .unwrap();
7880
7881        // Visible from own namespace.
7882        let own_ns = rt.get_edge(&ns_a, Uuid::from(edge.id)).await;
7883        assert!(
7884            own_ns.is_ok() && own_ns.unwrap().is_some(),
7885            "edge must be visible in its own namespace"
7886        );
7887
7888        // PR-A1: foreign namespace must now SUCCEED — by-ID get is namespace-agnostic.
7889        let cross_ns = rt.get_edge(&ns_b, Uuid::from(edge.id)).await;
7890        assert!(
7891            matches!(cross_ns, Ok(Some(_))),
7892            "cross-namespace get_edge must return Ok(Some(_)) after PR-A1, got {cross_ns:?}"
7893        );
7894
7895        // Absent edge UUID still returns None regardless of token namespace.
7896        let absent = rt.get_edge(&ns_b, Uuid::new_v4()).await;
7897        assert!(
7898            matches!(absent, Ok(None)),
7899            "absent edge must return Ok(None), got {absent:?}"
7900        );
7901    }
7902
7903    // ── ADR-007 PR-A1: traversal across namespace labels now succeeds ────────
7904    //
7905    // Pre-fix (#568): traverse with ns_b token + ns_a root was silently empty
7906    // because substrate_exists_in_ns → get_entity rejected cross-namespace lookups.
7907    // Post-fix: get_entity finds any entity by UUID; traverse finds the root and
7908    // returns paths scoped to the graph store's namespace filter for ns_b.
7909    // Full visible-set removal (PR-B) will collapse the namespace filter to "local".
7910    #[tokio::test]
7911    async fn traverse_cross_namespace_root_is_accepted() {
7912        use khive_storage::types::TraversalOptions;
7913
7914        let rt = rt();
7915        let ns_a = NamespaceToken::for_namespace(Namespace::parse("ns-a").unwrap());
7916        let ns_b = NamespaceToken::for_namespace(Namespace::parse("ns-b").unwrap());
7917
7918        let a = rt
7919            .create_entity(&ns_a, "concept", None, "A", None, None, vec![])
7920            .await
7921            .unwrap();
7922        rt.create_entity(&ns_a, "concept", None, "B", None, None, vec![])
7923            .await
7924            .unwrap();
7925        rt.link(&ns_a, a.id, a.id, EdgeRelation::Extends, 1.0, None)
7926            .await
7927            .ok(); // may conflict with self-loop check; we just need an entity
7928
7929        // With PR-A1: substrate_exists_in_ns finds the ns_a root via get_entity
7930        // (UUID-global lookup). The traverse proceeds; no panic.
7931        let result = rt
7932            .traverse(
7933                &ns_b,
7934                TraversalRequest {
7935                    roots: vec![a.id],
7936                    options: TraversalOptions {
7937                        max_depth: 1,
7938                        direction: Direction::Out,
7939                        ..Default::default()
7940                    },
7941                    include_roots: true,
7942                    include_properties: false,
7943                },
7944            )
7945            .await;
7946        assert!(result.is_ok(), "traverse must not error; got {:?}", result);
7947    }
7948
7949    // ---- PR #82 regression: purge cascade must include already-soft-deleted edges ----
7950    //
7951    // ADR-002 requires hard delete to cascade ALL incident edges synchronously. The old
7952    // implementation drove the cascade through `neighbors()`, which filters `deleted_at IS NULL`,
7953    // so incident edges that were already soft-deleted survived endpoint purge as dangling rows.
7954    // `purge_incident_edges` issues a single DELETE without a `deleted_at` guard.
7955
7956    /// Count ALL `graph_edges` rows for a given UUID (source OR target), including soft-deleted.
7957    async fn count_all_incident_edges(rt: &KhiveRuntime, node_id: Uuid, ns: &str) -> u64 {
7958        let mut reader = rt.sql().reader().await.expect("sql reader must open");
7959        let row = reader
7960            .query_scalar(SqlStatement {
7961                sql: "SELECT COUNT(*) FROM graph_edges \
7962                      WHERE namespace = ?1 AND (source_id = ?2 OR target_id = ?2)"
7963                    .into(),
7964                params: vec![
7965                    SqlValue::Text(ns.to_string()),
7966                    SqlValue::Text(node_id.to_string()),
7967                ],
7968                label: Some("count_all_incident_edges".into()),
7969            })
7970            .await
7971            .expect("count query must succeed");
7972        match row {
7973            Some(SqlValue::Integer(n)) => n as u64,
7974            _ => panic!("count must return an integer"),
7975        }
7976    }
7977
7978    #[tokio::test]
7979    async fn hard_delete_entity_purges_already_soft_deleted_incident_edge() {
7980        let rt = rt();
7981        let tok = NamespaceToken::local();
7982        let ns = tok.namespace().to_string();
7983
7984        let a = rt
7985            .create_entity(&tok, "concept", None, "SrcA", None, None, vec![])
7986            .await
7987            .unwrap();
7988        let b = rt
7989            .create_entity(&tok, "concept", None, "TgtB", None, None, vec![])
7990            .await
7991            .unwrap();
7992
7993        rt.link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
7994            .await
7995            .unwrap();
7996
7997        // Soft-delete the edge — it is now invisible to `neighbors` but still in storage.
7998        let edge_hit = rt
7999            .neighbors(&tok, a.id, Direction::Out, None, None)
8000            .await
8001            .unwrap();
8002        assert_eq!(edge_hit.len(), 1, "edge must exist before soft-delete");
8003        let edge_uuid = edge_hit[0].edge_id;
8004        rt.delete_edge(&tok, edge_uuid, false).await.unwrap();
8005
8006        // Confirm the edge is invisible to normal read paths but present in raw storage.
8007        let visible = rt
8008            .neighbors(&tok, a.id, Direction::Out, None, None)
8009            .await
8010            .unwrap();
8011        assert!(visible.is_empty(), "soft-deleted edge must be invisible");
8012        let raw_before = count_all_incident_edges(&rt, a.id, &ns).await;
8013        assert_eq!(
8014            raw_before, 1,
8015            "soft-deleted edge must still be a physical row"
8016        );
8017
8018        // Hard-delete (purge) the source entity — cascade must also remove the soft-deleted edge.
8019        rt.delete_entity(&tok, a.id, true).await.unwrap();
8020
8021        let raw_after = count_all_incident_edges(&rt, a.id, &ns).await;
8022        assert_eq!(
8023            raw_after, 0,
8024            "purge_incident_edges must physically remove soft-deleted edge rows (ADR-002)"
8025        );
8026    }
8027
8028    #[tokio::test]
8029    async fn hard_delete_note_purges_already_soft_deleted_incident_edge() {
8030        let rt = rt();
8031        let tok = NamespaceToken::local();
8032        let ns = tok.namespace().to_string();
8033
8034        let target = rt
8035            .create_note(
8036                &tok,
8037                "observation",
8038                None,
8039                "purge-cascade target note",
8040                Some(0.5),
8041                None,
8042                vec![],
8043            )
8044            .await
8045            .unwrap();
8046        let annotating = rt
8047            .create_note(
8048                &tok,
8049                "insight",
8050                None,
8051                "annotator note",
8052                Some(0.5),
8053                None,
8054                vec![target.id],
8055            )
8056            .await
8057            .unwrap();
8058
8059        // Soft-delete the annotates edge.
8060        let edge_hit = rt
8061            .neighbors(
8062                &tok,
8063                annotating.id,
8064                Direction::Out,
8065                None,
8066                Some(vec![EdgeRelation::Annotates]),
8067            )
8068            .await
8069            .unwrap();
8070        assert_eq!(edge_hit.len(), 1, "annotates edge must exist");
8071        let edge_uuid = edge_hit[0].edge_id;
8072        rt.delete_edge(&tok, edge_uuid, false).await.unwrap();
8073
8074        let raw_before = count_all_incident_edges(&rt, target.id, &ns).await;
8075        assert_eq!(
8076            raw_before, 1,
8077            "soft-deleted edge must still be a physical row before note purge"
8078        );
8079
8080        // Hard-delete the target note — cascade must remove the soft-deleted edge row.
8081        rt.delete_note(&tok, target.id, true).await.unwrap();
8082
8083        let raw_after = count_all_incident_edges(&rt, target.id, &ns).await;
8084        assert_eq!(
8085            raw_after, 0,
8086            "purge_incident_edges must physically remove soft-deleted edge rows on note purge (ADR-002)"
8087        );
8088    }
8089
8090    // ---- PR #148 High-#2 regression: cross-namespace entity hard-delete purges ALL incident edges ----
8091    //
8092    // Before this fix: purge_incident_edges used `WHERE namespace = caller_ns AND ...`, so a
8093    // foreign-namespace entity's incident edges in ITS namespace survived the cascade as dangling rows.
8094
8095    /// Count ALL `graph_edges` rows for a given node UUID, across every namespace.
8096    async fn count_all_incident_edges_global(rt: &KhiveRuntime, node_id: Uuid) -> u64 {
8097        let mut reader = rt.sql().reader().await.expect("sql reader must open");
8098        let row = reader
8099            .query_scalar(SqlStatement {
8100                sql: "SELECT COUNT(*) FROM graph_edges WHERE source_id = ?1 OR target_id = ?1"
8101                    .into(),
8102                params: vec![SqlValue::Text(node_id.to_string())],
8103                label: Some("count_all_incident_edges_global".into()),
8104            })
8105            .await
8106            .expect("count query must succeed");
8107        match row {
8108            Some(SqlValue::Integer(n)) => n as u64,
8109            _ => panic!("count must return an integer"),
8110        }
8111    }
8112
8113    #[tokio::test]
8114    async fn cross_namespace_hard_delete_entity_purges_all_incident_edges() {
8115        // Entity lives in ns-owner. Edges live in ns-owner.
8116        // Delete is driven from ns-caller (a different namespace).
8117        // Assertion: after hard delete, no incident edges remain in ANY namespace.
8118        let rt = rt();
8119        let ns_owner = NamespaceToken::for_namespace(Namespace::parse("ns-owner").unwrap());
8120        let ns_caller = NamespaceToken::for_namespace(Namespace::parse("ns-caller").unwrap());
8121
8122        let entity = rt
8123            .create_entity(
8124                &ns_owner,
8125                "concept",
8126                None,
8127                "ForeignEntity",
8128                None,
8129                None,
8130                vec![],
8131            )
8132            .await
8133            .unwrap();
8134        let peer = rt
8135            .create_entity(&ns_owner, "concept", None, "Peer", None, None, vec![])
8136            .await
8137            .unwrap();
8138        // Create two incident edges in ns_owner. concept->Extends->concept is in the allowlist.
8139        rt.link(
8140            &ns_owner,
8141            entity.id,
8142            peer.id,
8143            EdgeRelation::Extends,
8144            1.0,
8145            None,
8146        )
8147        .await
8148        .unwrap();
8149        rt.link(
8150            &ns_owner,
8151            peer.id,
8152            entity.id,
8153            EdgeRelation::Extends,
8154            1.0,
8155            None,
8156        )
8157        .await
8158        .unwrap();
8159
8160        let before = count_all_incident_edges_global(&rt, entity.id).await;
8161        assert_eq!(before, 2, "two incident edges must exist before delete");
8162
8163        // Hard-delete entity from a DIFFERENT namespace token.
8164        let deleted = rt.delete_entity(&ns_caller, entity.id, true).await.unwrap();
8165        assert!(deleted, "cross-ns hard delete must return true");
8166
8167        // All incident edges must be gone regardless of namespace.
8168        let after = count_all_incident_edges_global(&rt, entity.id).await;
8169        assert_eq!(
8170            after, 0,
8171            "purge_incident_edges must remove all incident edges across namespaces (ADR-002, ADR-007)"
8172        );
8173    }
8174
8175    // ---- PR #82 round-2 regression: edge-ID hard-delete path ----
8176    //
8177    // Bug class (codex R2): delete_edge drove the primary-edge guard through get_edge()
8178    // (live-only) and the cascade through neighbors() (live-only). Two reachable holes:
8179    // (a) soft-deleted primary edge cannot be hard-purged via its own ID;
8180    // (b) an already-soft-deleted annotates edge targeting a base edge survives that
8181    //     edge's hard delete as a dangling row with target_id = physically-gone edge id.
8182
8183    /// Count graph_edges rows matching the given edge ID, including soft-deleted rows.
8184    async fn count_edge_rows_by_id(rt: &KhiveRuntime, edge_id: Uuid, ns: &str) -> u64 {
8185        let mut reader = rt.sql().reader().await.expect("sql reader must open");
8186        let row = reader
8187            .query_scalar(SqlStatement {
8188                sql: "SELECT COUNT(*) FROM graph_edges WHERE namespace = ?1 AND id = ?2".into(),
8189                params: vec![
8190                    SqlValue::Text(ns.to_string()),
8191                    SqlValue::Text(edge_id.to_string()),
8192                ],
8193                label: Some("count_edge_rows_by_id".into()),
8194            })
8195            .await
8196            .expect("count query must succeed");
8197        match row {
8198            Some(SqlValue::Integer(n)) => n as u64,
8199            _ => panic!("count must return an integer"),
8200        }
8201    }
8202
8203    #[tokio::test]
8204    async fn hard_delete_edge_purges_already_soft_deleted_primary_edge() {
8205        let rt = rt();
8206        let tok = NamespaceToken::local();
8207        let ns = tok.namespace().to_string();
8208
8209        let a = rt
8210            .create_entity(&tok, "concept", None, "EA", None, None, vec![])
8211            .await
8212            .unwrap();
8213        let b = rt
8214            .create_entity(&tok, "concept", None, "EB", None, None, vec![])
8215            .await
8216            .unwrap();
8217
8218        let edge = rt
8219            .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
8220            .await
8221            .unwrap();
8222        let edge_uuid: Uuid = edge.id.into();
8223
8224        // Soft-delete the edge first.
8225        let soft = rt.delete_edge(&tok, edge_uuid, false).await.unwrap();
8226        assert!(soft, "soft delete must succeed");
8227
8228        // Edge is now invisible to normal reads but still a physical row.
8229        assert!(
8230            rt.get_edge(&tok, edge_uuid).await.unwrap().is_none(),
8231            "soft-deleted edge must be invisible to get_edge"
8232        );
8233        assert_eq!(
8234            count_edge_rows_by_id(&rt, edge_uuid, &ns).await,
8235            1,
8236            "soft-deleted edge must still be a physical row"
8237        );
8238
8239        // Hard-delete (purge) via the edge ID — must succeed and remove the row.
8240        let purged = rt.delete_edge(&tok, edge_uuid, true).await.unwrap();
8241        assert!(
8242            purged,
8243            "hard delete of a soft-deleted edge must return true"
8244        );
8245
8246        assert_eq!(
8247            count_edge_rows_by_id(&rt, edge_uuid, &ns).await,
8248            0,
8249            "hard-delete must physically remove the soft-deleted edge row (ADR-002)"
8250        );
8251    }
8252
8253    #[tokio::test]
8254    async fn hard_delete_base_edge_purges_already_soft_deleted_annotates_edge() {
8255        let rt = rt();
8256        let tok = NamespaceToken::local();
8257        let ns = tok.namespace().to_string();
8258
8259        let a = rt
8260            .create_entity(&tok, "concept", None, "CA", None, None, vec![])
8261            .await
8262            .unwrap();
8263        let b = rt
8264            .create_entity(&tok, "concept", None, "CB", None, None, vec![])
8265            .await
8266            .unwrap();
8267
8268        // Create the base edge to be annotated.
8269        let base_edge = rt
8270            .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
8271            .await
8272            .unwrap();
8273        let base_edge_uuid: Uuid = base_edge.id.into();
8274
8275        // Create a note that annotates the base edge.
8276        let note = rt
8277            .create_note(
8278                &tok,
8279                "observation",
8280                None,
8281                "note about base edge",
8282                Some(0.5),
8283                None,
8284                vec![base_edge_uuid],
8285            )
8286            .await
8287            .unwrap();
8288
8289        // Find the annotates edge.
8290        let ann_hits = rt
8291            .neighbors(
8292                &tok,
8293                note.id,
8294                Direction::Out,
8295                None,
8296                Some(vec![EdgeRelation::Annotates]),
8297            )
8298            .await
8299            .unwrap();
8300        assert_eq!(ann_hits.len(), 1, "annotates edge must exist");
8301        let ann_edge_uuid = ann_hits[0].edge_id;
8302
8303        // Soft-delete the annotates edge — now invisible but still a physical row.
8304        rt.delete_edge(&tok, ann_edge_uuid, false).await.unwrap();
8305        assert_eq!(
8306            count_edge_rows_by_id(&rt, ann_edge_uuid, &ns).await,
8307            1,
8308            "soft-deleted annotates edge must still be a physical row"
8309        );
8310
8311        // Hard-delete the base edge — cascade must also remove the soft-deleted annotates row.
8312        let purged = rt.delete_edge(&tok, base_edge_uuid, true).await.unwrap();
8313        assert!(purged, "hard delete of base edge must return true");
8314
8315        assert_eq!(
8316            count_edge_rows_by_id(&rt, ann_edge_uuid, &ns).await,
8317            0,
8318            "hard-delete of base edge must purge already-soft-deleted annotates edge row (ADR-002)"
8319        );
8320        assert_eq!(
8321            count_edge_rows_by_id(&rt, base_edge_uuid, &ns).await,
8322            0,
8323            "hard-delete must physically remove the base edge row"
8324        );
8325    }
8326
8327    // ---- Issue #10: entity create/update multi-model embed fan-out tests ----
8328
8329    // T-E1: FTS failure after entity row commit rolls back the entity row.
8330    // Mirrors create_note_fts_failure_rolls_back_note_row but for entities.
8331    // Uses a unique namespace so the process-global FTS_FAIL_NS one-shot is
8332    // consumed only by this test's create_entity call.
8333    #[tokio::test]
8334    async fn create_entity_fts_failure_rolls_back_entity_row() {
8335        let rt = KhiveRuntime::memory().unwrap();
8336        let ns = Namespace::parse("fault-entity-fts").unwrap();
8337        let tok = NamespaceToken::for_namespace(ns.clone());
8338
8339        arm_fts_fail(ns.as_str());
8340
8341        let result = rt
8342            .create_entity(
8343                &tok,
8344                "concept",
8345                None,
8346                "fts-fail rollback target",
8347                None,
8348                None,
8349                vec![],
8350            )
8351            .await;
8352
8353        assert!(
8354            result.is_err(),
8355            "create_entity must propagate the injected FTS failure"
8356        );
8357        let err_msg = result.unwrap_err().to_string();
8358        assert!(
8359            err_msg.contains("injected FTS failure"),
8360            "error must carry injection message; got: {err_msg}"
8361        );
8362
8363        let entities = rt.list_entities(&tok, None, None, 1000, 0).await.unwrap();
8364        assert!(
8365            entities.is_empty(),
8366            "compensation must remove the entity row after FTS failure; got {entities:?}"
8367        );
8368    }
8369
8370    // T-E2: Vector insert failure after entity row + FTS commit rolls back both.
8371    // Uses a unique namespace to avoid consuming the VECTOR_FAIL_NS flag from
8372    // a concurrent test's create_entity or create_note.
8373    #[tokio::test]
8374    async fn create_entity_vector_failure_rolls_back_entity_row_and_fts() {
8375        const MODEL: &str = "test-entity-vec-inject";
8376        const DIMS: usize = 4;
8377
8378        let rt = KhiveRuntime::memory().unwrap();
8379        let (provider, _counter) = ConstVecProvider::new(MODEL, DIMS);
8380        rt.register_embedder(provider);
8381
8382        let ns = Namespace::parse("fault-entity-vec").unwrap();
8383        let tok = NamespaceToken::for_namespace(ns.clone());
8384
8385        arm_vector_fail(ns.as_str());
8386
8387        let result = rt
8388            .create_entity(
8389                &tok,
8390                "concept",
8391                None,
8392                "vec-fail rollback target",
8393                Some("description so embed body is non-empty"),
8394                None,
8395                vec![],
8396            )
8397            .await;
8398
8399        assert!(
8400            result.is_err(),
8401            "create_entity must propagate the injected vector failure"
8402        );
8403        let err_msg = result.unwrap_err().to_string();
8404        assert!(
8405            err_msg.contains("injected vector failure"),
8406            "error must carry injection message; got: {err_msg}"
8407        );
8408
8409        let entities = rt.list_entities(&tok, None, None, 1000, 0).await.unwrap();
8410        assert!(
8411            entities.is_empty(),
8412            "compensation must remove entity row after vector failure; got {entities:?}"
8413        );
8414
8415        // FTS document must also be removed.
8416        use khive_storage::types::{TextFilter, TextQueryMode, TextSearchRequest};
8417        let fts_hits = rt
8418            .text(&tok)
8419            .unwrap()
8420            .search(TextSearchRequest {
8421                query: "vec-fail rollback target".to_string(),
8422                mode: TextQueryMode::Plain,
8423                filter: Some(TextFilter {
8424                    namespaces: vec![ns.as_str().to_string()],
8425                    ..Default::default()
8426                }),
8427                top_k: 10,
8428                snippet_chars: 100,
8429            })
8430            .await
8431            .unwrap();
8432        assert!(
8433            fts_hits.is_empty(),
8434            "compensation must remove FTS document after vector failure; got {fts_hits:?}"
8435        );
8436    }
8437
8438    // T-E3: Multi-model create_entity — second model's vector INSERT fails after the
8439    // first model's insert succeeds, triggering inserted_models rollback.
8440    // Uses arm_vector_fail_after(1) so the first insert passes and the second fails,
8441    // exercising the inserted_models compensation path in create_entity.
8442    // Thread-local VECTOR_FAIL_AFTER is per-thread isolated (current-thread tokio runtime),
8443    // so this test does not race with namespace-targeted VECTOR_FAIL_NS tests.
8444    #[tokio::test]
8445    async fn create_entity_multi_model_second_vector_failure_rolls_back_all() {
8446        const DIMS: usize = 4;
8447
8448        let rt = KhiveRuntime::memory().unwrap();
8449        let (provider_a, _ca) = ConstVecProvider::new("model-a", DIMS);
8450        let (provider_b, _cb) = ConstVecProvider::new("model-b", DIMS);
8451        rt.register_embedder(provider_a);
8452        rt.register_embedder(provider_b);
8453
8454        let ns = Namespace::parse("fault-entity-multi").unwrap();
8455        let tok = NamespaceToken::for_namespace(ns.clone());
8456
8457        // Let the first vector insert succeed, fail on the second.
8458        arm_vector_fail_after(1);
8459
8460        let result = rt
8461            .create_entity(
8462                &tok,
8463                "concept",
8464                None,
8465                "multi-model rollback target",
8466                Some("description for embedding"),
8467                None,
8468                vec![],
8469            )
8470            .await;
8471
8472        assert!(
8473            result.is_err(),
8474            "create_entity must propagate the injected multi-model vector failure"
8475        );
8476
8477        let entities = rt.list_entities(&tok, None, None, 1000, 0).await.unwrap();
8478        assert!(
8479            entities.is_empty(),
8480            "compensation must remove entity row; got {entities:?}"
8481        );
8482
8483        // Both model-a and model-b vector stores must be empty for the entity id.
8484        // (The entity was never returned so we can't get its id from the result;
8485        // list_entities returning empty is the primary assertion. Additionally confirm
8486        // both stores have zero rows via a broad vector search.)
8487        use khive_storage::types::VectorSearchRequest;
8488        let query_vec = vec![1.0_f32; DIMS];
8489        let hits_a = rt
8490            .vectors_for_model(&tok, "model-a")
8491            .unwrap()
8492            .search(VectorSearchRequest {
8493                query_vectors: vec![query_vec.clone()],
8494                top_k: 100,
8495                namespace: Some(ns.as_str().to_string()),
8496                kind: Some(khive_types::SubstrateKind::Entity),
8497                embedding_model: Some("model-a".to_string()),
8498                filter: None,
8499                backend_hints: None,
8500            })
8501            .await
8502            .unwrap();
8503        assert!(
8504            hits_a.is_empty(),
8505            "model-a vector store must be empty after rollback; got {hits_a:?}"
8506        );
8507        let hits_b = rt
8508            .vectors_for_model(&tok, "model-b")
8509            .unwrap()
8510            .search(VectorSearchRequest {
8511                query_vectors: vec![query_vec],
8512                top_k: 100,
8513                namespace: Some(ns.as_str().to_string()),
8514                kind: Some(khive_types::SubstrateKind::Entity),
8515                embedding_model: Some("model-b".to_string()),
8516                filter: None,
8517                backend_hints: None,
8518            })
8519            .await
8520            .unwrap();
8521        assert!(
8522            hits_b.is_empty(),
8523            "model-b vector store must be empty after rollback; got {hits_b:?}"
8524        );
8525    }
8526
8527    // T-U1: update_entity fans out to ALL registered models.
8528    // After create + update with a changed description, both model-a and model-b
8529    // vector stores hold a row for the entity id.
8530    #[tokio::test]
8531    async fn update_entity_fans_out_to_all_registered_models() {
8532        const DIMS: usize = 4;
8533
8534        let rt = KhiveRuntime::memory().unwrap();
8535        let (provider_a, _ca) = ConstVecProvider::new("embed-a", DIMS);
8536        let (provider_b, _cb) = ConstVecProvider::new("embed-b", DIMS);
8537        rt.register_embedder(provider_a);
8538        rt.register_embedder(provider_b);
8539
8540        let ns = Namespace::parse("update-entity-fanout").unwrap();
8541        let tok = NamespaceToken::for_namespace(ns.clone());
8542
8543        let entity = rt
8544            .create_entity(
8545                &tok,
8546                "concept",
8547                None,
8548                "FanOutEntity",
8549                Some("initial description"),
8550                None,
8551                vec![],
8552            )
8553            .await
8554            .expect("create_entity must succeed");
8555
8556        use crate::curation::EntityPatch;
8557        let patch = EntityPatch {
8558            description: Some(Some("updated description after fan-out fix".to_string())),
8559            ..Default::default()
8560        };
8561        rt.update_entity(&tok, entity.id, patch)
8562            .await
8563            .expect("update_entity must succeed");
8564
8565        use khive_storage::types::VectorSearchRequest;
8566        let query_vec = vec![1.0_f32; DIMS];
8567
8568        let hits_a = rt
8569            .vectors_for_model(&tok, "embed-a")
8570            .unwrap()
8571            .search(VectorSearchRequest {
8572                query_vectors: vec![query_vec.clone()],
8573                top_k: 10,
8574                namespace: Some(ns.as_str().to_string()),
8575                kind: Some(khive_types::SubstrateKind::Entity),
8576                embedding_model: Some("embed-a".to_string()),
8577                filter: None,
8578                backend_hints: None,
8579            })
8580            .await
8581            .unwrap();
8582        assert!(
8583            hits_a.iter().any(|h| h.subject_id == entity.id),
8584            "embed-a must hold a vector for the entity after update; got {hits_a:?}"
8585        );
8586
8587        let hits_b = rt
8588            .vectors_for_model(&tok, "embed-b")
8589            .unwrap()
8590            .search(VectorSearchRequest {
8591                query_vectors: vec![query_vec],
8592                top_k: 10,
8593                namespace: Some(ns.as_str().to_string()),
8594                kind: Some(khive_types::SubstrateKind::Entity),
8595                embedding_model: Some("embed-b".to_string()),
8596                filter: None,
8597                backend_hints: None,
8598            })
8599            .await
8600            .unwrap();
8601        assert!(
8602            hits_b.iter().any(|h| h.subject_id == entity.id),
8603            "embed-b must hold a vector for the entity after update; got {hits_b:?}"
8604        );
8605    }
8606
8607    // T-U2: update_note fans out to ALL registered models.
8608    // After create + update with changed content, both embed-a and embed-b
8609    // vector stores hold a row for the note id.
8610    #[tokio::test]
8611    async fn update_note_fans_out_to_all_registered_models() {
8612        const DIMS: usize = 4;
8613
8614        let rt = KhiveRuntime::memory().unwrap();
8615        let (provider_a, _ca) = ConstVecProvider::new("embed-a", DIMS);
8616        let (provider_b, _cb) = ConstVecProvider::new("embed-b", DIMS);
8617        rt.register_embedder(provider_a);
8618        rt.register_embedder(provider_b);
8619
8620        let ns = Namespace::parse("update-note-fanout").unwrap();
8621        let tok = NamespaceToken::for_namespace(ns.clone());
8622
8623        let note = rt
8624            .create_note(
8625                &tok,
8626                "observation",
8627                None,
8628                "initial note content for fan-out test",
8629                None,
8630                None,
8631                vec![],
8632            )
8633            .await
8634            .expect("create_note must succeed");
8635
8636        use crate::curation::NotePatch;
8637        let patch = NotePatch {
8638            content: Some("updated content after fan-out fix".to_string()),
8639            ..Default::default()
8640        };
8641        rt.update_note(&tok, note.id, patch)
8642            .await
8643            .expect("update_note must succeed");
8644
8645        use khive_storage::types::VectorSearchRequest;
8646        let query_vec = vec![1.0_f32; DIMS];
8647
8648        let hits_a = rt
8649            .vectors_for_model(&tok, "embed-a")
8650            .unwrap()
8651            .search(VectorSearchRequest {
8652                query_vectors: vec![query_vec.clone()],
8653                top_k: 10,
8654                namespace: Some(ns.as_str().to_string()),
8655                kind: Some(khive_types::SubstrateKind::Note),
8656                embedding_model: Some("embed-a".to_string()),
8657                filter: None,
8658                backend_hints: None,
8659            })
8660            .await
8661            .unwrap();
8662        assert!(
8663            hits_a.iter().any(|h| h.subject_id == note.id),
8664            "embed-a must hold a vector for the note after update; got {hits_a:?}"
8665        );
8666
8667        let hits_b = rt
8668            .vectors_for_model(&tok, "embed-b")
8669            .unwrap()
8670            .search(VectorSearchRequest {
8671                query_vectors: vec![query_vec],
8672                top_k: 10,
8673                namespace: Some(ns.as_str().to_string()),
8674                kind: Some(khive_types::SubstrateKind::Note),
8675                embedding_model: Some("embed-b".to_string()),
8676                filter: None,
8677                backend_hints: None,
8678            })
8679            .await
8680            .unwrap();
8681        assert!(
8682            hits_b.iter().any(|h| h.subject_id == note.id),
8683            "embed-b must hold a vector for the note after update; got {hits_b:?}"
8684        );
8685    }
8686
8687    // ── ADR-007 PR-A1 regression (V3): by-ID ops must not filter by namespace ──
8688    //
8689    // Pre-fix: get/update/delete on an entity stamped "lambda:leo" from a "local"
8690    // token returned NotFound, causing the gtd.complete / update blindness.
8691    // Post-fix: UUID is globally unique; by-ID ops find the record regardless of
8692    // which namespace the caller's token carries.
8693
8694    #[tokio::test]
8695    async fn get_entity_cross_namespace_succeeds() {
8696        let rt = rt();
8697        // Create under "lambda:leo".
8698        let leo_tok = NamespaceToken::for_namespace(Namespace::parse("lambda:leo").unwrap());
8699        let entity = rt
8700            .create_entity(&leo_tok, "concept", None, "Leo-Entity", None, None, vec![])
8701            .await
8702            .unwrap();
8703        assert_eq!(entity.namespace, "lambda:leo");
8704
8705        // Read from "local" — must succeed (no namespace gate on by-ID get).
8706        let local_tok = NamespaceToken::local();
8707        let fetched = rt.get_entity(&local_tok, entity.id).await;
8708        assert!(
8709            fetched.is_ok(),
8710            "get_entity from local token must find lambda:leo entity; got {:?}",
8711            fetched
8712        );
8713        assert_eq!(fetched.unwrap().id, entity.id);
8714    }
8715
8716    #[tokio::test]
8717    async fn update_entity_cross_namespace_succeeds() {
8718        let rt = rt();
8719        let leo_tok = NamespaceToken::for_namespace(Namespace::parse("lambda:leo").unwrap());
8720        let entity = rt
8721            .create_entity(
8722                &leo_tok,
8723                "concept",
8724                None,
8725                "Leo-Entity-Update",
8726                None,
8727                None,
8728                vec![],
8729            )
8730            .await
8731            .unwrap();
8732
8733        // Update from "local" token — must not error with NotFound.
8734        let local_tok = NamespaceToken::local();
8735        let patch = crate::curation::EntityPatch {
8736            name: Some("Leo-Entity-Updated".to_string()),
8737            ..Default::default()
8738        };
8739        let result = rt.update_entity(&local_tok, entity.id, patch).await;
8740        assert!(
8741            result.is_ok(),
8742            "update_entity from local token must succeed on lambda:leo entity; got {:?}",
8743            result
8744        );
8745        assert_eq!(result.unwrap().name, "Leo-Entity-Updated");
8746    }
8747
8748    #[tokio::test]
8749    async fn delete_entity_cross_namespace_succeeds() {
8750        let rt = rt();
8751        let leo_tok = NamespaceToken::for_namespace(Namespace::parse("lambda:leo").unwrap());
8752        let entity = rt
8753            .create_entity(
8754                &leo_tok,
8755                "concept",
8756                None,
8757                "Leo-Entity-Delete",
8758                None,
8759                None,
8760                vec![],
8761            )
8762            .await
8763            .unwrap();
8764
8765        // Delete from "local" token — must succeed.
8766        let local_tok = NamespaceToken::local();
8767        let deleted = rt.delete_entity(&local_tok, entity.id, false).await;
8768        assert!(
8769            deleted.is_ok(),
8770            "delete_entity from local token must succeed on lambda:leo entity; got {:?}",
8771            deleted
8772        );
8773        assert!(
8774            deleted.unwrap(),
8775            "delete must return true when entity existed"
8776        );
8777    }
8778
8779    #[tokio::test]
8780    async fn namespace_preserved_on_entity_after_cross_namespace_get() {
8781        let rt = rt();
8782        let leo_tok = NamespaceToken::for_namespace(Namespace::parse("lambda:leo").unwrap());
8783        let entity = rt
8784            .create_entity(
8785                &leo_tok,
8786                "concept",
8787                None,
8788                "NS-Preserved",
8789                None,
8790                None,
8791                vec![],
8792            )
8793            .await
8794            .unwrap();
8795
8796        // The namespace column on the fetched record must still say "lambda:leo".
8797        let local_tok = NamespaceToken::local();
8798        let fetched = rt.get_entity(&local_tok, entity.id).await.unwrap();
8799        assert_eq!(
8800            fetched.namespace, "lambda:leo",
8801            "namespace column must be preserved; not overwritten with caller's namespace"
8802        );
8803    }
8804
8805    // ── PackByIdResolver unit tests (ADR-061, #158) ───────────────────────────
8806
8807    use crate::pack::PackByIdResolver;
8808    use tokio::sync::Mutex as TokioMutex;
8809
8810    #[derive(Debug, Default)]
8811    struct MockResolverState {
8812        owned: Vec<Uuid>,
8813        deleted: Vec<Uuid>,
8814        delete_calls: Vec<(Uuid, bool)>,
8815    }
8816
8817    struct MockPackResolver(TokioMutex<MockResolverState>);
8818
8819    impl MockPackResolver {
8820        fn new() -> Self {
8821            Self(TokioMutex::new(MockResolverState::default()))
8822        }
8823    }
8824
8825    #[async_trait::async_trait]
8826    impl crate::pack::PackByIdResolver for MockPackResolver {
8827        async fn resolve_by_id(&self, id: Uuid) -> Result<Option<Resolved>, RuntimeError> {
8828            let state = self.0.lock().await;
8829            if state.owned.contains(&id) && !state.deleted.contains(&id) {
8830                Ok(Some(Resolved::PackRecord {
8831                    pack: "mock".into(),
8832                    kind: "widget".into(),
8833                    data: serde_json::json!({ "id": id.to_string(), "name": "test-widget" }),
8834                }))
8835            } else {
8836                Ok(None)
8837            }
8838        }
8839
8840        async fn resolve_by_id_including_deleted(
8841            &self,
8842            id: Uuid,
8843        ) -> Result<Option<Resolved>, RuntimeError> {
8844            let state = self.0.lock().await;
8845            if state.owned.contains(&id) {
8846                Ok(Some(Resolved::PackRecord {
8847                    pack: "mock".into(),
8848                    kind: "widget".into(),
8849                    data: serde_json::json!({ "id": id.to_string(), "name": "test-widget" }),
8850                }))
8851            } else {
8852                Ok(None)
8853            }
8854        }
8855
8856        async fn delete_by_id(
8857            &self,
8858            id: Uuid,
8859            hard: bool,
8860        ) -> Result<serde_json::Value, RuntimeError> {
8861            let mut state = self.0.lock().await;
8862            if !state.owned.contains(&id) {
8863                return Err(RuntimeError::NotFound(format!(
8864                    "mock widget not found: {id}"
8865                )));
8866            }
8867            state.delete_calls.push((id, hard));
8868            if hard {
8869                state.owned.retain(|&x| x != id);
8870                state.deleted.retain(|&x| x != id);
8871            } else {
8872                state.deleted.push(id);
8873            }
8874            Ok(
8875                serde_json::json!({ "deleted": true, "id": id.to_string(), "kind": "widget", "hard": hard }),
8876            )
8877        }
8878    }
8879
8880    fn registry_with_mock_resolver(
8881        rt: KhiveRuntime,
8882        resolver: Box<dyn crate::pack::PackByIdResolver>,
8883    ) -> crate::VerbRegistry {
8884        use crate::pack::{PackRuntime, VerbRegistryBuilder};
8885        use khive_types::{HandlerDef, VerbCategory, Visibility};
8886
8887        static MINIMAL_HANDLERS: &[HandlerDef] = &[HandlerDef {
8888            name: "minimal.noop",
8889            description: "noop",
8890            visibility: Visibility::Verb,
8891            category: VerbCategory::Commissive,
8892            params: &[],
8893        }];
8894
8895        struct MinimalPack;
8896        impl khive_types::Pack for MinimalPack {
8897            const NAME: &'static str = "minimal";
8898            const NOTE_KINDS: &'static [&'static str] = &[];
8899            const ENTITY_KINDS: &'static [&'static str] = &[];
8900            const HANDLERS: &'static [HandlerDef] = MINIMAL_HANDLERS;
8901        }
8902        #[async_trait::async_trait]
8903        impl PackRuntime for MinimalPack {
8904            fn name(&self) -> &str {
8905                "minimal"
8906            }
8907            fn note_kinds(&self) -> &'static [&'static str] {
8908                &[]
8909            }
8910            fn entity_kinds(&self) -> &'static [&'static str] {
8911                &[]
8912            }
8913            fn handlers(&self) -> &'static [HandlerDef] {
8914                MINIMAL_HANDLERS
8915            }
8916            async fn dispatch(
8917                &self,
8918                _verb: &str,
8919                _params: serde_json::Value,
8920                _registry: &crate::VerbRegistry,
8921                _token: &NamespaceToken,
8922            ) -> Result<serde_json::Value, RuntimeError> {
8923                Err(RuntimeError::InvalidInput("stub".into()))
8924            }
8925        }
8926
8927        let _ = rt;
8928        let mut builder = VerbRegistryBuilder::new();
8929        builder.register(MinimalPack);
8930        builder.register_resolver("mock", resolver);
8931        builder.build().expect("registry build failed")
8932    }
8933
8934    #[tokio::test]
8935    async fn pack_record_resolved_pair_returns_none() {
8936        let pr = Resolved::PackRecord {
8937            pack: "knowledge".into(),
8938            kind: "atom".into(),
8939            data: serde_json::json!({}),
8940        };
8941        assert!(
8942            resolved_pair(Some(&pr)).is_none(),
8943            "PackRecord must not be a valid edge endpoint"
8944        );
8945    }
8946
8947    #[test]
8948    fn resolved_pair_surfaces_entity_type() {
8949        let e = Resolved::Entity(
8950            Entity::new("mathlib", "concept", "Nat.add_comm").with_entity_type(Some("theorem")),
8951        );
8952        assert_eq!(
8953            resolved_pair(Some(&e)),
8954            Some(("entity", "concept", Some("theorem"))),
8955            "entity_type subtype must be surfaced alongside base kind"
8956        );
8957    }
8958
8959    #[test]
8960    fn endpoint_of_type_matches_subtype_not_base_kind() {
8961        // An entity whose base kind is "concept" and subtype is "theorem".
8962        let kind = "concept";
8963        let et = Some("theorem");
8964
8965        // EntityOfType matches only when BOTH base kind and subtype match.
8966        assert!(endpoint_matches(
8967            &EndpointKind::EntityOfType {
8968                kind: "concept",
8969                entity_type: "theorem",
8970            },
8971            "entity",
8972            kind,
8973            et
8974        ));
8975        assert!(!endpoint_matches(
8976            &EndpointKind::EntityOfType {
8977                kind: "concept",
8978                entity_type: "definition",
8979            },
8980            "entity",
8981            kind,
8982            et
8983        ));
8984
8985        // The silently-inert trap (ADR-069 A7): EntityOfKind sees only the BASE
8986        // kind, so EntityOfKind("theorem") never matches a concept/theorem.
8987        assert!(!endpoint_matches(
8988            &EndpointKind::EntityOfKind("theorem"),
8989            "entity",
8990            kind,
8991            et
8992        ));
8993        // EntityOfKind still matches the base kind.
8994        assert!(endpoint_matches(
8995            &EndpointKind::EntityOfKind("concept"),
8996            "entity",
8997            kind,
8998            et
8999        ));
9000
9001        // EntityOfType rejects non-entity substrates and entities with no subtype.
9002        assert!(!endpoint_matches(
9003            &EndpointKind::EntityOfType {
9004                kind: "concept",
9005                entity_type: "theorem",
9006            },
9007            "note",
9008            "task",
9009            None
9010        ));
9011        assert!(!endpoint_matches(
9012            &EndpointKind::EntityOfType {
9013                kind: "concept",
9014                entity_type: "theorem",
9015            },
9016            "entity",
9017            kind,
9018            None
9019        ));
9020    }
9021
9022    #[test]
9023    fn endpoint_of_type_requires_base_kind_match() {
9024        // Regression: an entity with entity_type="theorem" but base kind != "concept"
9025        // must NOT match a formal concept rule. This was the exact bypass codex named:
9026        // before the fix, EntityOfType("theorem") ignored the base kind entirely.
9027        let wrong_base_kind = "project"; // not "concept"
9028        let et = Some("theorem");
9029
9030        // The formal rule requires kind="concept". A "project" entity with
9031        // entity_type="theorem" must not match — even though the subtype string
9032        // matches — because the base kind differs.
9033        assert!(
9034            !endpoint_matches(
9035                &EndpointKind::EntityOfType {
9036                    kind: "concept",
9037                    entity_type: "theorem",
9038                },
9039                "entity",
9040                wrong_base_kind,
9041                et
9042            ),
9043            "EntityOfType must reject an entity whose base kind != rule.kind \
9044             even when entity_type matches — the pre-fix bug admitted this"
9045        );
9046
9047        // The correct concept entity with the same subtype still matches.
9048        assert!(endpoint_matches(
9049            &EndpointKind::EntityOfType {
9050                kind: "concept",
9051                entity_type: "theorem",
9052            },
9053            "entity",
9054            "concept",
9055            et
9056        ));
9057    }
9058
9059    #[tokio::test]
9060    async fn registry_resolvers_accessor_returns_registered() {
9061        let resolver = Box::new(MockPackResolver::new());
9062        let registry = registry_with_mock_resolver(rt(), resolver);
9063        assert_eq!(registry.resolvers().len(), 1);
9064        assert_eq!(registry.resolvers()[0].0, "mock");
9065    }
9066
9067    #[tokio::test]
9068    async fn mock_resolver_resolve_by_id_returns_pack_record() {
9069        let id = Uuid::new_v4();
9070        let resolver: Box<dyn PackByIdResolver> = Box::new(MockPackResolver::new());
9071        // We need interior access — downcast first, then use via trait.
9072        let inner = MockPackResolver::new();
9073        inner.0.lock().await.owned.push(id);
9074        let result: Result<Option<Resolved>, RuntimeError> = inner.resolve_by_id(id).await;
9075        match result.unwrap() {
9076            Some(Resolved::PackRecord { pack, kind, data }) => {
9077                assert_eq!(pack, "mock");
9078                assert_eq!(kind, "widget");
9079                assert_eq!(data["id"].as_str().unwrap(), id.to_string());
9080            }
9081            other => panic!("expected PackRecord, got {:?}", other),
9082        }
9083        let _ = resolver;
9084    }
9085
9086    #[tokio::test]
9087    async fn mock_resolver_resolve_unknown_uuid_returns_none() {
9088        let inner = MockPackResolver::new();
9089        let id = Uuid::new_v4();
9090        let result: Result<Option<Resolved>, RuntimeError> = inner.resolve_by_id(id).await;
9091        assert!(result.unwrap().is_none());
9092    }
9093
9094    #[tokio::test]
9095    async fn mock_resolver_delete_soft_records_call() {
9096        let id = Uuid::new_v4();
9097        let inner = MockPackResolver::new();
9098        inner.0.lock().await.owned.push(id);
9099
9100        let result: Result<serde_json::Value, RuntimeError> = inner.delete_by_id(id, false).await;
9101        let result = result.unwrap();
9102        assert_eq!(result["deleted"], serde_json::json!(true));
9103        assert_eq!(result["hard"], serde_json::json!(false));
9104
9105        // After soft-delete: resolve_by_id returns None, but including_deleted returns Some.
9106        let live: Result<Option<Resolved>, RuntimeError> = inner.resolve_by_id(id).await;
9107        assert!(live.unwrap().is_none());
9108        let incl: Result<Option<Resolved>, RuntimeError> =
9109            inner.resolve_by_id_including_deleted(id).await;
9110        assert!(incl.unwrap().is_some());
9111    }
9112
9113    #[tokio::test]
9114    async fn mock_resolver_delete_hard_removes_record() {
9115        let id = Uuid::new_v4();
9116        let inner = MockPackResolver::new();
9117        inner.0.lock().await.owned.push(id);
9118
9119        let result: Result<serde_json::Value, RuntimeError> = inner.delete_by_id(id, true).await;
9120        assert_eq!(result.unwrap()["hard"], serde_json::json!(true));
9121
9122        // After hard-delete: neither probe finds the record.
9123        let incl: Result<Option<Resolved>, RuntimeError> =
9124            inner.resolve_by_id_including_deleted(id).await;
9125        assert!(incl.unwrap().is_none());
9126    }
9127
9128    #[tokio::test]
9129    async fn pack_record_not_valid_context_entity() {
9130        // Validates the GTD handler arm compiles and returns InvalidInput.
9131        // We exercise the match logic directly by constructing a PackRecord Resolved.
9132        let pr = Resolved::PackRecord {
9133            pack: "knowledge".into(),
9134            kind: "atom".into(),
9135            data: serde_json::json!({}),
9136        };
9137        // The match in GTD handlers.rs now handles PackRecord → InvalidInput.
9138        // We can verify the enum variant is reachable.
9139        assert!(matches!(pr, Resolved::PackRecord { .. }));
9140    }
9141
9142    // ── #249 regression: batched enrich_neighbor_hits / enrich_path_nodes ────
9143
9144    fn neighbor_hit(node_id: Uuid) -> NeighborHit {
9145        NeighborHit {
9146            node_id,
9147            edge_id: Uuid::new_v4(),
9148            relation: EdgeRelation::Extends,
9149            weight: 1.0,
9150            name: None,
9151            kind: None,
9152            entity_type: None,
9153        }
9154    }
9155
9156    fn path_node(node_id: Uuid, depth: usize) -> PathNode {
9157        PathNode {
9158            node_id,
9159            via_edge: None,
9160            depth,
9161            name: None,
9162            kind: None,
9163            properties: None,
9164        }
9165    }
9166
9167    /// enrich_neighbor_hits: entity hit resolved, note hit resolved with
9168    /// name-fallback to "[kind]", bogus UUID left as None.  Order preserved.
9169    #[tokio::test]
9170    async fn enrich_neighbor_hits_batch_entity_note_and_bogus() {
9171        let rt = rt();
9172        let tok = NamespaceToken::local();
9173
9174        // Create an entity neighbor.
9175        let entity = rt
9176            .create_entity(&tok, "concept", None, "MyEntity", None, None, vec![])
9177            .await
9178            .unwrap();
9179
9180        // Nameless note — name falls back to "[observation]".
9181        let note = rt
9182            .create_note(&tok, "observation", None, "body", Some(0.5), None, vec![])
9183            .await
9184            .unwrap();
9185
9186        let bogus_id = Uuid::new_v4();
9187
9188        let mut hits = vec![
9189            neighbor_hit(entity.id),
9190            neighbor_hit(note.id),
9191            neighbor_hit(bogus_id),
9192        ];
9193
9194        rt.enrich_neighbor_hits(&tok, &mut hits).await;
9195
9196        assert_eq!(hits[0].name.as_deref(), Some("MyEntity"));
9197        assert_eq!(hits[0].kind.as_deref(), Some("concept"));
9198
9199        assert_eq!(hits[1].name.as_deref(), Some("[observation]"));
9200        assert_eq!(hits[1].kind.as_deref(), Some("observation"));
9201
9202        assert!(hits[2].name.is_none());
9203        assert!(hits[2].kind.is_none());
9204    }
9205
9206    /// enrich_neighbor_hits: note with a non-empty name uses the actual name.
9207    #[tokio::test]
9208    async fn enrich_neighbor_hits_note_with_name_uses_name() {
9209        let rt = rt();
9210        let tok = NamespaceToken::local();
9211
9212        let note = rt
9213            .create_note(
9214                &tok,
9215                "insight",
9216                Some("NoteTitle"),
9217                "body",
9218                Some(0.5),
9219                None,
9220                vec![],
9221            )
9222            .await
9223            .unwrap();
9224
9225        let mut hits = vec![neighbor_hit(note.id)];
9226        rt.enrich_neighbor_hits(&tok, &mut hits).await;
9227
9228        assert_eq!(hits[0].name.as_deref(), Some("NoteTitle"));
9229        assert_eq!(hits[0].kind.as_deref(), Some("insight"));
9230    }
9231
9232    /// enrich_path_nodes: two paths sharing a repeated node_id; each node
9233    /// enriched from a single batch; unresolved node stays None.
9234    #[tokio::test]
9235    async fn enrich_path_nodes_batch_dedup_and_unresolved() {
9236        let rt = rt();
9237        let tok = NamespaceToken::local();
9238
9239        let ea = rt
9240            .create_entity(&tok, "concept", None, "Alpha", None, None, vec![])
9241            .await
9242            .unwrap();
9243        let eb = rt
9244            .create_entity(&tok, "document", None, "Beta", None, None, vec![])
9245            .await
9246            .unwrap();
9247        let bogus_id = Uuid::new_v4();
9248
9249        // Path 1: ea → eb → bogus  |  Path 2: eb → ea  (shared nodes, reversed)
9250        let mut paths = vec![
9251            GraphPath {
9252                root_id: ea.id,
9253                nodes: vec![
9254                    path_node(ea.id, 0),
9255                    path_node(eb.id, 1),
9256                    path_node(bogus_id, 2),
9257                ],
9258                total_weight: 1.0,
9259            },
9260            GraphPath {
9261                root_id: eb.id,
9262                nodes: vec![path_node(eb.id, 0), path_node(ea.id, 1)],
9263                total_weight: 1.0,
9264            },
9265        ];
9266
9267        rt.enrich_path_nodes(&tok, &mut paths, false).await;
9268
9269        assert_eq!(paths[0].nodes[0].name.as_deref(), Some("Alpha"));
9270        assert_eq!(paths[0].nodes[0].kind.as_deref(), Some("concept"));
9271        assert_eq!(paths[0].nodes[1].name.as_deref(), Some("Beta"));
9272        assert_eq!(paths[0].nodes[1].kind.as_deref(), Some("document"));
9273        assert!(paths[0].nodes[2].name.is_none());
9274        assert!(paths[0].nodes[2].kind.is_none());
9275
9276        // Shared nodes resolve from the same HashMap — order within each path is preserved.
9277        assert_eq!(paths[1].nodes[0].name.as_deref(), Some("Beta"));
9278        assert_eq!(paths[1].nodes[0].kind.as_deref(), Some("document"));
9279        assert_eq!(paths[1].nodes[1].name.as_deref(), Some("Alpha"));
9280        assert_eq!(paths[1].nodes[1].kind.as_deref(), Some("concept"));
9281    }
9282
9283    /// enrich_neighbor_hits and enrich_path_nodes must resolve entities whose
9284    /// namespace is in the token's extra-visible set (not only the primary).
9285    ///
9286    /// Regression for PR #253 review finding: the old `get_entities_by_ids`
9287    /// call left `filter.namespaces` unset, which collapses to
9288    /// `namespace = primary` in `build_entity_where`.  Graph expansion already
9289    /// crosses visible namespaces, so enrichment must match that scope.
9290    #[tokio::test]
9291    async fn enrich_resolves_entities_in_extra_visible_namespace() {
9292        let rt = KhiveRuntime::memory().unwrap();
9293
9294        let ns_a = Namespace::parse("enrich-ns-a").unwrap();
9295        let ns_b = Namespace::parse("enrich-ns-b").unwrap();
9296
9297        let tok_b = rt.authorize(ns_b.clone()).unwrap();
9298
9299        // Entity lives in ns-b.
9300        let entity_b = rt
9301            .create_entity(&tok_b, "concept", None, "EntityInB", None, None, vec![])
9302            .await
9303            .unwrap();
9304        assert_eq!(entity_b.namespace, "enrich-ns-b");
9305
9306        // Token whose primary is ns-a but ns-b is in the visible set.
9307        let vis_tok = rt
9308            .authorize_with_visibility(ns_a.clone(), vec![ns_b.clone()])
9309            .unwrap();
9310
9311        // ── neighbor hits ──────────────────────────────────────────────────
9312        let mut hits = vec![neighbor_hit(entity_b.id)];
9313        rt.enrich_neighbor_hits(&vis_tok, &mut hits).await;
9314
9315        assert_eq!(
9316            hits[0].name.as_deref(),
9317            Some("EntityInB"),
9318            "entity in extra-visible ns must be enriched by enrich_neighbor_hits"
9319        );
9320        assert_eq!(hits[0].kind.as_deref(), Some("concept"));
9321
9322        // ── path nodes ─────────────────────────────────────────────────────
9323        let mut paths = vec![GraphPath {
9324            root_id: entity_b.id,
9325            nodes: vec![path_node(entity_b.id, 0)],
9326            total_weight: 1.0,
9327        }];
9328        rt.enrich_path_nodes(&vis_tok, &mut paths, false).await;
9329
9330        assert_eq!(
9331            paths[0].nodes[0].name.as_deref(),
9332            Some("EntityInB"),
9333            "entity in extra-visible ns must be enriched by enrich_path_nodes"
9334        );
9335        assert_eq!(paths[0].nodes[0].kind.as_deref(), Some("concept"));
9336    }
9337
9338    /// enrich_neighbor_hits populates entity_type from the already-fetched entity
9339    /// batch when the entity has a non-null entity_type.  Entities without one and
9340    /// note nodes leave entity_type as None.
9341    #[tokio::test]
9342    async fn enrich_neighbor_hits_populates_entity_type() {
9343        let rt = rt();
9344        let tok = NamespaceToken::local();
9345
9346        let props = serde_json::json!({"domain": "attention"});
9347        let entity = rt
9348            .create_entity(
9349                &tok,
9350                "concept",
9351                Some("algorithm"),
9352                "FlashAttn",
9353                None,
9354                Some(props),
9355                vec![],
9356            )
9357            .await
9358            .unwrap();
9359
9360        let entity_no_type = rt
9361            .create_entity(&tok, "concept", None, "PlainConcept", None, None, vec![])
9362            .await
9363            .unwrap();
9364
9365        let mut hits = vec![neighbor_hit(entity.id), neighbor_hit(entity_no_type.id)];
9366        rt.enrich_neighbor_hits(&tok, &mut hits).await;
9367
9368        assert_eq!(hits[0].entity_type.as_deref(), Some("algorithm"));
9369        assert!(
9370            hits[1].entity_type.is_none(),
9371            "entity without entity_type must leave the field as None"
9372        );
9373    }
9374
9375    /// enrich_path_nodes populates properties from the already-fetched entity
9376    /// batch when the entity has a non-null properties blob.  Entities without
9377    /// properties leave the field as None.
9378    #[tokio::test]
9379    async fn enrich_path_nodes_populates_properties() {
9380        let rt = rt();
9381        let tok = NamespaceToken::local();
9382
9383        let props = serde_json::json!({"year": 2024, "venue": "NeurIPS"});
9384        let entity_with_props = rt
9385            .create_entity(
9386                &tok,
9387                "document",
9388                None,
9389                "AttentionPaper",
9390                None,
9391                Some(props.clone()),
9392                vec![],
9393            )
9394            .await
9395            .unwrap();
9396
9397        let entity_no_props = rt
9398            .create_entity(&tok, "concept", None, "BareConceptNode", None, None, vec![])
9399            .await
9400            .unwrap();
9401
9402        let mut paths = vec![GraphPath {
9403            root_id: entity_with_props.id,
9404            nodes: vec![
9405                path_node(entity_with_props.id, 0),
9406                path_node(entity_no_props.id, 1),
9407            ],
9408            total_weight: 1.0,
9409        }];
9410
9411        rt.enrich_path_nodes(&tok, &mut paths, true).await;
9412
9413        assert_eq!(
9414            paths[0].nodes[0].properties.as_ref(),
9415            Some(&props),
9416            "properties must be filled when entity has a non-null properties blob"
9417        );
9418        assert!(
9419            paths[0].nodes[1].properties.is_none(),
9420            "entity without properties must leave the field as None"
9421        );
9422    }
9423
9424    /// Regression: GraphStore::traverse must not fail with "too many SQL variables"
9425    /// or "too many terms in compound SELECT" when the root set exceeds the chunk
9426    /// boundary (400 roots per CTE VALUES clause after the fix).
9427    ///
9428    /// Graph: 1 000 roots, each with one distinct outgoing edge to a unique child.
9429    /// The graph store's `traverse` is exercised directly (bypassing the runtime-level
9430    /// entity-existence filter) to keep the test fast and targeted.
9431    ///
9432    /// Correctness: every root must appear in the result with exactly one reachable node.
9433    #[tokio::test]
9434    async fn traverse_chunks_root_binds_over_host_param_limit() {
9435        use khive_storage::types::TraversalOptions;
9436
9437        let rt = rt();
9438        let tok = NamespaceToken::local();
9439        let graph = rt.graph(&tok).unwrap();
9440
9441        const N: usize = 1_000;
9442        let now = chrono::Utc::now();
9443
9444        let mut roots: Vec<uuid::Uuid> = Vec::with_capacity(N);
9445        let mut expected_children: std::collections::HashMap<uuid::Uuid, uuid::Uuid> =
9446            std::collections::HashMap::with_capacity(N);
9447
9448        for _ in 0..N {
9449            let root = uuid::Uuid::new_v4();
9450            let child = uuid::Uuid::new_v4();
9451            graph
9452                .upsert_edge(Edge {
9453                    id: LinkId::from(uuid::Uuid::new_v4()),
9454                    namespace: "local".to_string(),
9455                    source_id: root,
9456                    target_id: child,
9457                    relation: EdgeRelation::Extends,
9458                    weight: 1.0,
9459                    created_at: now,
9460                    updated_at: now,
9461                    deleted_at: None,
9462                    metadata: None,
9463                    target_backend: None,
9464                })
9465                .await
9466                .unwrap();
9467            roots.push(root);
9468            expected_children.insert(root, child);
9469        }
9470
9471        // Must return Ok: no "too many SQL variables" or "too many terms in compound SELECT".
9472        let paths = graph
9473            .traverse(TraversalRequest {
9474                roots: roots.clone(),
9475                options: TraversalOptions {
9476                    max_depth: 1,
9477                    direction: Direction::Out,
9478                    relations: None,
9479                    min_weight: None,
9480                    limit: None,
9481                },
9482                include_roots: false,
9483                include_properties: false,
9484            })
9485            .await
9486            .unwrap();
9487
9488        assert_eq!(
9489            paths.len(),
9490            N,
9491            "traverse over {N} roots must return one GraphPath per root"
9492        );
9493
9494        for path in &paths {
9495            let expected_child = expected_children[&path.root_id];
9496            assert_eq!(
9497                path.nodes.len(),
9498                1,
9499                "root {:?} must reach exactly 1 node",
9500                path.root_id
9501            );
9502            assert_eq!(
9503                path.nodes[0].node_id, expected_child,
9504                "root {:?} must reach its direct child",
9505                path.root_id
9506            );
9507        }
9508    }
9509
9510    // ── Additive EDGE_RULES composition: pack EntityOfType rules must not shadow
9511    // the base EntityOfKind contract for the same relation. ──────────────────────
9512    //
9513    // When a pack contributes EntityOfType rules for a relation (e.g. variant_of:
9514    // goal -> theorem and goal -> definition), the base contract's EntityOfKind
9515    // rule for the same relation (concept -> concept) must still fire for entities
9516    // whose base kind is "concept" but whose EntityOfType pair is not in any pack rule.
9517    //
9518    // Specifically: a goal entity resolves to base kind "concept". A goal -> goal
9519    // variant_of edge has no matching pack rule (goal -> goal is not declared), so
9520    // pack_rule_allows returns false. The validator then extracts the base kind
9521    // ("concept") and checks base_entity_rule_allows, which returns true.
9522    // The edge is therefore allowed: additive composition holds.
9523
9524    #[test]
9525    fn pack_entity_of_type_rules_do_not_shadow_base_entity_of_kind_rule() {
9526        // Formal-style EntityOfType rules for variant_of: goal -> theorem, goal -> definition.
9527        // goal -> goal is deliberately absent — that case must fall through to the base rule.
9528        let pack_rules: Vec<EdgeEndpointRule> = vec![
9529            EdgeEndpointRule {
9530                relation: EdgeRelation::VariantOf,
9531                source: EndpointKind::EntityOfType {
9532                    kind: "concept",
9533                    entity_type: "goal",
9534                },
9535                target: EndpointKind::EntityOfType {
9536                    kind: "concept",
9537                    entity_type: "theorem",
9538                },
9539            },
9540            EdgeEndpointRule {
9541                relation: EdgeRelation::VariantOf,
9542                source: EndpointKind::EntityOfType {
9543                    kind: "concept",
9544                    entity_type: "goal",
9545                },
9546                target: EndpointKind::EntityOfType {
9547                    kind: "concept",
9548                    entity_type: "definition",
9549                },
9550            },
9551        ];
9552
9553        let goal_a =
9554            Resolved::Entity(Entity::new("local", "concept", "G-a").with_entity_type(Some("goal")));
9555        let goal_b =
9556            Resolved::Entity(Entity::new("local", "concept", "G-b").with_entity_type(Some("goal")));
9557
9558        // Pack rules do not cover goal -> goal, so pack_rule_allows must return false.
9559        assert!(
9560            !pack_rule_allows(
9561                &pack_rules,
9562                EdgeRelation::VariantOf,
9563                Some(&goal_a),
9564                Some(&goal_b)
9565            ),
9566            "pack rules must not cover goal->goal variant_of (no such rule declared)"
9567        );
9568
9569        // The base contract allows concept -> concept for variant_of.
9570        // A goal entity's base kind is "concept", so this must return true.
9571        assert!(
9572            base_entity_rule_allows("concept", EdgeRelation::VariantOf, "concept"),
9573            "base contract must allow concept->concept variant_of regardless of pack EntityOfType rules"
9574        );
9575    }
9576
9577    // Integration path: pack EntityOfType rules installed on the runtime must not
9578    // block a goal->goal variant_of link that the base contract already permits.
9579    // Exercises validate_edge_relation_endpoints lines 1173-1223:
9580    //   pack miss -> extract e.kind ("concept") -> base_entity_rule_allows -> Ok.
9581    #[tokio::test]
9582    async fn link_variant_of_goal_to_goal_allowed_when_pack_has_entity_of_type_rules() {
9583        let rt = rt();
9584        let tok = NamespaceToken::local();
9585
9586        // Install formal-style EntityOfType rules for variant_of (goal -> theorem/definition).
9587        // goal -> goal is absent so the base concept->concept rule must carry this case.
9588        rt.install_edge_rules(vec![
9589            EdgeEndpointRule {
9590                relation: EdgeRelation::VariantOf,
9591                source: EndpointKind::EntityOfType {
9592                    kind: "concept",
9593                    entity_type: "goal",
9594                },
9595                target: EndpointKind::EntityOfType {
9596                    kind: "concept",
9597                    entity_type: "theorem",
9598                },
9599            },
9600            EdgeEndpointRule {
9601                relation: EdgeRelation::VariantOf,
9602                source: EndpointKind::EntityOfType {
9603                    kind: "concept",
9604                    entity_type: "goal",
9605                },
9606                target: EndpointKind::EntityOfType {
9607                    kind: "concept",
9608                    entity_type: "definition",
9609                },
9610            },
9611        ]);
9612
9613        let a = rt
9614            .create_entity(
9615                &tok,
9616                "concept",
9617                Some("goal"),
9618                "Goal Alpha",
9619                None,
9620                None,
9621                vec![],
9622            )
9623            .await
9624            .unwrap();
9625        let b = rt
9626            .create_entity(
9627                &tok,
9628                "concept",
9629                Some("goal"),
9630                "Goal Beta",
9631                None,
9632                None,
9633                vec![],
9634            )
9635            .await
9636            .unwrap();
9637
9638        // Neither endpoint matches any pack rule (no goal->goal rule).
9639        // The base concept->concept rule must fire and allow the edge.
9640        let result = rt
9641            .link(&tok, a.id, b.id, EdgeRelation::VariantOf, 1.0, None)
9642            .await;
9643        assert!(
9644            result.is_ok(),
9645            "goal->goal variant_of must be allowed via the base concept->concept rule \
9646             even when EntityOfType rules for variant_of are installed; got {result:?}"
9647        );
9648
9649        // Fail-closed check: additive rules must not make validation fail-open.
9650        // A goal(concept) -> project variant_of edge has no matching pack rule
9651        // (project is not in the installed variant_of rules) and no matching base
9652        // rule (no (concept, VariantOf, project) row). It must be rejected.
9653        let p = rt
9654            .create_entity(&tok, "project", None, "Proj", None, None, vec![])
9655            .await
9656            .unwrap();
9657        let bad = rt
9658            .link(&tok, a.id, p.id, EdgeRelation::VariantOf, 1.0, None)
9659            .await;
9660        assert!(
9661            bad.is_err(),
9662            "additive pack rules must not make validation fail-open; \
9663             goal(concept)->project variant_of must be rejected (pack miss + base miss); \
9664             got {bad:?}"
9665        );
9666    }
9667
9668    // Load-bearing positive: a pack EntityOfType rule adds an endpoint the base contract
9669    // does not cover. The base contract has no (concept, DependsOn, concept) row (the
9670    // DependsOn rows are project/service/artifact only). A theorem->definition DependsOn
9671    // edge can therefore ONLY pass through the pack rule, proving the union is load-bearing.
9672    #[tokio::test]
9673    async fn link_depends_on_theorem_to_definition_allowed_only_via_pack_rule() {
9674        let rt = rt();
9675        let tok = NamespaceToken::local();
9676
9677        // Confirm the base contract does NOT allow concept->concept DependsOn.
9678        // (Documented here so the assertion below is not a tautology.)
9679        assert!(
9680            !base_entity_rule_allows("concept", EdgeRelation::DependsOn, "concept"),
9681            "base contract must not allow concept->concept DependsOn; \
9682             test would be vacuous if this precondition fails"
9683        );
9684
9685        // Install a single EntityOfType rule: theorem depends_on definition.
9686        // With no rules, the link would be rejected by the base contract.
9687        // With this rule, it must be accepted via the pack path (lines 1173-1179).
9688        rt.install_edge_rules(vec![EdgeEndpointRule {
9689            relation: EdgeRelation::DependsOn,
9690            source: EndpointKind::EntityOfType {
9691                kind: "concept",
9692                entity_type: "theorem",
9693            },
9694            target: EndpointKind::EntityOfType {
9695                kind: "concept",
9696                entity_type: "definition",
9697            },
9698        }]);
9699
9700        let thm = rt
9701            .create_entity(&tok, "concept", Some("theorem"), "T1", None, None, vec![])
9702            .await
9703            .unwrap();
9704        let def = rt
9705            .create_entity(
9706                &tok,
9707                "concept",
9708                Some("definition"),
9709                "D1",
9710                None,
9711                None,
9712                vec![],
9713            )
9714            .await
9715            .unwrap();
9716
9717        // This can only pass through the pack rule — the base contract rejects it.
9718        let result = rt
9719            .link(&tok, thm.id, def.id, EdgeRelation::DependsOn, 1.0, None)
9720            .await;
9721        assert!(
9722            result.is_ok(),
9723            "theorem->definition DependsOn must be allowed by the installed pack rule; \
9724             the base contract has no concept->concept DependsOn row; got {result:?}"
9725        );
9726    }
9727}