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, DirectedNeighborHit, Direction, EdgeSortField, GraphPath,
21    LinkId, NeighborHit, NeighborQuery, Page, PageRequest, SortOrder, SqlRow, SqlStatement,
22    SqlValue, TextFilter, TextQueryMode, TextSearchRequest, TraversalRequest,
23};
24use khive_storage::{Edge, EdgeRelation, Entity, EntityFilter, Event, EventFilter};
25use khive_types::{EdgeEndpointRule, EndpointKind, EventKind, SubstrateKind};
26
27use khive_db::stores::entity::entity_hard_delete_statement;
28use khive_db::stores::graph::{edge_hard_delete_statement, purge_incident_edges_statement};
29use khive_db::stores::note::note_hard_delete_statement;
30use khive_db::SqliteError;
31use rusqlite::OptionalExtension;
32
33use crate::atomic_plan::{AffectedRowGuard, DeletePlan, PlanStatement, PostCommitEffect};
34use crate::atomic_runner::{run_atomic_unit, AtomicOpFailure, AtomicOpPlan, AtomicRunOutcome};
35use crate::curation::{entity_fts_document, note_fts_document};
36use crate::error::{GuardedWriteFailure, RuntimeError, RuntimeResult};
37use crate::runtime::{KhiveRuntime, NamespaceToken};
38
39// Test-only failure injection for `create_note_inner`. Namespace-targeted so only
40// calls for the armed namespace fire, avoiding cross-test races without `#[serial]`.
41// Gated behind `cfg(any(test, feature = "fault-injection"))` so no lock acquisitions
42// or injection surface exist in production/published binaries. External integration
43// test crates enable it via a dev-dependency: khive-runtime = { ..., features = ["fault-injection"] }
44#[cfg(test)]
45std::thread_local! {
46    static LINK_FAIL_AFTER: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
47}
48
49// Count-targetable vector-INSERT fault injection: when set to N (N > 0), the next N
50// vector insert calls (entity or note, single- or multi-model) succeed and the
51// (N+1)-th returns an injected error, then the counter resets to 0.
52// `thread_local!` provides per-thread isolation (`#[tokio::test]` uses a
53// current-thread runtime, so there is no thread migration mid-test), letting a
54// test fail one specific model's insert in a multi-model fan-out and giving any
55// caller whose test suite shares a default namespace a deterministic, race-free
56// injection instead of depending on `VECTOR_FAIL_NS`'s namespace match.
57#[cfg(any(test, feature = "fault-injection"))]
58std::thread_local! {
59    static VECTOR_FAIL_AFTER: std::cell::Cell<Option<usize>> =
60        const { std::cell::Cell::new(None) };
61}
62
63/// Arm the count-targetable vector-INSERT fault: let `n` inserts succeed, then fail
64/// the next one (entity or note, single- or multi-model). Set `n = 0` to fail
65/// immediately on the first insert. Thread-local, so unlike `arm_vector_fail`
66/// it cannot be won or disarmed by a concurrently-running test on another
67/// thread — prefer this one whenever the caller cannot guarantee it is the
68/// only test writing into the namespace it cares about.
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// Namespace-keyed one-shot set, not a single `Option<String>` slot:
76// `create_note_inner` and `create_entity_inner` share this flag, and a
77// single-slot design let a concurrently running test's `arm_fts_fail(other_ns)`
78// overwrite this test's armed namespace before its own create call consumed
79// it, so the intended injection silently never fired (#1095). Keying by
80// namespace fixes that at the root — arming `ns_B` inserts `ns_B` without
81// evicting `ns_A`. Process-wide (not thread-local) so a caller may arm on
82// one OS thread and run the triggering `create_note`/`create_entity` on
83// another (e.g. via `tokio::spawn` on a multi-thread runtime); the
84// check-and-remove under the mutex lock keeps exactly-once semantics even
85// under concurrent same-namespace creates.
86#[cfg(any(test, feature = "fault-injection"))]
87static FTS_FAIL_NS: std::sync::LazyLock<std::sync::Mutex<std::collections::HashSet<String>>> =
88    std::sync::LazyLock::new(|| std::sync::Mutex::new(std::collections::HashSet::new()));
89#[cfg(any(test, feature = "fault-injection"))]
90static VECTOR_FAIL_NS: std::sync::Mutex<Option<String>> = std::sync::Mutex::new(None);
91/// FTS failure injection for `create_many` — separate from `FTS_FAIL_NS` so that
92/// create_note_inner and create_many tests cannot disarm each other.
93#[cfg(any(test, feature = "fault-injection"))]
94static FTS_FAIL_MANY_NS: std::sync::Mutex<Option<String>> = std::sync::Mutex::new(None);
95/// FTS partial-failure injection for `create_many` — returns `Ok(BatchWriteSummary)`
96/// with `failed > 0` so that the `summary.failed > 0` rollback branch is exercised.
97/// Distinct from `FTS_FAIL_MANY_NS` which injects a hard `Err`.
98#[cfg(any(test, feature = "fault-injection"))]
99static FTS_FAIL_MANY_PARTIAL_NS: std::sync::Mutex<Option<String>> = std::sync::Mutex::new(None);
100/// Non-parser FTS *search*-leg failure injection for `search_notes`: distinct
101/// from `FTS_FAIL_NS` (which injects at the FTS *upsert*/write step of
102/// `create_note_inner`). Injects a `StorageError::Timeout` at the `search()`
103/// call the FTS fail-open arm guards, so the arm's `is_fts5_syntax_error()`
104/// gate can be exercised against a genuine non-parser failure and asserted to
105/// propagate rather than degrade.
106#[cfg(any(test, feature = "fault-injection"))]
107static FTS_SEARCH_FAIL_NS: std::sync::Mutex<Option<String>> = std::sync::Mutex::new(None);
108
109/// Arm a one-shot FTS failure injection for `create_note_inner`/`create_entity_inner`
110/// targeting namespace `ns`.
111///
112/// The next `create_note` or `create_entity` call whose namespace equals `ns` returns
113/// an injected error at the FTS upsert step (after the row is committed), then disarms
114/// — only that namespace's entry is consumed. The arm is process-wide and thread
115/// independent: it may be set from one OS thread and consumed by a `create_note`/
116/// `create_entity` call running on another (e.g. inside `tokio::spawn`). Concurrent
117/// arms of distinct namespaces do not interfere with each other.
118/// Available when compiled with `cfg(test)` or `feature = "fault-injection"`.
119#[cfg(any(test, feature = "fault-injection"))]
120pub fn arm_fts_fail(ns: &str) {
121    FTS_FAIL_NS.lock().unwrap().insert(ns.to_string());
122}
123
124/// Arm the FTS failure injection for `create_many` targeting namespace `ns`.
125///
126/// The next `create_many` call whose namespace equals `ns` returns an injected
127/// error at the FTS upsert step (after entity rows are committed), then disarms.
128/// Calls on other namespaces are unaffected.
129/// Available when compiled with `cfg(test)` or `feature = "fault-injection"`.
130#[cfg(any(test, feature = "fault-injection"))]
131pub fn arm_fts_fail_many(ns: &str) {
132    *FTS_FAIL_MANY_NS.lock().unwrap() = Some(ns.to_string());
133}
134
135/// Arm the FTS *partial*-failure injection for `create_many` targeting namespace `ns`.
136///
137/// The next `create_many` call whose namespace equals `ns` returns
138/// `Ok(BatchWriteSummary { attempted: 2, affected: 1, failed: 1, ... })` from the
139/// FTS upsert step, exercising the `summary.failed > 0` rollback branch (as opposed
140/// to the hard-`Err` branch exercised by `arm_fts_fail_many`).  Then disarms.
141/// Available when compiled with `cfg(test)` or `feature = "fault-injection"`.
142#[cfg(any(test, feature = "fault-injection"))]
143pub fn arm_fts_fail_many_partial(ns: &str) {
144    *FTS_FAIL_MANY_PARTIAL_NS.lock().unwrap() = Some(ns.to_string());
145}
146
147/// Arm a non-parser FTS *search*-leg failure injection for `search_notes` targeting
148/// any call whose visible namespaces include `ns`.
149///
150/// The next `search_notes` call touching `ns` returns `StorageError::Timeout` from
151/// the FTS leg instead of calling the real `TextSearch::search`, then disarms.
152/// Used to prove the fail-open arm in `search_notes` propagates non-parser
153/// `StorageError`s instead of silently degrading them the way a genuine FTS5
154/// parser syntax error is degraded.
155/// Available when compiled with `cfg(test)` or `feature = "fault-injection"`.
156#[cfg(any(test, feature = "fault-injection"))]
157pub fn arm_fts_search_fail(ns: &str) {
158    *FTS_SEARCH_FAIL_NS.lock().unwrap() = Some(ns.to_string());
159}
160
161/// Arm the vector insertion failure injection for `create_note_inner` targeting `ns`.
162///
163/// The next `create_note` call whose note namespace equals `ns` returns an injected
164/// error at the first vector insert step, then disarms.  Calls on other namespaces
165/// are unaffected.
166/// Available when compiled with `cfg(test)` or `feature = "fault-injection"`.
167#[cfg(any(test, feature = "fault-injection"))]
168pub fn arm_vector_fail(ns: &str) {
169    *VECTOR_FAIL_NS.lock().unwrap() = Some(ns.to_string());
170}
171
172/// Failure injection for `delete_note_row_first_for_compensation`'s post-row-removal
173/// cleanup step: distinct from `FTS_FAIL_NS`/`VECTOR_FAIL_NS`, which target
174/// `create_note_inner`. Lets tests prove that a rollback compensation's cleanup
175/// failure still leaves the note row (and thus the live message) gone.
176#[cfg(any(test, feature = "fault-injection"))]
177static ROLLBACK_CLEANUP_FAIL_NS: std::sync::Mutex<Option<String>> = std::sync::Mutex::new(None);
178
179/// Arm the rollback-compensation cleanup failure injection targeting `ns`.
180///
181/// The next `delete_note_row_first_for_compensation` call whose note namespace
182/// equals `ns` removes the row as usual, then returns an injected cleanup error
183/// instead of running the real graph/FTS/vector cleanup, then disarms.
184/// Available when compiled with `cfg(test)` or `feature = "fault-injection"`.
185#[cfg(any(test, feature = "fault-injection"))]
186pub fn arm_rollback_cleanup_fail(ns: &str) {
187    *ROLLBACK_CLEANUP_FAIL_NS.lock().unwrap() = Some(ns.to_string());
188}
189
190/// A note search result with UUID, salience-weighted RRF score, and display text.
191#[derive(Clone, Debug)]
192pub struct NoteSearchHit {
193    pub note_id: Uuid,
194    pub score: DeterministicScore,
195    pub title: Option<String>,
196    pub snippet: Option<String>,
197}
198
199/// Re-insert hyphens at canonical UUID positions (8-4-4-4-12) into a
200/// hyphen-free hex prefix, so a `LIKE '<pattern>%'` scan against the
201/// hyphenated `id` column matches correctly. Prefixes that already
202/// contain a hyphen are passed through unchanged. No-op for len <= 8
203/// (already correct). Input longer than 32 hex chars is NOT truncated: the
204/// extra hex chars are appended past the canonical 12-char final segment
205/// with no further hyphen, so the resulting `LIKE` pattern requires literal
206/// characters beyond position 36 that no real (36-char) UUID string can
207/// ever have — the scan naturally fails closed instead of silently
208/// resolving `<valid-32-hex><extra-hex>` to the valid UUID.
209pub fn hex_prefix_to_uuid_pattern(prefix: &str) -> String {
210    if prefix.contains('-') {
211        return prefix.to_string();
212    }
213    const BOUNDARIES: [usize; 4] = [8, 13, 18, 23]; // post-hyphen-insertion offsets
214    let mut out = String::with_capacity(36);
215    for c in prefix.chars() {
216        if BOUNDARIES.contains(&out.len()) {
217            out.push('-');
218        }
219        out.push(c);
220    }
221    out
222}
223
224fn text_preview(text: &str, max_chars: usize) -> Option<String> {
225    let trimmed = text.trim();
226    if trimmed.is_empty() {
227        None
228    } else {
229        Some(trimmed.chars().take(max_chars).collect())
230    }
231}
232
233/// Symmetric relations (`competes_with`, `composed_with`) are stored with a
234/// canonical source (lower UUID wins), so a directed `Out` or `In` query may
235/// miss results. When the relations filter is non-empty and contains **only**
236/// symmetric relations, override direction to `Both` so callers always see all
237/// edges for these relations regardless of storage canonicalization.
238fn normalize_symmetric_direction(
239    direction: Direction,
240    relations: Option<&[EdgeRelation]>,
241) -> Direction {
242    let Some(rels) = relations else {
243        return direction;
244    };
245    if rels.is_empty() {
246        return direction;
247    }
248    let all_symmetric = rels
249        .iter()
250        .all(|r| matches!(r, EdgeRelation::CompetesWith | EdgeRelation::ComposedWith));
251    if all_symmetric {
252        Direction::Both
253    } else {
254        direction
255    }
256}
257
258/// Stable tie-break rank for [`Direction`] — `Out` before `In` — used to make
259/// the both-direction sort/dedup key total over self-loop edges. A self-loop
260/// (`source_id == target_id == node_id`) produces two `UNION ALL` rows with
261/// the same `(node_id, edge_id)` but opposite directions; without direction in
262/// the key, sort-then-dedup collapses them to one and drops the direction
263/// parity a separate `Out` call plus a separate `In` call would preserve.
264fn direction_sort_rank(direction: &Direction) -> u8 {
265    match direction {
266        Direction::Out => 0,
267        Direction::In => 1,
268        Direction::Both => 2,
269    }
270}
271
272fn note_title(note: &Note) -> Option<String> {
273    note.name
274        .clone()
275        .filter(|s| !s.trim().is_empty())
276        .or_else(|| Some(format!("[{}]", note.kind.as_str())))
277}
278
279fn note_snippet(note: &Note) -> Option<String> {
280    text_preview(&note.content, 200)
281}
282
283/// Result of resolving a UUID to its substrate kind.
284#[derive(Clone, Debug)]
285pub enum Resolved {
286    Entity(Entity),
287    Note(Note),
288    Event(Event),
289    /// A record owned by a pack's private tables.
290    ///
291    /// `pack` identifies the owning pack by name, `kind` is the pack-local
292    /// record type (e.g. "domain", "atom"), and `data` is the full record as
293    /// a JSON Value. Pack-private records are not valid edge endpoints,
294    /// annotates sources, or task context entities.
295    PackRecord {
296        pack: String,
297        kind: String,
298        data: serde_json::Value,
299    },
300}
301
302/// A by-ID edge-endpoint substrate kind, including `Edge` itself.
303///
304/// Unlike [`Resolved`], this carries no record data — it is used where only
305/// the substrate classification is needed (coordinator locate/link parity
306/// with `get`, ADR-002 rule 1: `annotates` target may be entity, note, edge,
307/// or event).
308#[derive(Clone, Copy, Debug, Eq, PartialEq)]
309pub enum EdgeEndpointKind {
310    Entity,
311    Note,
312    Event,
313    Edge,
314}
315
316/// Map a resolved endpoint to its `(substrate, kind, entity_type)` triple, or
317/// `None` if the substrate is not a valid edge endpoint (events, edges).
318///
319/// `entity_type` carries the pack-owned granular subtype (`Entity::entity_type`,
320/// e.g. `"theorem"`); it is `None` for notes and for entities with no subtype.
321fn resolved_pair(r: Option<&Resolved>) -> Option<(&'static str, &str, Option<&str>)> {
322    match r? {
323        Resolved::Entity(e) => Some(("entity", e.kind.as_str(), e.entity_type.as_deref())),
324        Resolved::Note(n) => Some(("note", n.kind.as_str(), None)),
325        Resolved::Event(_) => None,
326        Resolved::PackRecord { .. } => None,
327    }
328}
329
330/// `true` if `spec` matches the given substrate + kind + entity_type triple.
331///
332/// Pure and DB-free — exposed so offline consumers (e.g. `kkernel kg
333/// validate`, which parses `(substrate, kind, entity_type)` straight out of
334/// NDJSON with no live record to resolve) can apply the exact same
335/// `EdgeEndpointRule` matching semantics `pack_rule_allows` uses internally,
336/// instead of re-deriving a parallel matcher that could drift out of sync.
337pub fn endpoint_matches(
338    spec: &EndpointKind,
339    substrate: &str,
340    kind: &str,
341    entity_type: Option<&str>,
342) -> bool {
343    match spec {
344        EndpointKind::EntityOfKind(k) => substrate == "entity" && *k == kind,
345        EndpointKind::NoteOfKind(k) => substrate == "note" && *k == kind,
346        EndpointKind::EntityOfType {
347            kind: k,
348            entity_type: t,
349        } => substrate == "entity" && *k == kind && entity_type == Some(*t),
350    }
351}
352
353/// Relations that a composed pack `EDGE_RULES` set accepts for a given
354/// `(entity_kind, entity_type)` endpoint pair, using the EXACT SAME
355/// `endpoint_matches` semantics `pack_rule_allows` applies internally
356/// (`EntityOfKind`, `EntityOfType`, `NoteOfKind`) — never a re-filtered copy.
357///
358/// Both endpoints are treated as entities (substrate `"entity"`), matching
359/// the only case pack-layer error-hint code needs (issue #543): a rejected
360/// `link` between two already-resolved entities. `entity_type` is the
361/// pack-owned granular subtype (e.g. `"theorem"`); pass `None` for
362/// untyped entities. Exposed so `khive-pack-kg`'s hint derivation cannot
363/// silently diverge from the validator by only matching `EntityOfKind` and
364/// missing pack rules declared via `EntityOfType` (e.g. `khive-pack-formal`'s
365/// typed `theorem -> definition` `depends_on` rules).
366pub fn accepted_pack_relations_for_entities(
367    rules: &[EdgeEndpointRule],
368    src_kind: &str,
369    src_entity_type: Option<&str>,
370    tgt_kind: &str,
371    tgt_entity_type: Option<&str>,
372) -> Vec<EdgeRelation> {
373    let mut relations: Vec<EdgeRelation> = rules
374        .iter()
375        .filter(|r| {
376            endpoint_matches(&r.source, "entity", src_kind, src_entity_type)
377                && endpoint_matches(&r.target, "entity", tgt_kind, tgt_entity_type)
378        })
379        .map(|r| r.relation)
380        .collect();
381    relations.sort_by_key(|r| r.as_str());
382    relations.dedup();
383    relations
384}
385
386/// `true` if any pack-declared edge endpoint rule allows the
387/// `(source, relation, target)` triple. Pack rules are additive only.
388fn pack_rule_allows(
389    rules: &[EdgeEndpointRule],
390    relation: EdgeRelation,
391    src: Option<&Resolved>,
392    tgt: Option<&Resolved>,
393) -> bool {
394    let Some((src_sub, src_kind, src_type)) = resolved_pair(src) else {
395        return false;
396    };
397    let Some((tgt_sub, tgt_kind, tgt_type)) = resolved_pair(tgt) else {
398        return false;
399    };
400    rules.iter().any(|r| {
401        r.relation == relation
402            && endpoint_matches(&r.source, src_sub, src_kind, src_type)
403            && endpoint_matches(&r.target, tgt_sub, tgt_kind, tgt_type)
404    })
405}
406
407/// Base entity endpoint allowlist — the closed set of permitted entity→entity
408/// relation triples.
409///
410/// Each entry `(src_kind, relation, tgt_kind)` explicitly allows that combination.
411/// `"*"` as `src_kind` means "any entity kind" (used by `instance_of` whose source
412/// is unrestricted).
413///
414/// Pack rules (via `EDGE_RULES`) are additive — they cannot remove rows here.
415/// Exposed via `base_entity_endpoint_rules()` for the ADR-076 certificate tests.
416pub const BASE_ENTITY_ENDPOINT_RULES: &[(&str, EdgeRelation, &str)] = &[
417    // Structure
418    ("concept", EdgeRelation::Contains, "concept"),
419    ("project", EdgeRelation::Contains, "project"),
420    ("project", EdgeRelation::Contains, "artifact"),
421    ("org", EdgeRelation::Contains, "project"),
422    ("org", EdgeRelation::Contains, "service"),
423    ("concept", EdgeRelation::PartOf, "concept"),
424    ("project", EdgeRelation::PartOf, "project"),
425    ("project", EdgeRelation::PartOf, "org"),
426    ("*", EdgeRelation::InstanceOf, "concept"),
427    ("service", EdgeRelation::InstanceOf, "project"),
428    // Derivation
429    ("concept", EdgeRelation::Extends, "concept"),
430    ("concept", EdgeRelation::VariantOf, "concept"),
431    ("artifact", EdgeRelation::VariantOf, "artifact"),
432    ("concept", EdgeRelation::IntroducedBy, "document"),
433    ("concept", EdgeRelation::IntroducedBy, "person"),
434    ("artifact", EdgeRelation::IntroducedBy, "document"),
435    ("document", EdgeRelation::IntroducedBy, "person"),
436    ("document", EdgeRelation::IntroducedBy, "org"),
437    ("concept", EdgeRelation::IntroducedBy, "org"),
438    // Provenance
439    ("artifact", EdgeRelation::DerivedFrom, "dataset"),
440    ("artifact", EdgeRelation::DerivedFrom, "document"),
441    ("artifact", EdgeRelation::DerivedFrom, "project"),
442    ("artifact", EdgeRelation::DerivedFrom, "artifact"),
443    // Temporal
444    ("document", EdgeRelation::Precedes, "document"),
445    ("dataset", EdgeRelation::Precedes, "dataset"),
446    ("artifact", EdgeRelation::Precedes, "artifact"),
447    ("service", EdgeRelation::Precedes, "service"),
448    ("project", EdgeRelation::Precedes, "project"),
449    // Dependency
450    ("project", EdgeRelation::DependsOn, "project"),
451    ("service", EdgeRelation::DependsOn, "project"),
452    ("service", EdgeRelation::DependsOn, "service"),
453    ("service", EdgeRelation::DependsOn, "artifact"),
454    ("service", EdgeRelation::DependsOn, "dataset"),
455    ("artifact", EdgeRelation::DependsOn, "project"),
456    ("artifact", EdgeRelation::DependsOn, "service"),
457    ("document", EdgeRelation::DependsOn, "document"),
458    ("concept", EdgeRelation::Enables, "concept"),
459    ("service", EdgeRelation::Enables, "concept"),
460    ("dataset", EdgeRelation::Enables, "concept"),
461    // Implementation
462    ("project", EdgeRelation::Implements, "concept"),
463    ("service", EdgeRelation::Implements, "concept"),
464    // Lateral
465    ("concept", EdgeRelation::CompetesWith, "concept"),
466    ("project", EdgeRelation::CompetesWith, "project"),
467    ("service", EdgeRelation::CompetesWith, "service"),
468    ("concept", EdgeRelation::ComposedWith, "concept"),
469    ("project", EdgeRelation::ComposedWith, "project"),
470    // Versioning (Supersedes — Concept/Document/Artifact/Service/Dataset only)
471    ("concept", EdgeRelation::Supersedes, "concept"),
472    ("document", EdgeRelation::Supersedes, "document"),
473    ("artifact", EdgeRelation::Supersedes, "artifact"),
474    ("service", EdgeRelation::Supersedes, "service"),
475    ("dataset", EdgeRelation::Supersedes, "dataset"),
476    // Epistemic (Supports/Refutes — evidence sources → Concept claim only)
477    ("concept", EdgeRelation::Supports, "concept"),
478    ("document", EdgeRelation::Supports, "concept"),
479    ("dataset", EdgeRelation::Supports, "concept"),
480    ("artifact", EdgeRelation::Supports, "concept"),
481    ("concept", EdgeRelation::Refutes, "concept"),
482    ("document", EdgeRelation::Refutes, "concept"),
483    ("dataset", EdgeRelation::Refutes, "concept"),
484    ("artifact", EdgeRelation::Refutes, "concept"),
485];
486
487/// Returns the base entity endpoint allowlist.
488///
489/// The returned slice is the same data that `base_entity_rule_allows` consults at
490/// runtime. Exposed for the ADR-076 certificate tests in `khive-pack-kg`, which
491/// must audit live rules rather than hand-copied snapshots.
492pub fn base_entity_endpoint_rules() -> &'static [(&'static str, EdgeRelation, &'static str)] {
493    BASE_ENTITY_ENDPOINT_RULES
494}
495
496/// `true` if `(src_kind, relation, tgt_kind)` is in the base entity endpoint
497/// allowlist. Pure and DB-free — exposed alongside [`base_entity_endpoint_rules`]
498/// so offline consumers (e.g. `kkernel kg validate`) can apply the exact same
499/// base-table membership test the live validator uses, instead of re-deriving
500/// a parallel `.any()` predicate over a hand-copied allowlist.
501pub fn base_entity_rule_allows(src_kind: &str, relation: EdgeRelation, tgt_kind: &str) -> bool {
502    BASE_ENTITY_ENDPOINT_RULES.iter().any(|(src, rel, tgt)| {
503        *rel == relation && (*src == "*" || *src == src_kind) && *tgt == tgt_kind
504    })
505}
506
507/// Canonical endpoint order for symmetric relations (F012).
508///
509/// For `competes_with` and `composed_with`, normalises direction so that
510/// `source_uuid < target_uuid` (lexicographic on the UUID bytes). This
511/// collapses A→B and B→A into a single canonical row, preventing duplicates.
512pub(crate) fn canonical_edge_endpoints(
513    relation: EdgeRelation,
514    source_id: Uuid,
515    target_id: Uuid,
516) -> (Uuid, Uuid) {
517    if relation.is_symmetric() && target_id < source_id {
518        (target_id, source_id)
519    } else {
520        (source_id, target_id)
521    }
522}
523
524/// Infer the default `dependency_kind` from endpoint entity kinds.
525///
526/// `pub(crate)` so `crate::atomic_prepare::prepare_link` can reuse this exact
527/// inference table, keeping `--atomic link` byte-for-byte consistent with the
528/// non-atomic `link()` rather than re-deriving the table.
529pub(crate) fn infer_dependency_kind(src_kind: &str, tgt_kind: &str) -> Option<&'static str> {
530    match (src_kind, tgt_kind) {
531        ("project", "project") => Some("build"),
532        ("service", "service") => Some("runtime"),
533        ("service", "dataset") => Some("data"),
534        ("service", "artifact") => Some("artifact"),
535        ("artifact", "project") | ("artifact", "service") => Some("tooling"),
536        ("document", "document") => Some("normative"),
537        _ => None,
538    }
539}
540
541/// Merge an inferred `dependency_kind` into `depends_on` edge metadata.
542///
543/// If `metadata` already carries a `dependency_kind` key the existing value is
544/// preserved. If the key is absent and the endpoint pair has a known default,
545/// the inferred value is added. Returns `metadata` unchanged for all other
546/// cases (no matching default, or metadata already has the key).
547///
548/// `pub(crate)` so `crate::atomic_prepare::prepare_link` can reuse it for
549/// atomic/non-atomic parity.
550pub(crate) fn merge_dependency_kind(
551    src_kind: &str,
552    tgt_kind: &str,
553    metadata: Option<serde_json::Value>,
554) -> Option<serde_json::Value> {
555    if let Some(ref m) = metadata {
556        if m.get("dependency_kind").is_some() {
557            return metadata;
558        }
559    }
560    let inferred = infer_dependency_kind(src_kind, tgt_kind)?;
561    let mut obj = metadata.unwrap_or_else(|| serde_json::json!({}));
562    if let Some(o) = obj.as_object_mut() {
563        o.insert("dependency_kind".to_string(), serde_json::json!(inferred));
564    }
565    Some(obj)
566}
567
568/// Merge a caller-supplied top-level `dependency_kind` param into an edge's
569/// `metadata` object, filling the key only if `metadata` doesn't already
570/// carry one. This is distinct from `merge_dependency_kind` above (which
571/// infers a default from endpoint entity kinds when no explicit value was
572/// given at all) — this one folds in an EXPLICIT `dependency_kind` argument
573/// the caller passed alongside `metadata`.
574///
575/// `pub`: the single source both `khive-pack-kg::handlers::link::handle_link`
576/// (via `khive_runtime::merge_entry_metadata`) and
577/// `crate::atomic_prepare::prepare_link` call. Lives in `khive-runtime` (not
578/// pack-kg) because packs depend on `khive-runtime`, never the reverse: the
579/// only direction that lets both call sites share one copy instead of a
580/// hand-duplicated block.
581pub fn merge_entry_metadata(
582    metadata: Option<serde_json::Value>,
583    dependency_kind: Option<String>,
584) -> RuntimeResult<Option<serde_json::Value>> {
585    let Some(dk) = dependency_kind else {
586        return Ok(metadata);
587    };
588    let mut obj = metadata.unwrap_or_else(|| serde_json::json!({}));
589    let map = obj
590        .as_object_mut()
591        .ok_or_else(|| RuntimeError::InvalidInput("metadata must be a JSON object".into()))?;
592    map.entry("dependency_kind".to_string())
593        .or_insert_with(|| serde_json::json!(dk));
594    Ok(Some(obj))
595}
596
597/// Valid `dependency_kind` values for `depends_on` edges.
598const VALID_DEPENDENCY_KINDS: &[&str] = &[
599    "build",
600    "runtime",
601    "data",
602    "artifact",
603    "tooling",
604    "normative",
605];
606
607/// Validate that an edge weight is finite and within `[0.0, 1.0]`.
608///
609/// Rejects NaN, infinities, negative values, and values exceeding 1.0.
610/// Used by `link` and `import_kg` to enforce the weight invariant consistently
611/// across all edge creation paths.
612pub(crate) fn validate_edge_weight(weight: f64) -> RuntimeResult<()> {
613    if !weight.is_finite() || !(0.0..=1.0).contains(&weight) {
614        return Err(RuntimeError::InvalidInput(format!(
615            "edge weight must be finite and in [0.0, 1.0], got {weight}"
616        )));
617    }
618    Ok(())
619}
620
621/// Validate governed edge metadata keys.
622///
623/// Currently enforces:
624/// - `dependency_kind` is only valid on `depends_on` edges.
625/// - `dependency_kind`, when present, must be one of the governed values.
626pub(crate) fn validate_edge_metadata(
627    relation: EdgeRelation,
628    metadata: Option<&serde_json::Value>,
629) -> RuntimeResult<()> {
630    let Some(meta) = metadata else {
631        return Ok(());
632    };
633    if let Some(dk) = meta.get("dependency_kind") {
634        if relation != EdgeRelation::DependsOn {
635            return Err(RuntimeError::InvalidInput(format!(
636                "dependency_kind is only valid on depends_on edges (got {})",
637                relation.as_str()
638            )));
639        }
640        let dk_str = dk
641            .as_str()
642            .ok_or_else(|| RuntimeError::InvalidInput("dependency_kind must be a string".into()))?;
643        if !VALID_DEPENDENCY_KINDS.contains(&dk_str) {
644            return Err(RuntimeError::InvalidInput(format!(
645                "unknown dependency_kind {dk_str:?}; valid: {}",
646                VALID_DEPENDENCY_KINDS.join(" | ")
647            )));
648        }
649    }
650    Ok(())
651}
652
653/// Returns `true` when `note_props` is a superset of all key-value pairs in `filter`.
654///
655/// Mirrors the semantics of `khive_pack_kg::handlers::common::props_match` so that the
656/// storage-leg predicate in `search_notes` is identical to the handler-side post-filter.
657fn note_props_match(note_props: Option<&serde_json::Value>, filter: &serde_json::Value) -> bool {
658    let required = match filter.as_object() {
659        Some(obj) if !obj.is_empty() => obj,
660        _ => return true,
661    };
662    let actual = match note_props.and_then(serde_json::Value::as_object) {
663        Some(obj) => obj,
664        None => return false,
665    };
666    required
667        .iter()
668        .all(|(k, v)| actual.get(k).is_some_and(|av| av == v))
669}
670
671impl KhiveRuntime {
672    // ---- Entity operations ----
673
674    /// Create and persist a new entity.
675    // REASON: entity creation requires kind, type, name, description, properties, tags, and
676    // namespace token — refactoring into a builder would add indirection without reducing
677    // caller complexity; this signature mirrors the MCP verb surface directly.
678    #[allow(clippy::too_many_arguments)]
679    pub async fn create_entity(
680        &self,
681        token: &NamespaceToken,
682        kind: &str,
683        entity_type: Option<&str>,
684        name: &str,
685        description: Option<&str>,
686        properties: Option<serde_json::Value>,
687        tags: Vec<String>,
688    ) -> RuntimeResult<Entity> {
689        self.validate_entity_kind(kind)?;
690        // Secret gate: scan name, description, structured properties, and tags.
691        crate::secret_gate::check(name)?;
692        if let Some(d) = description {
693            crate::secret_gate::check(d)?;
694        }
695        if let Some(ref p) = properties {
696            crate::secret_gate::check_json(p)?;
697        }
698        crate::secret_gate::check_tags(&tags)?;
699        let ns = token.namespace().as_str();
700        let mut entity = Entity::new(ns, kind, name).with_entity_type(entity_type);
701        if let Some(d) = description {
702            entity = entity.with_description(d);
703        }
704        if let Some(p) = properties {
705            entity = entity.with_properties(p);
706        }
707        if !tags.is_empty() {
708            entity = entity.with_tags(tags);
709        }
710        self.entities(token)?.upsert_entity(entity.clone()).await?;
711
712        let doc = entity_fts_document(&entity);
713        let embed_body = doc.body.clone();
714
715        // FTS step — compensate entity row on failure (mirrors create_note_inner).
716        {
717            #[cfg(any(test, feature = "fault-injection"))]
718            let fts_inject = FTS_FAIL_NS.lock().unwrap().remove(ns);
719            #[cfg(not(any(test, feature = "fault-injection")))]
720            let fts_inject = false;
721            let fts_result: RuntimeResult<()> = if fts_inject {
722                Err(RuntimeError::Internal("injected FTS failure".to_string()))
723            } else {
724                match self.text(token) {
725                    Ok(fts) => fts.upsert_document(doc).await.map_err(RuntimeError::from),
726                    Err(e) => Err(e),
727                }
728            };
729            if let Err(e) = fts_result {
730                if let Ok(store) = self.entities(token) {
731                    if let Err(ce) = store.delete_entity(entity.id, DeleteMode::Hard).await {
732                        tracing::error!(
733                            error = %ce,
734                            id = %entity.id,
735                            "create_entity: failed to roll back entity row after FTS failure"
736                        );
737                    }
738                }
739                return Err(e);
740            }
741        }
742
743        // Vector embedding + insert step — compensate entity row + FTS doc on failure.
744        // Fan out to ALL registered models (mirrors create_note_inner multi-model path).
745        let embed_model_names = {
746            let names = self.registered_embedding_model_names();
747            if names.is_empty() {
748                vec![]
749            } else {
750                names
751            }
752        };
753
754        if embed_model_names.len() == 1 {
755            let model_name = &embed_model_names[0];
756            let vec_result = self
757                .embed_document_with_model(model_name, &embed_body)
758                .await;
759
760            #[cfg(any(test, feature = "fault-injection"))]
761            let vec_inject = {
762                let mut g = VECTOR_FAIL_NS.lock().unwrap();
763                if g.as_deref() == Some(ns) {
764                    *g = None;
765                    true
766                } else {
767                    false
768                }
769            };
770            #[cfg(not(any(test, feature = "fault-injection")))]
771            let vec_inject = false;
772            let vec_result: RuntimeResult<Vec<f32>> = if vec_inject {
773                Err(RuntimeError::Internal(
774                    "injected vector failure".to_string(),
775                ))
776            } else {
777                vec_result
778            };
779
780            let single_result: RuntimeResult<()> = match vec_result {
781                Ok(vector) => match self.vectors_for_model(token, model_name) {
782                    Ok(vs) => vs
783                        .insert(
784                            entity.id,
785                            SubstrateKind::Entity,
786                            ns,
787                            "entity.body",
788                            vec![vector],
789                        )
790                        .await
791                        .map_err(RuntimeError::from),
792                    Err(e) => Err(e),
793                },
794                Err(e) => Err(e),
795            };
796            if let Err(e) = single_result {
797                if let Ok(store) = self.entities(token) {
798                    if let Err(ce) = store.delete_entity(entity.id, DeleteMode::Hard).await {
799                        tracing::error!(
800                            error = %ce,
801                            id = %entity.id,
802                            "create_entity: failed to roll back entity row after vector failure"
803                        );
804                    }
805                }
806                if let Ok(fts) = self.text(token) {
807                    if let Err(ce) = fts.delete_document(ns, entity.id).await {
808                        tracing::error!(
809                            error = %ce,
810                            id = %entity.id,
811                            "create_entity: failed to roll back FTS document after vector failure"
812                        );
813                    }
814                }
815                return Err(e);
816            }
817        } else if !embed_model_names.is_empty() {
818            // Multi-model path: embed with each model in parallel, then insert sequentially
819            // with inserted_models tracking for rollback on partial failure.
820            let rt_clone = self.clone();
821            let body_owned = embed_body.clone();
822            let mut handles = Vec::with_capacity(embed_model_names.len());
823            for model_name in &embed_model_names {
824                let rt = rt_clone.clone();
825                let text = body_owned.clone();
826                let name = model_name.clone();
827                handles.push(tokio::spawn(async move {
828                    rt.embed_document_with_model(&name, &text).await
829                }));
830            }
831            let mut vectors: Vec<Vec<f32>> = Vec::with_capacity(embed_model_names.len());
832            for handle in handles {
833                let join_result = handle
834                    .await
835                    .map_err(|e| RuntimeError::Internal(format!("embed task panicked: {e}")));
836                match join_result {
837                    Err(e) => {
838                        if let Ok(store) = self.entities(token) {
839                            if let Err(ce) = store.delete_entity(entity.id, DeleteMode::Hard).await
840                            {
841                                tracing::error!(
842                                    error = %ce,
843                                    id = %entity.id,
844                                    "create_entity: failed to roll back entity row after embed task panic"
845                                );
846                            }
847                        }
848                        if let Ok(fts) = self.text(token) {
849                            if let Err(ce) = fts.delete_document(ns, entity.id).await {
850                                tracing::error!(
851                                    error = %ce,
852                                    id = %entity.id,
853                                    "create_entity: failed to roll back FTS document after embed task panic"
854                                );
855                            }
856                        }
857                        return Err(e);
858                    }
859                    Ok(Err(e)) => {
860                        if let Ok(store) = self.entities(token) {
861                            if let Err(ce) = store.delete_entity(entity.id, DeleteMode::Hard).await
862                            {
863                                tracing::error!(
864                                    error = %ce,
865                                    id = %entity.id,
866                                    "create_entity: failed to roll back entity row after embed failure"
867                                );
868                            }
869                        }
870                        if let Ok(fts) = self.text(token) {
871                            if let Err(ce) = fts.delete_document(ns, entity.id).await {
872                                tracing::error!(
873                                    error = %ce,
874                                    id = %entity.id,
875                                    "create_entity: failed to roll back FTS document after embed failure"
876                                );
877                            }
878                        }
879                        return Err(e);
880                    }
881                    Ok(Ok(vec)) => vectors.push(vec),
882                }
883            }
884            // TODO(P2): parallelize vector inserts
885            let mut inserted_models: Vec<String> = Vec::with_capacity(embed_model_names.len());
886            for (model_name, vector) in embed_model_names.iter().zip(vectors) {
887                // Count-targetable fault injection for multi-model insert path.
888                #[cfg(any(test, feature = "fault-injection"))]
889                let count_inject = VECTOR_FAIL_AFTER.with(|cell| match cell.get() {
890                    Some(0) => {
891                        cell.set(None);
892                        true
893                    }
894                    Some(n) => {
895                        cell.set(Some(n - 1));
896                        false
897                    }
898                    None => false,
899                });
900                #[cfg(not(any(test, feature = "fault-injection")))]
901                let count_inject = false;
902
903                let insert_result = if count_inject {
904                    Err(RuntimeError::Internal(
905                        "injected vector insert failure".to_string(),
906                    ))
907                } else {
908                    match self.vectors_for_model(token, model_name) {
909                        Ok(vs) => vs
910                            .insert(
911                                entity.id,
912                                SubstrateKind::Entity,
913                                ns,
914                                "entity.body",
915                                vec![vector],
916                            )
917                            .await
918                            .map_err(RuntimeError::from),
919                        Err(e) => Err(e),
920                    }
921                };
922                if let Err(e) = insert_result {
923                    // Compensate entity row + FTS + already-inserted vectors.
924                    if let Ok(store) = self.entities(token) {
925                        if let Err(ce) = store.delete_entity(entity.id, DeleteMode::Hard).await {
926                            tracing::error!(
927                                error = %ce,
928                                id = %entity.id,
929                                "create_entity: failed to roll back entity row after vector insert failure"
930                            );
931                        }
932                    }
933                    if let Ok(fts) = self.text(token) {
934                        if let Err(ce) = fts.delete_document(ns, entity.id).await {
935                            tracing::error!(
936                                error = %ce,
937                                id = %entity.id,
938                                "create_entity: failed to roll back FTS document after vector insert failure"
939                            );
940                        }
941                    }
942                    for m in &inserted_models {
943                        if let Ok(vs) = self.vectors_for_model(token, m) {
944                            if let Err(ce) = vs.delete(entity.id).await {
945                                tracing::error!(
946                                    error = %ce,
947                                    model = m,
948                                    id = %entity.id,
949                                    "create_entity: failed to roll back vector for model after insert failure"
950                                );
951                            }
952                        }
953                    }
954                    return Err(e);
955                }
956                inserted_models.push(model_name.clone());
957            }
958        }
959
960        Ok(entity)
961    }
962
963    /// Retrieve an entity by ID.
964    ///
965    /// UUID v4 is globally unique: no namespace filter on by-ID ops.
966    pub async fn get_entity(&self, token: &NamespaceToken, id: Uuid) -> RuntimeResult<Entity> {
967        self.entities(token)?
968            .get_entity(id)
969            .await?
970            .ok_or_else(|| RuntimeError::NotFound(format!("entity {id}")))
971    }
972
973    /// Retrieve an entity by ID including soft-deleted rows.
974    ///
975    /// UUID v4 is globally unique: no namespace filter on by-ID ops.
976    pub async fn get_entity_including_deleted(
977        &self,
978        token: &NamespaceToken,
979        id: Uuid,
980    ) -> RuntimeResult<Option<Entity>> {
981        self.entities(token)?
982            .get_entity_including_deleted(id)
983            .await
984            .map_err(Into::into)
985    }
986
987    /// Retrieve a note by ID including soft-deleted rows.
988    ///
989    /// UUID v4 is globally unique: no namespace filter on by-ID ops.
990    pub async fn get_note_including_deleted(
991        &self,
992        token: &NamespaceToken,
993        id: Uuid,
994    ) -> RuntimeResult<Option<khive_storage::note::Note>> {
995        self.notes(token)?
996            .get_note_including_deleted(id)
997            .await
998            .map_err(Into::into)
999    }
1000
1001    /// Fetch multiple entities by ID, returning only those that exist in the
1002    /// caller's namespace.  Missing or namespace-mismatched IDs are silently
1003    /// omitted so that batch lookups don't abort on a single stale reference.
1004    pub async fn get_entities_by_ids(
1005        &self,
1006        token: &NamespaceToken,
1007        ids: &[Uuid],
1008    ) -> RuntimeResult<Vec<Entity>> {
1009        if ids.is_empty() {
1010            return Ok(vec![]);
1011        }
1012        let filter = EntityFilter {
1013            ids: ids.to_vec(),
1014            ..Default::default()
1015        };
1016        let page = self
1017            .entities(token)?
1018            .query_entities(
1019                token.namespace().as_str(),
1020                filter,
1021                PageRequest {
1022                    offset: 0,
1023                    limit: ids.len() as u32,
1024                },
1025            )
1026            .await?;
1027        Ok(page.items)
1028    }
1029
1030    /// Like `get_entities_by_ids` but scoped to the token's full visible-namespace
1031    /// set (`primary ∪ extra_visible`) instead of primary only.
1032    ///
1033    /// Graph expansion (`neighbors`, `traverse`) iterates over all visible
1034    /// namespaces, so enrichment must use the same scope — otherwise neighbors
1035    /// or path nodes whose entities live in an extra-visible namespace are left
1036    /// with `name = None`, `kind = None`.  Missing or out-of-scope IDs are
1037    /// silently omitted (best-effort, same as `get_entities_by_ids`).
1038    async fn get_entities_by_ids_visible(
1039        &self,
1040        token: &NamespaceToken,
1041        ids: &[Uuid],
1042    ) -> RuntimeResult<Vec<Entity>> {
1043        if ids.is_empty() {
1044            return Ok(vec![]);
1045        }
1046        let namespaces: Vec<String> = token
1047            .visible_namespaces()
1048            .iter()
1049            .map(|ns| ns.as_str().to_owned())
1050            .collect();
1051        let filter = EntityFilter {
1052            ids: ids.to_vec(),
1053            namespaces,
1054            ..Default::default()
1055        };
1056        let page = self
1057            .entities(token)?
1058            .query_entities(
1059                token.namespace().as_str(),
1060                filter,
1061                PageRequest {
1062                    offset: 0,
1063                    limit: ids.len() as u32,
1064                },
1065            )
1066            .await?;
1067        Ok(page.items)
1068    }
1069
1070    /// Enforce that `record_ns` is within the caller's visible namespace set.
1071    ///
1072    /// Returns `Err(NotFound)` when the record namespace is not in the visible
1073    /// set — wrong-namespace and absent UUIDs must be indistinguishable
1074    /// externally (no existence oracle).
1075    ///
1076    /// When the visible set is a single entry equal to `caller_primary_ns`, this
1077    /// is identical to the former strict-equality check (backward-compatible).
1078    pub(crate) fn ensure_namespace(record_ns: &str, caller_primary_ns: &str) -> RuntimeResult<()> {
1079        if record_ns == caller_primary_ns {
1080            return Ok(());
1081        }
1082        Err(RuntimeError::NotFound("not found in this namespace".into()))
1083    }
1084
1085    /// Enforce that `record_ns` is a member of the token's visible namespace set.
1086    ///
1087    /// This is the multi-namespace-aware variant used when the token carries an
1088    /// extended visibility set. For single-namespace tokens (visible == [primary])
1089    /// this degenerates to the same strict-equality check as `ensure_namespace`.
1090    pub(crate) fn ensure_namespace_visible(
1091        record_ns: &str,
1092        token: &NamespaceToken,
1093    ) -> RuntimeResult<()> {
1094        for ns in token.visible_namespaces() {
1095            if record_ns == ns.as_str() {
1096                return Ok(());
1097            }
1098        }
1099        Err(RuntimeError::NotFound("not found in this namespace".into()))
1100    }
1101
1102    /// List entities visible to the token, optionally filtered by kind and entity_type.
1103    ///
1104    /// When the token carries a multi-namespace visible set, entities from all
1105    /// visible namespaces are returned. When the visible set is `[primary]`
1106    /// (the default) this behaves identically to the pre-visibility behaviour.
1107    pub async fn list_entities(
1108        &self,
1109        token: &NamespaceToken,
1110        kind: Option<&str>,
1111        entity_type: Option<&str>,
1112        limit: u32,
1113        offset: u32,
1114    ) -> RuntimeResult<Vec<Entity>> {
1115        let ns_strs: Vec<String> = token
1116            .visible_namespaces()
1117            .iter()
1118            .map(|ns| ns.as_str().to_owned())
1119            .collect();
1120        let filter = EntityFilter {
1121            kinds: match kind {
1122                Some(k) => vec![k.to_string()],
1123                None => vec![],
1124            },
1125            entity_types: match entity_type {
1126                Some(t) => vec![t.to_string()],
1127                None => vec![],
1128            },
1129            namespaces: ns_strs,
1130            ..Default::default()
1131        };
1132        let page = self
1133            .entities(token)?
1134            .query_entities(
1135                token.namespace().as_str(),
1136                filter,
1137                PageRequest {
1138                    offset: offset.into(),
1139                    limit,
1140                },
1141            )
1142            .await?;
1143        Ok(page.items)
1144    }
1145
1146    /// List entities filtered by kind, optional domain tag, limit, and offset.
1147    ///
1148    /// When `domain_tag` is Some, the query is restricted at the storage layer via
1149    /// `EntityFilter::tags_any` so the page result already reflects the domain
1150    /// constraint.  This avoids the silent truncation that occurs when filtering
1151    /// post-page (K-3). Multi-namespace visibility from the token is applied.
1152    pub async fn list_entities_tagged(
1153        &self,
1154        token: &NamespaceToken,
1155        kind: Option<&str>,
1156        domain_tag: Option<&str>,
1157        limit: u32,
1158        offset: u32,
1159    ) -> RuntimeResult<Vec<Entity>> {
1160        let ns_strs: Vec<String> = token
1161            .visible_namespaces()
1162            .iter()
1163            .map(|ns| ns.as_str().to_owned())
1164            .collect();
1165        let filter = EntityFilter {
1166            kinds: match kind {
1167                Some(k) => vec![k.to_string()],
1168                None => vec![],
1169            },
1170            tags_any: match domain_tag {
1171                Some(t) if !t.is_empty() => vec![t.to_string()],
1172                _ => vec![],
1173            },
1174            namespaces: ns_strs,
1175            ..Default::default()
1176        };
1177        let page = self
1178            .entities(token)?
1179            .query_entities(
1180                token.namespace().as_str(),
1181                filter,
1182                PageRequest {
1183                    offset: offset.into(),
1184                    limit,
1185                },
1186            )
1187            .await?;
1188        Ok(page.items)
1189    }
1190
1191    /// Count entities filtered by kind and optional domain tag.
1192    ///
1193    /// Used to report a meaningful `total` alongside a paginated listing (K-6).
1194    /// Multi-namespace visibility from the token is applied.
1195    pub async fn count_entities_tagged(
1196        &self,
1197        token: &NamespaceToken,
1198        kind: Option<&str>,
1199        domain_tag: Option<&str>,
1200    ) -> RuntimeResult<u64> {
1201        let ns_strs: Vec<String> = token
1202            .visible_namespaces()
1203            .iter()
1204            .map(|ns| ns.as_str().to_owned())
1205            .collect();
1206        let filter = EntityFilter {
1207            kinds: match kind {
1208                Some(k) => vec![k.to_string()],
1209                None => vec![],
1210            },
1211            tags_any: match domain_tag {
1212                Some(t) if !t.is_empty() => vec![t.to_string()],
1213                _ => vec![],
1214            },
1215            namespaces: ns_strs,
1216            ..Default::default()
1217        };
1218        Ok(self
1219            .entities(token)?
1220            .count_entities(token.namespace().as_str(), filter)
1221            .await?)
1222    }
1223
1224    /// List events in the namespace proven by the caller token.
1225    pub async fn list_events(
1226        &self,
1227        token: &NamespaceToken,
1228        filter: EventFilter,
1229        page: PageRequest,
1230    ) -> RuntimeResult<Page<Event>> {
1231        self.events(token)?
1232            .query_events(filter, page)
1233            .await
1234            .map_err(Into::into)
1235    }
1236
1237    // ---- Edge operations ----
1238
1239    /// Validate that `source_id` and `target_id` are legal endpoints for `relation`.
1240    ///
1241    /// Centralises the three-case relation contract so that both
1242    /// `link()` and `update_edge()` share identical enforcement:
1243    ///
1244    /// - `annotates`: source MUST be a note; target may be any substrate.
1245    /// - `supersedes` / `supports` / `refutes`: same-substrate only (note→note or entity→entity).
1246    /// - All other 13 relations: both endpoints MUST be entities.
1247    ///
1248    /// Returns `Ok(())` when valid; otherwise `InvalidInput` or `NotFound` with
1249    /// the same messages as the previous inline block (byte-identical behaviour).
1250    ///
1251    /// `pub(crate)`: the atomic prepare pass (`crate::atomic_prepare`) reuses
1252    /// this exact endpoint-type validation during its async prepare step,
1253    /// before building a `LinkPlan`, rather than re-deriving the checks.
1254    pub(crate) async fn validate_edge_relation_endpoints(
1255        &self,
1256        token: &NamespaceToken,
1257        source_id: Uuid,
1258        target_id: Uuid,
1259        relation: EdgeRelation,
1260    ) -> RuntimeResult<()> {
1261        if source_id == target_id {
1262            return Err(RuntimeError::InvalidInput(
1263                "self-loop edges are not allowed: source_id and target_id must be different".into(),
1264            ));
1265        }
1266        if relation == EdgeRelation::Annotates {
1267            // Source must be a note. By-ID endpoint resolution is namespace-agnostic:
1268            // link consumes two by-ID endpoints, so it must resolve exactly what
1269            // get() resolves, regardless of caller namespace.
1270            match self.resolve_edge_endpoint(token, source_id).await? {
1271                Some(Resolved::Note(_)) => {}
1272                Some(_) => {
1273                    return Err(RuntimeError::InvalidInput(format!(
1274                        "annotates source {source_id} must be a note"
1275                    )));
1276                }
1277                None => {
1278                    // Existing edge used as annotates source: wrong kind, not absent.
1279                    if self.get_edge(token, source_id).await?.is_some() {
1280                        return Err(RuntimeError::InvalidInput(format!(
1281                            "annotates source {source_id} must be a note"
1282                        )));
1283                    }
1284                    return Err(RuntimeError::NotFound(format!(
1285                        "link source {source_id} not found"
1286                    )));
1287                }
1288            }
1289            // Target may be any substrate (entity, note, event, or edge) — by-ID, unfiltered.
1290            if !self.substrate_exists_by_id(token, target_id).await? {
1291                return Err(RuntimeError::NotFound(format!(
1292                    "link target {target_id} not found"
1293                )));
1294            }
1295        } else if matches!(
1296            relation,
1297            EdgeRelation::Supersedes | EdgeRelation::Supports | EdgeRelation::Refutes
1298        ) {
1299            // supersedes / supports / refutes: same-substrate only (note→note or entity→entity).
1300            // Event and edge endpoints are invalid regardless of the other endpoint.
1301            // Endpoint resolution is by-ID and namespace-agnostic.
1302            let rel_name = relation.as_str();
1303            let src = match self.resolve_edge_endpoint(token, source_id).await? {
1304                Some(r) => r,
1305                None => {
1306                    if self.get_edge(token, source_id).await?.is_some() {
1307                        return Err(RuntimeError::InvalidInput(format!(
1308                            "{rel_name} source {source_id} must be a note or entity (got edge)"
1309                        )));
1310                    }
1311                    return Err(RuntimeError::NotFound(format!(
1312                        "link source {source_id} not found"
1313                    )));
1314                }
1315            };
1316            let tgt = match self.resolve_edge_endpoint(token, target_id).await? {
1317                Some(r) => r,
1318                None => {
1319                    if self.get_edge(token, target_id).await?.is_some() {
1320                        return Err(RuntimeError::InvalidInput(format!(
1321                            "{rel_name} target {target_id} must be a note or entity (got edge)"
1322                        )));
1323                    }
1324                    return Err(RuntimeError::NotFound(format!(
1325                        "link target {target_id} not found"
1326                    )));
1327                }
1328            };
1329            match (&src, &tgt) {
1330                (Resolved::Entity(src_e), Resolved::Entity(tgt_e)) => {
1331                    if !base_entity_rule_allows(&src_e.kind, relation, &tgt_e.kind) {
1332                        let rule_hint = match relation {
1333                            EdgeRelation::Supports | EdgeRelation::Refutes => {
1334                                "requires concept|document|dataset|artifact -> concept \
1335                                 (or same-substrate note -> note)"
1336                            }
1337                            _ => "requires same-kind entity endpoints",
1338                        };
1339                        return Err(RuntimeError::InvalidInput(format!(
1340                            "({}) -[{rel_name}]-> ({}) is not in the base endpoint \
1341                             allowlist; {rel_name} {rule_hint}",
1342                            src_e.kind, tgt_e.kind
1343                        )));
1344                    }
1345                }
1346                (Resolved::Note(_), Resolved::Note(_)) => {}
1347                (Resolved::Event(_), _) => {
1348                    return Err(RuntimeError::InvalidInput(format!(
1349                        "{rel_name} does not apply to events; source {source_id} is an event"
1350                    )));
1351                }
1352                (_, Resolved::Event(_)) => {
1353                    return Err(RuntimeError::InvalidInput(format!(
1354                        "{rel_name} does not apply to events; target {target_id} is an event"
1355                    )));
1356                }
1357                (Resolved::Entity(_), Resolved::Note(_)) => {
1358                    return Err(RuntimeError::InvalidInput(format!(
1359                        "{rel_name} endpoints must be the same substrate (note→note or entity→entity); \
1360                         got source={source_id} (entity) target={target_id} (note)"
1361                    )));
1362                }
1363                (Resolved::Note(_), Resolved::Entity(_)) => {
1364                    return Err(RuntimeError::InvalidInput(format!(
1365                        "{rel_name} endpoints must be the same substrate (note→note or entity→entity); \
1366                         got source={source_id} (note) target={target_id} (entity)"
1367                    )));
1368                }
1369                (Resolved::PackRecord { .. }, _) | (_, Resolved::PackRecord { .. }) => {
1370                    return Err(RuntimeError::InvalidInput(format!(
1371                        "pack-private record is not a valid edge endpoint for {rel_name}"
1372                    )));
1373                }
1374            }
1375        } else {
1376            // All remaining base relations require entity→entity with kind-level
1377            // restrictions (see base allowlist). Packs may extend the allowlist
1378            // additively via EDGE_RULES.
1379            //
1380            // Strategy: resolve both endpoints once (by-ID, unfiltered), consult pack
1381            // rules; on miss, fall through to the original base-rule error messages.
1382            let src_res = self.resolve_edge_endpoint(token, source_id).await?;
1383            let tgt_res = self.resolve_edge_endpoint(token, target_id).await?;
1384
1385            if pack_rule_allows(
1386                &self.pack_edge_rules(),
1387                relation,
1388                src_res.as_ref(),
1389                tgt_res.as_ref(),
1390            ) {
1391                return Ok(());
1392            }
1393
1394            // Substrate check: both endpoints must be entities.
1395            let src_kind = match src_res {
1396                Some(Resolved::Entity(e)) => e.kind,
1397                Some(_) => {
1398                    return Err(RuntimeError::InvalidInput(format!(
1399                        "link source {source_id} must be an entity for relation {relation:?} \
1400                         (only `annotates` crosses substrates)"
1401                    )));
1402                }
1403                None => {
1404                    if self.get_edge(token, source_id).await?.is_some() {
1405                        return Err(RuntimeError::InvalidInput(format!(
1406                            "link source {source_id} must be an entity for relation {relation:?} \
1407                             (only `annotates` crosses substrates)"
1408                        )));
1409                    }
1410                    return Err(RuntimeError::NotFound(format!(
1411                        "link source {source_id} not found"
1412                    )));
1413                }
1414            };
1415            let tgt_kind = match tgt_res {
1416                Some(Resolved::Entity(e)) => e.kind,
1417                Some(_) => {
1418                    return Err(RuntimeError::InvalidInput(format!(
1419                        "link target {target_id} must be an entity for relation {relation:?} \
1420                         (only `annotates` crosses substrates)"
1421                    )));
1422                }
1423                None => {
1424                    if self.get_edge(token, target_id).await?.is_some() {
1425                        return Err(RuntimeError::InvalidInput(format!(
1426                            "link target {target_id} must be an entity for relation {relation:?} \
1427                             (only `annotates` crosses substrates)"
1428                        )));
1429                    }
1430                    return Err(RuntimeError::NotFound(format!(
1431                        "link target {target_id} not found"
1432                    )));
1433                }
1434            };
1435            if !base_entity_rule_allows(&src_kind, relation, &tgt_kind) {
1436                return Err(RuntimeError::InvalidInput(format!(
1437                    "({src_kind}) -[{}]-> ({tgt_kind}) is not in the base endpoint \
1438                     allowlist; use pack EDGE_RULES to extend the allowlist",
1439                    relation.as_str()
1440                )));
1441            }
1442        }
1443        Ok(())
1444    }
1445
1446    /// Public delegator for cross-backend link validation.
1447    ///
1448    /// Exposes `validate_edge_relation_endpoints` for the `SubstrateCoordinator`
1449    /// so it can validate the relation before writing the edge on the source backend.
1450    pub async fn validate_link_endpoints(
1451        &self,
1452        token: &NamespaceToken,
1453        source_id: Uuid,
1454        target_id: Uuid,
1455        relation: EdgeRelation,
1456    ) -> RuntimeResult<()> {
1457        self.validate_edge_relation_endpoints(token, source_id, target_id, relation)
1458            .await
1459    }
1460
1461    /// Validate an edge relation using pre-fetched endpoint records.
1462    ///
1463    /// For cross-backend links the source and target live on different backends —
1464    /// the source runtime cannot resolve the target. The coordinator fetches each
1465    /// endpoint from its own backend, then calls this method to enforce the
1466    /// kind-pairing rules without a second DB round-trip.
1467    ///
1468    /// `src` and `tgt` are the `resolve_edge_endpoint` results from each backend. The
1469    /// `token` supplies the pack edge rules installed on this (source) runtime;
1470    /// no DB access is performed.
1471    pub fn validate_link_endpoints_by_resolved(
1472        &self,
1473        source_id: Uuid,
1474        target_id: Uuid,
1475        relation: EdgeRelation,
1476        src: Option<&Resolved>,
1477        tgt: Option<&Resolved>,
1478    ) -> RuntimeResult<()> {
1479        if source_id == target_id {
1480            return Err(RuntimeError::InvalidInput(
1481                "self-loop edges are not allowed: source_id and target_id must be different".into(),
1482            ));
1483        }
1484
1485        if relation == EdgeRelation::Annotates {
1486            match src {
1487                Some(Resolved::Note(_)) => {}
1488                Some(_) => {
1489                    return Err(RuntimeError::InvalidInput(format!(
1490                        "annotates source {source_id} must be a note"
1491                    )));
1492                }
1493                None => {
1494                    return Err(RuntimeError::NotFound(format!(
1495                        "link source {source_id} not found"
1496                    )));
1497                }
1498            }
1499            if tgt.is_none() {
1500                return Err(RuntimeError::NotFound(format!(
1501                    "link target {target_id} not found"
1502                )));
1503            }
1504            return Ok(());
1505        }
1506
1507        if matches!(
1508            relation,
1509            EdgeRelation::Supersedes | EdgeRelation::Supports | EdgeRelation::Refutes
1510        ) {
1511            let rel_name = relation.as_str();
1512            let src = src.ok_or_else(|| {
1513                RuntimeError::NotFound(format!("link source {source_id} not found"))
1514            })?;
1515            let tgt = tgt.ok_or_else(|| {
1516                RuntimeError::NotFound(format!("link target {target_id} not found"))
1517            })?;
1518            match (src, tgt) {
1519                (Resolved::Entity(src_e), Resolved::Entity(tgt_e)) => {
1520                    if !base_entity_rule_allows(&src_e.kind, relation, &tgt_e.kind) {
1521                        let rule_hint = match relation {
1522                            EdgeRelation::Supports | EdgeRelation::Refutes => {
1523                                "requires concept|document|dataset|artifact -> concept \
1524                                 (or same-substrate note -> note)"
1525                            }
1526                            _ => "requires same-kind entity endpoints",
1527                        };
1528                        return Err(RuntimeError::InvalidInput(format!(
1529                            "({}) -[{rel_name}]-> ({}) is not in the base endpoint \
1530                             allowlist; {rel_name} {rule_hint}",
1531                            src_e.kind, tgt_e.kind
1532                        )));
1533                    }
1534                }
1535                (Resolved::Note(_), Resolved::Note(_)) => {}
1536                (Resolved::Entity(_), Resolved::Note(_)) => {
1537                    return Err(RuntimeError::InvalidInput(format!(
1538                        "{rel_name} endpoints must be the same substrate \
1539                         (note→note or entity→entity); got source={source_id} (entity) \
1540                         target={target_id} (note)"
1541                    )));
1542                }
1543                (Resolved::Note(_), Resolved::Entity(_)) => {
1544                    return Err(RuntimeError::InvalidInput(format!(
1545                        "{rel_name} endpoints must be the same substrate \
1546                         (note→note or entity→entity); got source={source_id} (note) \
1547                         target={target_id} (entity)"
1548                    )));
1549                }
1550                (Resolved::PackRecord { .. }, _) | (_, Resolved::PackRecord { .. }) => {
1551                    return Err(RuntimeError::InvalidInput(format!(
1552                        "pack-private record is not a valid edge endpoint for {rel_name}"
1553                    )));
1554                }
1555                _ => {
1556                    return Err(RuntimeError::InvalidInput(format!(
1557                        "{rel_name} endpoints must be notes or entities (not events)"
1558                    )));
1559                }
1560            }
1561            return Ok(());
1562        }
1563
1564        // All remaining base relations: entity→entity with kind-level restrictions.
1565        // Consult pack rules installed on this (source) runtime first.
1566        if pack_rule_allows(&self.pack_edge_rules(), relation, src, tgt) {
1567            return Ok(());
1568        }
1569
1570        let src_kind = match src {
1571            Some(Resolved::Entity(e)) => &e.kind,
1572            Some(_) => {
1573                return Err(RuntimeError::InvalidInput(format!(
1574                    "link source {source_id} must be an entity for relation {relation:?} \
1575                     (only `annotates` crosses substrates)"
1576                )));
1577            }
1578            None => {
1579                return Err(RuntimeError::NotFound(format!(
1580                    "link source {source_id} not found"
1581                )));
1582            }
1583        };
1584        let tgt_kind = match tgt {
1585            Some(Resolved::Entity(e)) => &e.kind,
1586            Some(_) => {
1587                return Err(RuntimeError::InvalidInput(format!(
1588                    "link target {target_id} must be an entity for relation {relation:?} \
1589                     (only `annotates` crosses substrates)"
1590                )));
1591            }
1592            None => {
1593                return Err(RuntimeError::NotFound(format!(
1594                    "link target {target_id} not found"
1595                )));
1596            }
1597        };
1598
1599        if !base_entity_rule_allows(src_kind, relation, tgt_kind) {
1600            return Err(RuntimeError::InvalidInput(format!(
1601                "({src_kind}) -[{}]-> ({tgt_kind}) is not in the base endpoint \
1602                 allowlist; use pack EDGE_RULES to extend the allowlist",
1603                relation.as_str()
1604            )));
1605        }
1606
1607        Ok(())
1608    }
1609
1610    /// Validate an `annotates` edge relation using pre-located endpoint kinds.
1611    ///
1612    /// Sibling of [`Self::validate_link_endpoints_by_resolved`] for callers that
1613    /// only have an [`EdgeEndpointKind`] (entity/note/event/edge) rather than a
1614    /// full [`Resolved`] record — the `SubstrateCoordinator`'s cross-backend
1615    /// `locate_endpoint` resolves edge-substrate UUIDs too (matching `get`'s
1616    /// by-ID resolution order), but edges have no `Resolved` variant, so
1617    /// `validate_link_endpoints_by_resolved` cannot express them.
1618    ///
1619    /// `annotates` is the only relation this covers: source must be a note,
1620    /// target may be any substrate (entity, note, event, or edge).
1621    pub fn validate_annotates_endpoint_kinds(
1622        &self,
1623        source_id: Uuid,
1624        target_id: Uuid,
1625        source: Option<EdgeEndpointKind>,
1626        target: Option<EdgeEndpointKind>,
1627    ) -> RuntimeResult<()> {
1628        if source_id == target_id {
1629            return Err(RuntimeError::InvalidInput(
1630                "self-loop edges are not allowed: source_id and target_id must be different".into(),
1631            ));
1632        }
1633        match source {
1634            Some(EdgeEndpointKind::Note) => {}
1635            Some(_) => {
1636                return Err(RuntimeError::InvalidInput(format!(
1637                    "annotates source {source_id} must be a note"
1638                )));
1639            }
1640            None => {
1641                return Err(RuntimeError::NotFound(format!(
1642                    "link source {source_id} not found"
1643                )));
1644            }
1645        }
1646        if target.is_none() {
1647            return Err(RuntimeError::NotFound(format!(
1648                "link target {target_id} not found"
1649            )));
1650        }
1651        Ok(())
1652    }
1653
1654    /// Create a directed edge between two substrates.
1655    ///
1656    /// Enforces the three-case relation contract via
1657    /// `validate_edge_relation_endpoints`. See that method for the full contract.
1658    ///
1659    /// For symmetric relations (`competes_with`, `composed_with`) the endpoint
1660    /// pair is canonicalised to `source_uuid < target_uuid` so that A→B and B→A
1661    /// deduplicate to one row.
1662    ///
1663    /// `metadata` is validated against governed keys; `dependency_kind` is
1664    /// inferred for `depends_on` edges when absent.
1665    ///
1666    /// `target_backend` is always `None` for locally-routed edges written through
1667    /// this path. Both endpoints must exist in the local namespace, so setting
1668    /// `target_backend = None` is the only valid choice.
1669    ///
1670    /// Endpoint existence is a by-ID check and namespace-agnostic: a record
1671    /// that exists in a different namespace than the caller still resolves,
1672    /// exactly as `get()` would.
1673    pub async fn link(
1674        &self,
1675        token: &NamespaceToken,
1676        source_id: Uuid,
1677        target_id: Uuid,
1678        relation: EdgeRelation,
1679        weight: f64,
1680        metadata: Option<serde_json::Value>,
1681    ) -> RuntimeResult<Edge> {
1682        validate_edge_weight(weight)?;
1683        self.validate_edge_relation_endpoints(token, source_id, target_id, relation)
1684            .await?;
1685        let (source_id, target_id) = canonical_edge_endpoints(relation, source_id, target_id);
1686        let metadata = if relation == EdgeRelation::DependsOn {
1687            // By-ID, unfiltered — matches the namespace-agnostic endpoint validation
1688            // above. The visible-set-scoped `resolve` would silently drop the
1689            // dependency_kind inference for endpoints validation now allows outside
1690            // the caller's visible set.
1691            match (
1692                self.resolve_edge_endpoint(token, source_id).await?,
1693                self.resolve_edge_endpoint(token, target_id).await?,
1694            ) {
1695                (Some(Resolved::Entity(src_e)), Some(Resolved::Entity(tgt_e))) => {
1696                    merge_dependency_kind(&src_e.kind, &tgt_e.kind, metadata)
1697                }
1698                _ => metadata,
1699            }
1700        } else {
1701            metadata
1702        };
1703        validate_edge_metadata(relation, metadata.as_ref())?;
1704        let now = chrono::Utc::now();
1705        let ns = token.namespace().as_str();
1706        let edge = Edge {
1707            id: LinkId::from(Uuid::new_v4()),
1708            namespace: ns.to_string(),
1709            source_id,
1710            target_id,
1711            relation,
1712            weight,
1713            created_at: now,
1714            updated_at: now,
1715            deleted_at: None,
1716            metadata,
1717            target_backend: None,
1718        };
1719        // `upsert_edge_guarded` re-checks both endpoints exist as part of the same
1720        // write, not the separate `validate_edge_relation_endpoints` read above: a
1721        // concurrent hard-delete landing between that read and this write can no
1722        // longer create a durably dangling edge. Which endpoint(s) were missing is
1723        // reported by the guard's own in-transaction probe (`GuardedWriteOutcome::
1724        // Refused`), not reconstructed here by re-reading the endpoints after the
1725        // fact: a second concurrent write landing between the refusal and a
1726        // post-hoc read could otherwise misreport which endpoint was actually
1727        // missing at write time.
1728        match self.graph(token)?.upsert_edge_guarded(edge).await? {
1729            khive_storage::GuardedWriteOutcome::Written => {}
1730            khive_storage::GuardedWriteOutcome::Refused(missing) => {
1731                return Err(RuntimeError::GuardedWriteFailed(GuardedWriteFailure {
1732                    entry_index: None,
1733                    missing_source: missing.source.then_some(source_id),
1734                    missing_target: missing.target.then_some(target_id),
1735                }));
1736            }
1737        }
1738
1739        // Read back the persisted row by natural key so the returned
1740        // edge ID is always the one stored in the database, not the locally
1741        // generated UUID that was displaced by an ON CONFLICT DO UPDATE.
1742        // Under parallel calls for the same triple, every caller now returns
1743        // the same persisted edge ID — the winner's insert or the updated row.
1744        let persisted = self
1745            .list_edges(
1746                token,
1747                crate::curation::EdgeListFilter {
1748                    source_id: Some(source_id),
1749                    target_id: Some(target_id),
1750                    relations: vec![relation],
1751                    ..Default::default()
1752                },
1753                1,
1754                0,
1755            )
1756            .await?
1757            .into_iter()
1758            .next()
1759            .ok_or_else(|| {
1760                crate::RuntimeError::Internal(format!(
1761                    "upsert_edge succeeded but natural-key lookup for ({source_id}, {target_id}, {relation}) returned nothing"
1762                ))
1763            })?;
1764        Ok(persisted)
1765    }
1766
1767    /// Write an edge with an explicit `target_backend` stamp (ADR-029 D3).
1768    ///
1769    /// Called by the `SubstrateCoordinator` when source and target are on
1770    /// different backends. The coordinator validates endpoints before calling
1771    /// this method via [`Self::validate_link_endpoints`], so endpoint validation is
1772    /// skipped here. The edge is written on the source backend only.
1773    #[allow(clippy::too_many_arguments)]
1774    pub async fn link_with_target_backend(
1775        &self,
1776        token: &NamespaceToken,
1777        source_id: Uuid,
1778        target_id: Uuid,
1779        relation: EdgeRelation,
1780        weight: f64,
1781        metadata: Option<serde_json::Value>,
1782        target_backend: Option<String>,
1783    ) -> RuntimeResult<Edge> {
1784        validate_edge_weight(weight)?;
1785        let (source_id, target_id) = canonical_edge_endpoints(relation, source_id, target_id);
1786        validate_edge_metadata(relation, metadata.as_ref())?;
1787        let now = chrono::Utc::now();
1788        let ns = token.namespace().as_str();
1789        let edge = Edge {
1790            id: LinkId::from(Uuid::new_v4()),
1791            namespace: ns.to_string(),
1792            source_id,
1793            target_id,
1794            relation,
1795            weight,
1796            created_at: now,
1797            updated_at: now,
1798            deleted_at: None,
1799            metadata,
1800            target_backend,
1801        };
1802        self.graph(token)?.upsert_edge(edge).await?;
1803        let persisted = self
1804            .list_edges(
1805                token,
1806                crate::curation::EdgeListFilter {
1807                    source_id: Some(source_id),
1808                    target_id: Some(target_id),
1809                    relations: vec![relation],
1810                    ..Default::default()
1811                },
1812                1,
1813                0,
1814            )
1815            .await?
1816            .into_iter()
1817            .next()
1818            .ok_or_else(|| {
1819                crate::RuntimeError::Internal(format!(
1820                    "upsert_edge succeeded but natural-key lookup for ({source_id}, {target_id}, {relation}) returned nothing"
1821                ))
1822            })?;
1823        Ok(persisted)
1824    }
1825
1826    /// Returns `true` if `id` resolves to a live substrate record in the
1827    /// caller's visible namespace set.
1828    ///
1829    /// Covers entity, note, event (via `resolve`) and edge (via `get_edge_visible`).
1830    /// Only records that are accessible to the caller (primary or configured visible
1831    /// namespaces) return `true`; absent or foreign-invisible records return `false`.
1832    pub(crate) async fn substrate_exists_in_ns(
1833        &self,
1834        token: &NamespaceToken,
1835        id: Uuid,
1836    ) -> RuntimeResult<bool> {
1837        if self.resolve(token, id).await?.is_some() {
1838            return Ok(true);
1839        }
1840        match self.get_edge_visible(token, id).await {
1841            Ok(Some(_)) => Ok(true),
1842            Ok(None) | Err(RuntimeError::NotFound(_)) => Ok(false),
1843            Err(err) => Err(err),
1844        }
1845    }
1846
1847    /// Returns `true` if `id` resolves to a live substrate record, by ID, with
1848    /// no namespace filter.
1849    ///
1850    /// Used from `annotates` endpoint validation (`link` and `create`'s
1851    /// `annotates` targets), which consume a by-ID endpoint and so must follow
1852    /// the same namespace-agnostic by-ID contract as `get()`.
1853    pub(crate) async fn substrate_exists_by_id(
1854        &self,
1855        token: &NamespaceToken,
1856        id: Uuid,
1857    ) -> RuntimeResult<bool> {
1858        if self.resolve_edge_endpoint(token, id).await?.is_some() {
1859            return Ok(true);
1860        }
1861        match self.get_edge(token, id).await {
1862            Ok(Some(_)) => Ok(true),
1863            Ok(None) | Err(RuntimeError::NotFound(_)) => Ok(false),
1864            Err(err) => Err(err),
1865        }
1866    }
1867
1868    /// Get immediate neighbors of a node, optionally filtered by relation type.
1869    ///
1870    /// Pass `relations: Some(vec![EdgeRelation::Annotates])` to retrieve only
1871    /// annotation edges, enabling cross-substrate navigation.
1872    ///
1873    /// Symmetric relations (`competes_with`, `composed_with`) are stored
1874    /// with the canonical source as the lower UUID. Direction normalization is
1875    /// applied in `neighbors_with_query` so both callers see correct results.
1876    pub async fn neighbors(
1877        &self,
1878        token: &NamespaceToken,
1879        node_id: Uuid,
1880        direction: Direction,
1881        limit: Option<u32>,
1882        relations: Option<Vec<EdgeRelation>>,
1883    ) -> RuntimeResult<Vec<NeighborHit>> {
1884        self.neighbors_with_query(
1885            token,
1886            node_id,
1887            NeighborQuery {
1888                direction,
1889                relations,
1890                limit,
1891                min_weight: None,
1892            },
1893        )
1894        .await
1895    }
1896
1897    /// Get neighbors with full query control (includes `min_weight`).
1898    ///
1899    /// Applies symmetric-relation direction normalization: if the
1900    /// relations filter contains only symmetric relations the direction is
1901    /// overridden to `Both` so edges stored in canonical order are always found.
1902    ///
1903    /// Soft-deleted entity nodes are excluded from results unless the caller
1904    /// explicitly requested them (future: `include_deleted` flag; currently
1905    /// always false).
1906    pub async fn neighbors_with_query(
1907        &self,
1908        token: &NamespaceToken,
1909        node_id: Uuid,
1910        mut query: NeighborQuery,
1911    ) -> RuntimeResult<Vec<NeighborHit>> {
1912        if !self.substrate_exists_in_ns(token, node_id).await? {
1913            return Ok(Vec::new());
1914        }
1915
1916        query.direction =
1917            normalize_symmetric_direction(query.direction, query.relations.as_deref());
1918        let mut hits = Vec::new();
1919        for ns in token.visible_namespaces() {
1920            let temp = NamespaceToken::for_namespace(ns.clone());
1921            let mut ns_hits = self.graph(&temp)?.neighbors(node_id, query.clone()).await?;
1922            hits.append(&mut ns_hits);
1923        }
1924        hits.sort_by_key(|h| (h.node_id, h.edge_id));
1925        hits.dedup_by_key(|h| (h.node_id, h.edge_id));
1926        self.enrich_neighbor_hits(token, &mut hits).await;
1927        // Filter out soft-deleted entity nodes.
1928        let candidate_ids: Vec<Uuid> = hits.iter().map(|h| h.node_id).collect();
1929        let deleted = self.deleted_entity_ids(candidate_ids).await;
1930        if !deleted.is_empty() {
1931            hits.retain(|h| !deleted.contains(&h.node_id));
1932        }
1933        // Restore the weight-descending, node_id-ascending order the storage
1934        // layer established (khive-db graph.rs `ORDER BY weight DESC, node_id
1935        // ASC`) — the (node_id, edge_id) sort above exists only to make
1936        // `dedup_by_key` adjacent-comparable and otherwise discards it. This
1937        // ordering contract must hold at every call site of this op (context
1938        // and neighbors verb alike), for every direction.
1939        hits.sort_by(|a, b| {
1940            b.weight
1941                .partial_cmp(&a.weight)
1942                .unwrap_or(std::cmp::Ordering::Equal)
1943                .then(a.node_id.cmp(&b.node_id))
1944        });
1945        Ok(hits)
1946    }
1947
1948    /// Find live `annotates` edges targeting one record without applying a
1949    /// namespace predicate.
1950    ///
1951    /// This is the graph counterpart to the namespace-agnostic by-ID `get`
1952    /// contract (ADR-007 Rev 6). Multi-record neighbor traversal remains
1953    /// visibility-scoped; callers should use this only after resolving a live
1954    /// target through a by-ID operation.
1955    pub async fn annotation_neighbors_by_target_id(
1956        &self,
1957        target_id: Uuid,
1958    ) -> RuntimeResult<Vec<NeighborHit>> {
1959        let mut reader = self.sql().reader().await?;
1960        let rows = reader
1961            .query_all(SqlStatement {
1962                sql: "SELECT source_id, id, weight FROM graph_edges \
1963                      WHERE target_id = ?1 AND relation = ?2 AND deleted_at IS NULL \
1964                      ORDER BY weight DESC, source_id ASC"
1965                    .to_string(),
1966                params: vec![
1967                    SqlValue::Text(target_id.to_string()),
1968                    SqlValue::Text(EdgeRelation::Annotates.to_string()),
1969                ],
1970                label: Some("annotations.by_target_id_unfiltered".into()),
1971            })
1972            .await?;
1973
1974        rows.into_iter()
1975            .map(|row| {
1976                let parse_uuid = |name: &str| match row.get(name) {
1977                    Some(SqlValue::Text(value)) => Uuid::from_str(value).map_err(|error| {
1978                        RuntimeError::Internal(format!("graph_edges.{name} is not a UUID: {error}"))
1979                    }),
1980                    Some(value) => Err(RuntimeError::Internal(format!(
1981                        "graph_edges.{name} has unexpected SQL value {value:?}"
1982                    ))),
1983                    None => Err(RuntimeError::Internal(format!(
1984                        "graph_edges row missing {name}"
1985                    ))),
1986                };
1987                let weight = match row.get("weight") {
1988                    Some(SqlValue::Float(value)) => Ok(*value),
1989                    Some(value) => Err(RuntimeError::Internal(format!(
1990                        "graph_edges.weight has unexpected SQL value {value:?}"
1991                    ))),
1992                    None => Err(RuntimeError::Internal(
1993                        "graph_edges row missing weight".into(),
1994                    )),
1995                }?;
1996
1997                Ok(NeighborHit {
1998                    node_id: parse_uuid("source_id")?,
1999                    edge_id: parse_uuid("id")?,
2000                    relation: EdgeRelation::Annotates,
2001                    weight,
2002                    name: None,
2003                    kind: None,
2004                    entity_type: None,
2005                })
2006            })
2007            .collect()
2008    }
2009
2010    /// Get both-direction neighbors, each tagged with the direction (`Out`/
2011    /// `In`) it was found in, via a single storage query per visible
2012    /// namespace instead of two separate direction-scoped `neighbors_with_query`
2013    /// calls: halving the neighbor SELECT count for `context(direction="both")`
2014    /// expansion. `query.direction` is ignored: always both.
2015    ///
2016    /// Mirrors `neighbors_with_query`'s dedup/enrich/soft-delete-filter/order
2017    /// pipeline exactly, carrying the per-hit direction tag through unchanged.
2018    pub async fn neighbors_with_query_directed(
2019        &self,
2020        token: &NamespaceToken,
2021        node_id: Uuid,
2022        query: NeighborQuery,
2023    ) -> RuntimeResult<Vec<(NeighborHit, Direction)>> {
2024        if !self.substrate_exists_in_ns(token, node_id).await? {
2025            return Ok(Vec::new());
2026        }
2027
2028        let mut hits: Vec<DirectedNeighborHit> = Vec::new();
2029        for ns in token.visible_namespaces() {
2030            let temp = NamespaceToken::for_namespace(ns.clone());
2031            let mut ns_hits = self
2032                .graph(&temp)?
2033                .neighbors_both_directions(node_id, query.clone())
2034                .await?;
2035            hits.append(&mut ns_hits);
2036        }
2037        // Direction is part of the key (not just node_id/edge_id) so a
2038        // self-loop's Out row and In row — same node_id and edge_id, opposite
2039        // direction: sort adjacent but distinct and both survive dedup.
2040        hits.sort_by_key(|h| {
2041            (
2042                h.hit.node_id,
2043                h.hit.edge_id,
2044                direction_sort_rank(&h.direction),
2045            )
2046        });
2047        hits.dedup_by_key(|h| {
2048            (
2049                h.hit.node_id,
2050                h.hit.edge_id,
2051                direction_sort_rank(&h.direction),
2052            )
2053        });
2054
2055        let mut plain_hits: Vec<NeighborHit> = hits.iter().map(|h| h.hit.clone()).collect();
2056        self.enrich_neighbor_hits(token, &mut plain_hits).await;
2057        for (dh, enriched) in hits.iter_mut().zip(plain_hits) {
2058            dh.hit = enriched;
2059        }
2060
2061        // Filter out soft-deleted entity nodes.
2062        let candidate_ids: Vec<Uuid> = hits.iter().map(|h| h.hit.node_id).collect();
2063        let deleted = self.deleted_entity_ids(candidate_ids).await;
2064        if !deleted.is_empty() {
2065            hits.retain(|h| !deleted.contains(&h.hit.node_id));
2066        }
2067        // Same global weight-descending/node_id-ascending restore as
2068        // `neighbors_with_query`: the (node_id, edge_id, direction) sort above
2069        // exists only to make `dedup_by_key` adjacent-comparable.
2070        hits.sort_by(|a, b| {
2071            b.hit
2072                .weight
2073                .partial_cmp(&a.hit.weight)
2074                .unwrap_or(std::cmp::Ordering::Equal)
2075                .then(a.hit.node_id.cmp(&b.hit.node_id))
2076        });
2077        Ok(hits.into_iter().map(|h| (h.hit, h.direction)).collect())
2078    }
2079
2080    /// Traverse the graph from a set of root nodes.
2081    ///
2082    /// Roots in a foreign namespace are silently filtered before storage expansion.
2083    /// Soft-deleted entity nodes are excluded from results.
2084    pub async fn traverse(
2085        &self,
2086        token: &NamespaceToken,
2087        request: TraversalRequest,
2088    ) -> RuntimeResult<Vec<GraphPath>> {
2089        let mut request = request;
2090        let mut visible_roots = Vec::with_capacity(request.roots.len());
2091        for root in request.roots.drain(..) {
2092            if self.substrate_exists_in_ns(token, root).await? {
2093                visible_roots.push(root);
2094            }
2095        }
2096        request.roots = visible_roots;
2097        if request.roots.is_empty() {
2098            return Ok(Vec::new());
2099        }
2100
2101        let mut paths = Vec::new();
2102        for ns in token.visible_namespaces() {
2103            let temp = NamespaceToken::for_namespace(ns.clone());
2104            let mut ns_paths = self.graph(&temp)?.traverse(request.clone()).await?;
2105            paths.append(&mut ns_paths);
2106        }
2107        self.enrich_path_nodes(token, &mut paths, request.include_properties)
2108            .await;
2109        // Filter out soft-deleted entity nodes from all path nodes.
2110        let all_node_ids: Vec<Uuid> = paths
2111            .iter()
2112            .flat_map(|p| p.nodes.iter().map(|n| n.node_id))
2113            .collect();
2114        let deleted = self.deleted_entity_ids(all_node_ids).await;
2115        if !deleted.is_empty() {
2116            for path in paths.iter_mut() {
2117                path.nodes.retain(|n| !deleted.contains(&n.node_id));
2118            }
2119            paths.retain(|p| !p.nodes.is_empty());
2120        }
2121        Ok(paths)
2122    }
2123
2124    /// Batch-query for soft-deleted UUIDs in `ids`, across BOTH the entities
2125    /// and notes tables.
2126    ///
2127    /// Neighbor/traverse candidates can be note-kind nodes (e.g. reached via
2128    /// `annotates` edges) as well as entities; a screen that only consults
2129    /// `entities` lets soft-deleted note targets leak through and hydrate as
2130    /// blank/missing hits. This is a view-layer read-only screen: it does
2131    /// not touch edges or mutate any data.
2132    ///
2133    /// Returns the subset of `ids` that have `deleted_at IS NOT NULL` in
2134    /// either table. Takes `Vec<Uuid>` (not an iterator) so the async state
2135    /// machine holds only owned data — no iterator borrow across yields.
2136    async fn deleted_entity_ids(&self, ids: Vec<Uuid>) -> std::collections::HashSet<Uuid> {
2137        if ids.is_empty() {
2138            return std::collections::HashSet::new();
2139        }
2140        let id_strs: Vec<String> = ids.iter().map(|u| u.to_string()).collect();
2141        let n = id_strs.len();
2142        // Each UNION half gets its OWN numbered-placeholder block (?1..?n for
2143        // entities, ?(n+1)..?(2n) for notes) — numbered SQLite params bind by
2144        // index, so reusing the same numbers across halves would silently
2145        // collapse to a single shared block instead of binding the full list
2146        // twice (see khive-db/src/stores/graph.rs batch_neighbors: "each half
2147        // is a fully independent positional-parameter block").
2148        let entities_placeholders = (0..n)
2149            .map(|i| format!("?{}", i + 1))
2150            .collect::<Vec<_>>()
2151            .join(",");
2152        let notes_placeholders = (0..n)
2153            .map(|i| format!("?{}", n + i + 1))
2154            .collect::<Vec<_>>()
2155            .join(",");
2156        let sql_str = format!(
2157            "SELECT id FROM entities WHERE id IN ({entities_placeholders}) AND deleted_at IS NOT NULL \
2158             UNION \
2159             SELECT id FROM notes WHERE id IN ({notes_placeholders}) AND deleted_at IS NOT NULL"
2160        );
2161        // Same id list bound twice — once per UNION arm's independent placeholder block.
2162        let params: Vec<SqlValue> = id_strs
2163            .iter()
2164            .chain(id_strs.iter())
2165            .cloned()
2166            .map(SqlValue::Text)
2167            .collect();
2168        let stmt = SqlStatement {
2169            sql: sql_str,
2170            params,
2171            label: Some("deleted_entity_ids".into()),
2172        };
2173        let mut out = std::collections::HashSet::new();
2174        let sql = self.sql();
2175        if let Ok(mut reader) = sql.reader().await {
2176            if let Ok(rows) = reader.query_all(stmt).await {
2177                for row in rows {
2178                    if let Some(col) = row.columns.first() {
2179                        if let SqlValue::Text(s) = &col.value {
2180                            if let Ok(u) = s.parse::<Uuid>() {
2181                                out.insert(u);
2182                            }
2183                        }
2184                    }
2185                }
2186            }
2187            // best-effort: on reader or query error, treat none as deleted
2188        }
2189        out
2190    }
2191
2192    /// Populate `name` and `kind` on each `NeighborHit` from the corresponding
2193    /// entity or note record. Best-effort: unresolved IDs leave the fields `None`.
2194    ///
2195    /// Uses a single batched entity lookup via `get_entities_by_ids_visible`
2196    /// (scoped to the token's full visible-namespace set so that neighbors in
2197    /// extra-visible namespaces are enriched), then a batched note lookup
2198    /// (`get_notes_batch`) for the residual IDs not resolved as entities.
2199    /// Order and identity of hits is preserved via `HashMap` re-index.
2200    async fn enrich_neighbor_hits(&self, token: &NamespaceToken, hits: &mut [NeighborHit]) {
2201        if hits.is_empty() {
2202            return;
2203        }
2204
2205        // Deduplicated IDs for the batch call.
2206        let unique_ids: Vec<Uuid> = {
2207            let mut seen = std::collections::HashSet::new();
2208            hits.iter()
2209                .filter_map(|h| {
2210                    if seen.insert(h.node_id) {
2211                        Some(h.node_id)
2212                    } else {
2213                        None
2214                    }
2215                })
2216                .collect()
2217        };
2218
2219        let entity_map: HashMap<Uuid, Entity> = self
2220            .get_entities_by_ids_visible(token, &unique_ids)
2221            .await
2222            .unwrap_or_default()
2223            .into_iter()
2224            .map(|e| (e.id, e))
2225            .collect();
2226
2227        // Batch note lookup for IDs not found as entities.
2228        let residual_ids: Vec<Uuid> = unique_ids
2229            .iter()
2230            .filter(|id| !entity_map.contains_key(id))
2231            .copied()
2232            .collect();
2233
2234        let note_map: HashMap<Uuid, Note> = if !residual_ids.is_empty() {
2235            if let Ok(store) = self.notes(token) {
2236                store
2237                    .get_notes_batch(&residual_ids)
2238                    .await
2239                    .unwrap_or_default()
2240                    .into_iter()
2241                    .map(|n| (n.id, n))
2242                    .collect()
2243            } else {
2244                HashMap::new()
2245            }
2246        } else {
2247            HashMap::new()
2248        };
2249
2250        for hit in hits.iter_mut() {
2251            if let Some(entity) = entity_map.get(&hit.node_id) {
2252                hit.name = Some(entity.name.clone());
2253                hit.kind = Some(entity.kind.clone());
2254                hit.entity_type = entity.entity_type.clone();
2255            } else if let Some(note) = note_map.get(&hit.node_id) {
2256                let kind = note.kind.clone();
2257                let name = note
2258                    .name
2259                    .as_deref()
2260                    .filter(|s| !s.trim().is_empty())
2261                    .map(|s| s.to_owned())
2262                    .unwrap_or_else(|| format!("[{kind}]"));
2263                hit.name = Some(name);
2264                hit.kind = Some(kind);
2265            }
2266        }
2267    }
2268
2269    /// Populate `name` and `kind` on each `PathNode` from the corresponding
2270    /// entity record. Same best-effort policy as `enrich_neighbor_hits`.
2271    ///
2272    /// Uses `get_entities_by_ids_visible` so that path nodes whose entities
2273    /// live in extra-visible namespaces are enriched correctly. Node IDs that
2274    /// repeat across paths are fetched exactly once.
2275    ///
2276    /// `include_properties` gates whether `entity.properties` is cloned onto
2277    /// each node. When `false` (the default), the potentially large JSON blob
2278    /// is never read from the map, keeping the hot path allocation-free.
2279    async fn enrich_path_nodes(
2280        &self,
2281        token: &NamespaceToken,
2282        paths: &mut [GraphPath],
2283        include_properties: bool,
2284    ) {
2285        if paths.is_empty() {
2286            return;
2287        }
2288
2289        // Deduplicate node IDs across all paths before the batch call.
2290        let unique_ids: Vec<Uuid> = {
2291            let mut seen = std::collections::HashSet::new();
2292            paths
2293                .iter()
2294                .flat_map(|p| p.nodes.iter())
2295                .filter_map(|n| {
2296                    if seen.insert(n.node_id) {
2297                        Some(n.node_id)
2298                    } else {
2299                        None
2300                    }
2301                })
2302                .collect()
2303        };
2304
2305        let entity_map: HashMap<Uuid, Entity> = self
2306            .get_entities_by_ids_visible(token, &unique_ids)
2307            .await
2308            .unwrap_or_default()
2309            .into_iter()
2310            .map(|e| (e.id, e))
2311            .collect();
2312
2313        for path in paths.iter_mut() {
2314            for node in path.nodes.iter_mut() {
2315                if let Some(entity) = entity_map.get(&node.node_id) {
2316                    node.name = Some(entity.name.clone());
2317                    node.kind = Some(entity.kind.clone());
2318                    if include_properties {
2319                        node.properties = entity.properties.clone();
2320                    }
2321                }
2322            }
2323        }
2324    }
2325
2326    // ---- Note operations ----
2327
2328    /// Create and persist a note, optionally with properties and annotation targets.
2329    ///
2330    /// After creating the note:
2331    /// - Always indexes into FTS5 at the `notes_<namespace>` key.
2332    /// - If an embedding model is configured, indexes into the vector store with
2333    ///   `SubstrateKind::Note`.
2334    /// - For each UUID in `annotates`, creates an `EdgeRelation::Annotates` edge from
2335    ///   the note to that target.
2336    // REASON: note creation requires kind, name, content, salience, properties, annotates,
2337    // and namespace token — mirrors the MCP verb surface; a builder would not reduce
2338    // caller complexity for pack handler callers.
2339    #[allow(clippy::too_many_arguments)]
2340    pub async fn create_note(
2341        &self,
2342        token: &NamespaceToken,
2343        kind: &str,
2344        name: Option<&str>,
2345        content: &str,
2346        salience: Option<f64>,
2347        properties: Option<serde_json::Value>,
2348        annotates: Vec<Uuid>,
2349    ) -> RuntimeResult<Note> {
2350        self.create_note_inner(
2351            token, kind, name, content, None, salience, None, properties, annotates, None,
2352        )
2353        .await
2354    }
2355
2356    /// Like [`Self::create_note`], but lets the caller supply a smaller text
2357    /// to send to the vector embedder while the note's stored/FTS-indexed
2358    /// `content` remains the full text.
2359    ///
2360    /// `embedding_content`, when `Some`, must be non-empty and a proper
2361    /// prefix of `content` — anything else is rejected with `InvalidInput`
2362    /// before any write. `None` behaves exactly like [`Self::create_note`].
2363    /// Use this when `content` may exceed an embedder's input cap (e.g. a
2364    /// very long commit message) and only a capped head prefix should be
2365    /// embedded, while the full text is still stored and searchable via FTS.
2366    #[allow(clippy::too_many_arguments)]
2367    pub async fn create_note_with_embedding_content(
2368        &self,
2369        token: &NamespaceToken,
2370        kind: &str,
2371        name: Option<&str>,
2372        content: &str,
2373        embedding_content: Option<&str>,
2374        salience: Option<f64>,
2375        properties: Option<serde_json::Value>,
2376        annotates: Vec<Uuid>,
2377    ) -> RuntimeResult<Note> {
2378        self.create_note_inner(
2379            token,
2380            kind,
2381            name,
2382            content,
2383            embedding_content,
2384            salience,
2385            None,
2386            properties,
2387            annotates,
2388            None,
2389        )
2390        .await
2391    }
2392
2393    /// Like [`Self::create_note`] but also sets a non-zero decay factor on the note.
2394    // REASON: extends create_note with an additional decay_factor parameter; same
2395    // rationale — mirrors the MCP surface and reduces an extra builder layer.
2396    #[allow(clippy::too_many_arguments)]
2397    pub async fn create_note_with_decay(
2398        &self,
2399        token: &NamespaceToken,
2400        kind: &str,
2401        name: Option<&str>,
2402        content: &str,
2403        salience: Option<f64>,
2404        decay_factor: f64,
2405        properties: Option<serde_json::Value>,
2406        annotates: Vec<Uuid>,
2407    ) -> RuntimeResult<Note> {
2408        self.create_note_with_decay_for_embedding_model(
2409            token,
2410            kind,
2411            name,
2412            content,
2413            salience,
2414            decay_factor,
2415            properties,
2416            annotates,
2417            None,
2418        )
2419        .await
2420    }
2421
2422    /// Like [`Self::create_note_with_decay`] but targets a specific embedding model.
2423    // REASON: adds an embedding_model parameter to the decay variant; the full parameter
2424    // set is required for correct MCP verb routing and cannot be collapsed without
2425    // introducing a separate config struct that would obscure call sites.
2426    #[allow(clippy::too_many_arguments)]
2427    pub async fn create_note_with_decay_for_embedding_model(
2428        &self,
2429        token: &NamespaceToken,
2430        kind: &str,
2431        name: Option<&str>,
2432        content: &str,
2433        salience: Option<f64>,
2434        decay_factor: f64,
2435        properties: Option<serde_json::Value>,
2436        annotates: Vec<Uuid>,
2437        embedding_model: Option<&str>,
2438    ) -> RuntimeResult<Note> {
2439        self.create_note_inner(
2440            token,
2441            kind,
2442            name,
2443            content,
2444            None,
2445            salience,
2446            Some(decay_factor),
2447            properties,
2448            annotates,
2449            embedding_model,
2450        )
2451        .await
2452    }
2453
2454    /// Insert a note using `INSERT OR IGNORE` semantics for atomic deduplication.
2455    ///
2456    /// Returns `Ok(Some(note))` when the note was newly written.  Returns
2457    /// `Ok(None)` when a unique constraint (e.g. the `external_id` partial
2458    /// index on comm message notes) was already satisfied by an existing row,
2459    /// making this call a no-op.  FTS indexing and vector embedding are
2460    /// attempted on success but treated as best-effort: failures are logged
2461    /// and do not abort the write.
2462    ///
2463    /// This method is intentionally narrower than `create_note`: it skips
2464    /// salience/decay, annotates edges, and embedding-model selection, which
2465    /// are not needed for channel-ingest paths.
2466    pub async fn try_create_note(
2467        &self,
2468        token: &NamespaceToken,
2469        kind: &str,
2470        name: Option<&str>,
2471        content: &str,
2472        properties: Option<serde_json::Value>,
2473    ) -> RuntimeResult<Option<Note>> {
2474        self.validate_note_kind(kind)?;
2475        crate::secret_gate::check(content)?;
2476        if let Some(n) = name {
2477            crate::secret_gate::check(n)?;
2478        }
2479        if let Some(ref p) = properties {
2480            crate::secret_gate::check_json(p)?;
2481        }
2482
2483        let ns = token.namespace().as_str();
2484        let mut note = Note::new(ns, kind, content);
2485        if let Some(n) = name {
2486            note = note.with_name(n);
2487        }
2488        if let Some(p) = properties {
2489            note = note.with_properties(p);
2490        }
2491
2492        let inserted = self.notes(token)?.try_insert_note(note.clone()).await?;
2493        if !inserted {
2494            return Ok(None);
2495        }
2496
2497        // Best-effort FTS: log and continue on failure.
2498        if let Ok(fts) = self.text_for_notes(token) {
2499            if let Err(e) = fts.upsert_document(note_fts_document(&note)).await {
2500                tracing::warn!(
2501                    note_id = %note.id,
2502                    error = %e,
2503                    "try_create_note: FTS indexing failed (non-fatal)"
2504                );
2505            }
2506        }
2507
2508        // Best-effort vector embedding: log and continue on failure.
2509        let embed_model_names = self.registered_embedding_model_names();
2510        for model_name in &embed_model_names {
2511            match self
2512                .embed_document_with_model(model_name, &note.content)
2513                .await
2514            {
2515                Ok(vector) => {
2516                    if let Ok(vs) = self.vectors_for_model(token, model_name) {
2517                        if let Err(e) = vs
2518                            .insert(
2519                                note.id,
2520                                SubstrateKind::Note,
2521                                ns,
2522                                "note.content",
2523                                vec![vector],
2524                            )
2525                            .await
2526                        {
2527                            tracing::warn!(
2528                                note_id = %note.id,
2529                                model = %model_name,
2530                                error = %e,
2531                                "try_create_note: vector insert failed (non-fatal)"
2532                            );
2533                        }
2534                    }
2535                }
2536                Err(e) => {
2537                    tracing::warn!(
2538                        note_id = %note.id,
2539                        model = %model_name,
2540                        error = %e,
2541                        "try_create_note: embedding failed (non-fatal)"
2542                    );
2543                }
2544            }
2545        }
2546
2547        Ok(Some(note))
2548    }
2549
2550    // REASON: private inner function unifies all create_note variants; it receives every
2551    // optional parameter individually so that public variants can pass None without
2552    // requiring callers to construct an intermediate struct.
2553    #[allow(clippy::too_many_arguments)]
2554    async fn create_note_inner(
2555        &self,
2556        token: &NamespaceToken,
2557        kind: &str,
2558        name: Option<&str>,
2559        content: &str,
2560        embedding_content: Option<&str>,
2561        salience: Option<f64>,
2562        decay_factor: Option<f64>,
2563        properties: Option<serde_json::Value>,
2564        annotates: Vec<Uuid>,
2565        embedding_model: Option<&str>,
2566    ) -> RuntimeResult<Note> {
2567        self.validate_note_kind(kind)?;
2568        // Secret gate: scan content, optional name, and structured properties.
2569        crate::secret_gate::check(content)?;
2570        if let Some(n) = name {
2571            crate::secret_gate::check(n)?;
2572        }
2573        if let Some(ref p) = properties {
2574            crate::secret_gate::check_json(p)?;
2575        }
2576        // `embedding_content` is a caller-supplied alternate vector-embedding
2577        // input: it must be a non-empty proper prefix of `content` (never a
2578        // superset, an unrelated string, or the full text) and passes the
2579        // same secret gate as any other stored/embedded text. Rejected
2580        // before any write, same as the checks above.
2581        if let Some(ec) = embedding_content {
2582            if ec.is_empty() {
2583                return Err(RuntimeError::InvalidInput(
2584                    "embedding_content must not be empty".into(),
2585                ));
2586            }
2587            if ec.len() >= content.len() || !content.starts_with(ec) {
2588                return Err(RuntimeError::InvalidInput(
2589                    "embedding_content must be a proper prefix of content".into(),
2590                ));
2591            }
2592            crate::secret_gate::check(ec)?;
2593        }
2594        let ns = token.namespace().as_str();
2595
2596        // Validate all annotates targets before any write (atomicity: all-or-nothing).
2597        // Endpoint resolution is by-ID and namespace-agnostic.
2598        for &target_id in &annotates {
2599            if !self.substrate_exists_by_id(token, target_id).await? {
2600                return Err(RuntimeError::NotFound(format!(
2601                    "create_note annotates target {target_id} not found"
2602                )));
2603            }
2604        }
2605
2606        // Reject non-finite or out-of-range salience/decay at the runtime boundary
2607        // rather than letting storage silently clamp them (coding-standards §508-516).
2608        if let Some(s) = salience {
2609            if !s.is_finite() || !(0.0..=1.0).contains(&s) {
2610                return Err(RuntimeError::InvalidInput(format!(
2611                    "salience must be a finite value in [0.0, 1.0]; got {s}"
2612                )));
2613            }
2614        }
2615        if let Some(d) = decay_factor {
2616            if !d.is_finite() || d < 0.0 {
2617                return Err(RuntimeError::InvalidInput(format!(
2618                    "decay_factor must be a finite value >= 0.0; got {d}"
2619                )));
2620            }
2621        }
2622
2623        // Resolve embedding_model BEFORE any note/FTS/vector write so unknown-model
2624        // errors are atomic at the runtime layer, not just at one pack handler.
2625        // Direct Rust callers (other packs, integration tests) get the same guarantee.
2626        if let Some(model_name) = embedding_model {
2627            self.resolve_embedding_model(Some(model_name))?;
2628        }
2629
2630        let mut note = Note::new(ns, kind, content);
2631        if let Some(s) = salience {
2632            note = note.with_salience(s);
2633        }
2634        if let Some(df) = decay_factor {
2635            note = note.with_decay(df);
2636        }
2637        if let Some(n) = name {
2638            note = note.with_name(n);
2639        }
2640        if let Some(p) = properties {
2641            note = note.with_properties(p);
2642        }
2643        self.notes(token)?.upsert_note(note.clone()).await?;
2644
2645        // From here on, any error must compensate by removing the note row, its
2646        // FTS document, and any vector entries already inserted — the same
2647        // cleanup used by the annotates-edge block below.
2648
2649        // Decide which embedding models to use (before touching FTS/vectors).
2650        let embed_model_names: Vec<String> = if let Some(m) = embedding_model {
2651            vec![m.to_string()]
2652        } else {
2653            // Fan out to ALL registered models — includes both lattice models
2654            // from RuntimeConfig and any custom providers added via
2655            // register_embedder(). Gate on the registry, not
2656            // config().embedding_model, so that custom-only runtimes (no
2657            // lattice model in config) also fan out.
2658            let names = self.registered_embedding_model_names();
2659            if names.is_empty() {
2660                // No models configured at all — skip vector embedding.
2661                vec![]
2662            } else {
2663                names
2664            }
2665        };
2666
2667        // FTS step — compensate note row on failure.
2668        {
2669            // Injection: check FTS_FAIL_NS (armed by `arm_fts_fail(ns)`).
2670            // Fires only when `ns` is in the armed set, removing it on the way
2671            // out (one-shot, atomic check-and-remove under the mutex). No lock
2672            // acquisition in release builds — the cfg(not) branch is a const
2673            // false so the compiler eliminates the if-branch entirely.
2674            #[cfg(any(test, feature = "fault-injection"))]
2675            let fts_inject = FTS_FAIL_NS.lock().unwrap().remove(ns);
2676            #[cfg(not(any(test, feature = "fault-injection")))]
2677            let fts_inject = false;
2678            let fts_result: RuntimeResult<()> = if fts_inject {
2679                Err(RuntimeError::Internal("injected FTS failure".to_string()))
2680            } else {
2681                match self.text_for_notes(token) {
2682                    Ok(fts) => fts
2683                        .upsert_document(note_fts_document(&note))
2684                        .await
2685                        .map_err(RuntimeError::from),
2686                    Err(e) => Err(e),
2687                }
2688            };
2689
2690            if let Err(e) = fts_result {
2691                // Best-effort compensation — ignore cleanup errors.
2692                if let Ok(store) = self.notes(token) {
2693                    let _ = store.delete_note(note.id, DeleteMode::Hard).await;
2694                }
2695                return Err(e);
2696            }
2697        }
2698
2699        // Vector embedding + insert step — compensate note row + FTS doc on failure.
2700        // Multi-model vector embedding:
2701        //   - explicit embedding_model → single model (existing behaviour)
2702        //   - None + any models registered → ALL registered models in parallel
2703        //   - None + no models configured → skip (text-only)
2704        // The effective text sent to every embedder: the caller-supplied
2705        // capped override when present, otherwise the full stored content.
2706        // FTS indexing above always used the full `note.content` — this cap
2707        // affects only the vector-embedding input.
2708        let embed_text: &str = embedding_content.unwrap_or(content);
2709
2710        if embed_model_names.len() == 1 {
2711            // Single-model path: preserves original sequential behaviour.
2712            let model_name = &embed_model_names[0];
2713            let vec_result = self.embed_document_with_model(model_name, embed_text).await;
2714
2715            // Injection: check VECTOR_FAIL_NS (armed by `arm_vector_fail(ns)`) or
2716            // VECTOR_FAIL_AFTER (armed by `arm_vector_fail_after(n)`). The former
2717            // fires only when the armed namespace matches this note's namespace;
2718            // callers that cannot guarantee no concurrently-running test also
2719            // writes a note into that same namespace (e.g. a test suite whose
2720            // fixtures share one default namespace) should prefer the latter,
2721            // thread-local count instead — see its doc comment. Either clears
2722            // (one-shot) once it fires. No lock/cell access in release builds —
2723            // the cfg(not) branch is a const false eliminating the if-branch.
2724            #[cfg(any(test, feature = "fault-injection"))]
2725            let vec_inject = {
2726                let ns_inject = {
2727                    let mut g = VECTOR_FAIL_NS.lock().unwrap();
2728                    if g.as_deref() == Some(ns) {
2729                        *g = None;
2730                        true
2731                    } else {
2732                        false
2733                    }
2734                };
2735                let count_inject = VECTOR_FAIL_AFTER.with(|cell| match cell.get() {
2736                    Some(0) => {
2737                        cell.set(None);
2738                        true
2739                    }
2740                    Some(n) => {
2741                        cell.set(Some(n - 1));
2742                        false
2743                    }
2744                    None => false,
2745                });
2746                ns_inject || count_inject
2747            };
2748            #[cfg(not(any(test, feature = "fault-injection")))]
2749            let vec_inject = false;
2750            let vec_result: RuntimeResult<Vec<f32>> = if vec_inject {
2751                Err(RuntimeError::Internal(
2752                    "injected vector failure".to_string(),
2753                ))
2754            } else {
2755                vec_result
2756            };
2757
2758            let single_model_result: RuntimeResult<()> = match vec_result {
2759                Ok(vector) => match self.vectors_for_model(token, model_name) {
2760                    Ok(vs) => vs
2761                        .insert(
2762                            note.id,
2763                            SubstrateKind::Note,
2764                            ns,
2765                            "note.content",
2766                            vec![vector],
2767                        )
2768                        .await
2769                        .map_err(RuntimeError::from),
2770                    Err(e) => Err(e),
2771                },
2772                Err(e) => Err(e),
2773            };
2774            if let Err(e) = single_model_result {
2775                // Compensate note row + FTS.
2776                if let Ok(store) = self.notes(token) {
2777                    let _ = store.delete_note(note.id, DeleteMode::Hard).await;
2778                }
2779                if let Ok(fts) = self.text_for_notes(token) {
2780                    let _ = fts.delete_document(ns, note.id).await;
2781                }
2782                return Err(e);
2783            }
2784        } else if !embed_model_names.is_empty() {
2785            // Multi-model path: embed with each model in parallel via spawned tasks,
2786            // then insert one VectorRecord per model.
2787            let rt_clone = self.clone();
2788            let content_owned = embed_text.to_string();
2789            let mut handles = Vec::with_capacity(embed_model_names.len());
2790            for model_name in &embed_model_names {
2791                let rt = rt_clone.clone();
2792                let text = content_owned.clone();
2793                let name = model_name.clone();
2794                handles.push(tokio::spawn(async move {
2795                    rt.embed_document_with_model(&name, &text).await
2796                }));
2797            }
2798            let mut vectors: Vec<Vec<f32>> = Vec::with_capacity(embed_model_names.len());
2799            for handle in handles {
2800                let join_result = handle
2801                    .await
2802                    .map_err(|e| RuntimeError::Internal(format!("embed task panicked: {e}")));
2803                match join_result {
2804                    Err(e) => {
2805                        // Compensate note row + FTS (no vectors inserted yet).
2806                        if let Ok(store) = self.notes(token) {
2807                            let _ = store.delete_note(note.id, DeleteMode::Hard).await;
2808                        }
2809                        if let Ok(fts) = self.text_for_notes(token) {
2810                            let _ = fts.delete_document(ns, note.id).await;
2811                        }
2812                        return Err(e);
2813                    }
2814                    Ok(Err(e)) => {
2815                        // Embed call failed — compensate note row + FTS.
2816                        if let Ok(store) = self.notes(token) {
2817                            let _ = store.delete_note(note.id, DeleteMode::Hard).await;
2818                        }
2819                        if let Ok(fts) = self.text_for_notes(token) {
2820                            let _ = fts.delete_document(ns, note.id).await;
2821                        }
2822                        return Err(e);
2823                    }
2824                    Ok(Ok(vec)) => vectors.push(vec),
2825                }
2826            }
2827            // TODO(P2): parallelize vector inserts
2828            let mut inserted_models: Vec<String> = Vec::with_capacity(embed_model_names.len());
2829            for (model_name, vector) in embed_model_names.iter().zip(vectors) {
2830                let insert_result = match self.vectors_for_model(token, model_name) {
2831                    Ok(vs) => vs
2832                        .insert(
2833                            note.id,
2834                            SubstrateKind::Note,
2835                            ns,
2836                            "note.content",
2837                            vec![vector],
2838                        )
2839                        .await
2840                        .map_err(RuntimeError::from),
2841                    Err(e) => Err(e),
2842                };
2843                if let Err(e) = insert_result {
2844                    // Compensate note row + FTS + already-inserted vectors.
2845                    if let Ok(store) = self.notes(token) {
2846                        let _ = store.delete_note(note.id, DeleteMode::Hard).await;
2847                    }
2848                    if let Ok(fts) = self.text_for_notes(token) {
2849                        let _ = fts.delete_document(ns, note.id).await;
2850                    }
2851                    for m in &inserted_models {
2852                        if let Ok(vs) = self.vectors_for_model(token, m) {
2853                            let _ = vs.delete(note.id).await;
2854                        }
2855                    }
2856                    return Err(e);
2857                }
2858                inserted_models.push(model_name.clone());
2859            }
2860        }
2861
2862        // Create annotates edges, compensating on failure to preserve atomicity.
2863        //
2864        // Pre-validation (above) ensures all targets exist, so link failures are
2865        // unexpected. If one occurs: delete any edges already created, then remove
2866        // the note, its FTS document, and its vector entry.
2867        let mut created_edges: Vec<Uuid> = Vec::with_capacity(annotates.len());
2868
2869        // In test builds, iterate with an index so the failure-injection hook can
2870        // target a specific call.  In release builds, skip the enumerate overhead.
2871        #[cfg(test)]
2872        let annotates_iter: Vec<(usize, Uuid)> = annotates
2873            .iter()
2874            .enumerate()
2875            .map(|(i, &id)| (i, id))
2876            .collect();
2877        #[cfg(test)]
2878        macro_rules! next_target {
2879            ($pair:expr) => {
2880                $pair.1
2881            };
2882        }
2883        #[cfg(not(test))]
2884        let annotates_iter: Vec<Uuid> = annotates.to_vec();
2885        #[cfg(not(test))]
2886        macro_rules! next_target {
2887            ($pair:expr) => {
2888                $pair
2889            };
2890        }
2891
2892        for pair in annotates_iter {
2893            let target_id = next_target!(pair);
2894
2895            // Test-only: inject a failure on the configured call index (1-based).
2896            #[cfg(test)]
2897            let injected_err: Option<RuntimeError> = {
2898                let call_idx = pair.0;
2899                LINK_FAIL_AFTER.with(|cell| {
2900                    let n = cell.get();
2901                    if n > 0 && call_idx + 1 == n {
2902                        cell.set(0); // reset so subsequent calls are unaffected
2903                        Some(RuntimeError::Internal("injected link failure".to_string()))
2904                    } else {
2905                        None
2906                    }
2907                })
2908            };
2909            #[cfg(not(test))]
2910            let injected_err: Option<RuntimeError> = None;
2911
2912            let link_result = if let Some(e) = injected_err {
2913                Err(e)
2914            } else {
2915                self.link(
2916                    token,
2917                    note.id,
2918                    target_id,
2919                    EdgeRelation::Annotates,
2920                    1.0,
2921                    None,
2922                )
2923                .await
2924            };
2925
2926            match link_result {
2927                Ok(edge) => created_edges.push(edge.id.into()),
2928                Err(e) => {
2929                    // Best-effort compensation — ignore cleanup errors.
2930                    for edge_id in created_edges {
2931                        let _ = self.delete_edge(token, edge_id, true).await;
2932                    }
2933                    if let Ok(store) = self.notes(token) {
2934                        let _ = store.delete_note(note.id, DeleteMode::Hard).await;
2935                    }
2936                    if let Ok(fts) = self.text_for_notes(token) {
2937                        let _ = fts.delete_document(ns, note.id).await;
2938                    }
2939                    for model_name in &embed_model_names {
2940                        if let Ok(vs) = self.vectors_for_model(token, model_name) {
2941                            let _ = vs.delete(note.id).await;
2942                        }
2943                    }
2944                    return Err(e);
2945                }
2946            }
2947        }
2948
2949        Ok(note)
2950    }
2951
2952    /// List notes visible to the token, optionally filtered by kind.
2953    ///
2954    /// When the token carries a multi-namespace visible set, notes from all
2955    /// visible namespaces are returned. When the visible set is `[primary]`
2956    /// (the default) this behaves identically to the pre-visibility behaviour.
2957    pub async fn list_notes(
2958        &self,
2959        token: &NamespaceToken,
2960        kind: Option<&str>,
2961        limit: u32,
2962        offset: u32,
2963    ) -> RuntimeResult<Vec<Note>> {
2964        let visible = token.visible_namespaces();
2965        if visible.len() == 1 {
2966            // Fast path: single namespace — use the dedicated query_notes method.
2967            let page = self
2968                .notes(token)?
2969                .query_notes(
2970                    token.namespace().as_str(),
2971                    kind,
2972                    PageRequest {
2973                        offset: offset.into(),
2974                        limit,
2975                    },
2976                )
2977                .await?;
2978            return Ok(page.items);
2979        }
2980        // Multi-namespace path: use query_notes_filtered with the visible set.
2981        use khive_storage::note::NoteFilter;
2982        let ns_strs: Vec<String> = visible.iter().map(|ns| ns.as_str().to_owned()).collect();
2983        let filter = NoteFilter {
2984            kind: kind.map(|k| k.to_string()),
2985            namespaces: ns_strs,
2986            ..Default::default()
2987        };
2988        let page = self
2989            .notes(token)?
2990            .query_notes_filtered(
2991                token.namespace().as_str(),
2992                &filter,
2993                PageRequest {
2994                    offset: offset.into(),
2995                    limit,
2996                },
2997            )
2998            .await?;
2999        Ok(page.items)
3000    }
3001
3002    /// Count notes matching `kind`, summed across the caller's visible
3003    /// namespaces. The store-layer `count_notes` is namespace-pinned by
3004    /// design (no `NamespaceFilter`-style `IN (...)` support); this sums the
3005    /// per-namespace store calls, mirroring [`Self::count_edges_by_relation`]
3006    /// and the multi-namespace path in [`Self::list_notes`] so `stats().notes`
3007    /// reconciles with a full `list` keyset walk under the same token.
3008    pub async fn count_notes(
3009        &self,
3010        token: &NamespaceToken,
3011        kind: Option<&str>,
3012    ) -> RuntimeResult<u64> {
3013        let mut total = 0u64;
3014        for ns in token.visible_namespaces() {
3015            let temp = NamespaceToken::for_namespace(ns.clone());
3016            total += self.notes(&temp)?.count_notes(ns.as_str(), kind).await?;
3017        }
3018        Ok(total)
3019    }
3020
3021    /// Search notes using a hybrid FTS5 + vector pipeline with salience weighting.
3022    ///
3023    /// Pipeline:
3024    /// 1. FTS5 query against `notes_<namespace>`.
3025    /// 2. If embedding model is configured: vector search filtered to `kind="note"`.
3026    /// 3. RRF fusion (k=60).
3027    /// 4. Salience-weighted rerank: `score *= (0.5 + 0.5 * note.salience)`.
3028    /// 5. Filter soft-deleted notes, apply optional kind / tag / properties predicates.
3029    ///    Tags and properties are pushed into the per-note fetch loop BEFORE truncation
3030    ///    so that matching notes ranked beyond `limit` in the raw fusion are not silently
3031    ///    dropped.
3032    /// 6. Truncate to `limit`.
3033    ///
3034    /// `tags_any`: when non-empty, only notes that have at least one of these tags
3035    /// (stored in `properties["tags"]`, case-insensitive match) are retained. The
3036    /// check happens inside the alive-note loop, before `hits.truncate(limit)`.
3037    ///
3038    /// `properties_filter`: when `Some`, only notes whose `properties` JSON object is
3039    /// a superset of the given filter object are retained. Also applied before truncation.
3040    #[allow(clippy::too_many_arguments)]
3041    pub async fn search_notes(
3042        &self,
3043        token: &NamespaceToken,
3044        query_text: &str,
3045        query_vector: Option<Vec<f32>>,
3046        limit: u32,
3047        note_kind: Option<&str>,
3048        include_superseded: bool,
3049        tags_any: &[String],
3050        properties_filter: Option<&serde_json::Value>,
3051    ) -> RuntimeResult<Vec<NoteSearchHit>> {
3052        const RRF_K: usize = 60;
3053        let candidates = limit.saturating_mul(4).max(limit);
3054        let visible_ns: Vec<String> = token
3055            .visible_namespaces()
3056            .iter()
3057            .map(|ns| ns.as_str().to_owned())
3058            .collect();
3059
3060        // FTS5 over the notes index — search all visible namespaces.
3061        //
3062        // `sanitize_fts5_query` strips known-unsafe FTS5 metacharacters, but
3063        // residual punctuation the sanitizer does not strip can still reach
3064        // the FTS5 parser and error. This fails loud instead of degrading to
3065        // vector-only fusion, so callers see the bad query instead of
3066        // silently losing the lexical leg. Errors from any other leg (vector
3067        // search, note hydration) still propagate normally.
3068        //
3069        // Injection: check FTS_SEARCH_FAIL_NS (armed by `arm_fts_search_fail(ns)`),
3070        // exercising the propagate branch above. Fires only when the armed
3071        // namespace is among this call's visible namespaces, then clears (one-shot).
3072        #[cfg(any(test, feature = "fault-injection"))]
3073        let fts_search_inject = {
3074            let mut g = FTS_SEARCH_FAIL_NS.lock().unwrap();
3075            match g.as_deref() {
3076                Some(armed) if visible_ns.iter().any(|ns| ns == armed) => {
3077                    *g = None;
3078                    true
3079                }
3080                _ => false,
3081            }
3082        };
3083        #[cfg(not(any(test, feature = "fault-injection")))]
3084        let fts_search_inject = false;
3085
3086        let text_search_result = if fts_search_inject {
3087            Err(khive_storage::StorageError::Timeout {
3088                operation: "fts_search".into(),
3089            })
3090        } else {
3091            self.text_for_notes(token)?
3092                .search(TextSearchRequest {
3093                    query: query_text.to_string(),
3094                    mode: TextQueryMode::Plain,
3095                    filter: Some(TextFilter {
3096                        namespaces: visible_ns.clone(),
3097                        ..TextFilter::default()
3098                    }),
3099                    top_k: candidates,
3100                    snippet_chars: 200,
3101                })
3102                .await
3103        };
3104
3105        let text_hits = crate::error::fts_text_leg_or_err(
3106            text_search_result.map_err(RuntimeError::from),
3107            "search_notes",
3108            query_text,
3109        )?;
3110
3111        // Vector search filtered to notes.
3112        let vector_hits = if query_vector.is_some() || self.config().embedding_model.is_some() {
3113            self.vector_search(
3114                token,
3115                query_vector,
3116                Some(query_text),
3117                candidates,
3118                Some(SubstrateKind::Note),
3119            )
3120            .await?
3121        } else {
3122            vec![]
3123        };
3124
3125        // Keep the full text∪vector union through RRF — salience weighting and
3126        // soft-delete/kind filtering happen *after* this, and the final
3127        // `hits.truncate(limit)` is the only result-limiting cut. Truncating to
3128        // `candidates` here would drop a high-salience note ranked just outside
3129        // the raw RRF cutoff before salience ever applied.
3130        let fuse_k = text_hits.len() + vector_hits.len();
3131        let fused = crate::fusion::rrf_fuse_k(text_hits, vector_hits, RRF_K, fuse_k)?;
3132
3133        let candidate_ids: Vec<Uuid> = fused.iter().map(|hit| hit.entity_id).collect();
3134        if candidate_ids.is_empty() {
3135            return Ok(vec![]);
3136        }
3137
3138        // Fetch each candidate note individually to get salience and apply
3139        // soft-delete + (optional) kind filtering. Notes whose `kind` doesn't
3140        // match `note_kind` are dropped post-fetch — they're a small set
3141        // bounded by the text∪vector union (≤ 2×candidates), so the read is cheap.
3142        let note_store = self.notes(token)?;
3143        let mut alive_notes: HashMap<Uuid, Note> = HashMap::new();
3144        for id in &candidate_ids {
3145            if let Some(note) = note_store.get_note(*id).await? {
3146                if note.deleted_at.is_some() {
3147                    continue;
3148                }
3149                if let Some(want_kind) = note_kind {
3150                    if note.kind != want_kind {
3151                        continue;
3152                    }
3153                }
3154                // Apply tag predicate before adding to alive set: tags on notes live
3155                // inside `properties["tags"]` (a JSON array). This pushes the filter
3156                // before truncation so matching notes ranked beyond `limit` in the raw
3157                // fusion are not silently dropped.
3158                if !tags_any.is_empty() {
3159                    let note_tags: Vec<String> = note
3160                        .properties
3161                        .as_ref()
3162                        .and_then(|p| p.get("tags"))
3163                        .and_then(serde_json::Value::as_array)
3164                        .map(|arr| {
3165                            arr.iter()
3166                                .filter_map(serde_json::Value::as_str)
3167                                .map(str::to_owned)
3168                                .collect()
3169                        })
3170                        .unwrap_or_default();
3171                    if !note_tags
3172                        .iter()
3173                        .any(|t| tags_any.iter().any(|w| t.eq_ignore_ascii_case(w)))
3174                    {
3175                        continue;
3176                    }
3177                }
3178                // Apply properties predicate before truncation, same reasoning as tags above.
3179                if let Some(pf) = properties_filter {
3180                    if !note_props_match(note.properties.as_ref(), pf) {
3181                        continue;
3182                    }
3183                }
3184                alive_notes.insert(*id, note);
3185            }
3186        }
3187
3188        // Drop superseded notes unless include_superseded is true: any note targeted
3189        // by a `supersedes` edge is obsolete and excluded from default search.
3190        if !include_superseded && !alive_notes.is_empty() {
3191            let graph = self.graph(token)?;
3192            let mut superseded: std::collections::HashSet<Uuid> = std::collections::HashSet::new();
3193            for &note_id in alive_notes.keys() {
3194                let inbound = graph
3195                    .neighbors(
3196                        note_id,
3197                        NeighborQuery {
3198                            direction: Direction::In,
3199                            relations: Some(vec![EdgeRelation::Supersedes]),
3200                            limit: Some(1),
3201                            min_weight: None,
3202                        },
3203                    )
3204                    .await?;
3205                if !inbound.is_empty() {
3206                    superseded.insert(note_id);
3207                }
3208            }
3209            alive_notes.retain(|id, _| !superseded.contains(id));
3210        }
3211
3212        // Apply salience weighting and collect final hits.
3213        let mut hits: Vec<NoteSearchHit> = fused
3214            .into_iter()
3215            .filter_map(|hit| {
3216                let note = alive_notes.get(&hit.entity_id)?;
3217                let salience = note.salience.unwrap_or(0.5);
3218                let weight = 0.5 + 0.5 * salience;
3219                let weighted = DeterministicScore::from_f64(hit.score.to_f64() * weight);
3220                Some(NoteSearchHit {
3221                    note_id: hit.entity_id,
3222                    score: weighted,
3223                    title: hit.title.or_else(|| note_title(note)),
3224                    snippet: hit.snippet.or_else(|| note_snippet(note)),
3225                })
3226            })
3227            .collect();
3228
3229        hits.sort_by(|a, b| b.score.cmp(&a.score).then(a.note_id.cmp(&b.note_id)));
3230        hits.truncate(limit as usize);
3231        Ok(hits)
3232    }
3233
3234    /// Resolve a short UUID prefix (8+ hex chars) to a full UUID.
3235    ///
3236    /// Searches entities, notes, and edges tables for a UUID starting with the
3237    /// given prefix, scoped to the caller's primary namespace only. Returns
3238    /// `Ok(Some(uuid))` if exactly one match is found, `Ok(None)` if no
3239    /// matches, or an error if ambiguous (multiple matches).
3240    pub async fn resolve_prefix(
3241        &self,
3242        token: &NamespaceToken,
3243        prefix: &str,
3244    ) -> RuntimeResult<Option<Uuid>> {
3245        let namespaces = [token.namespace().as_str().to_owned()];
3246        self.resolve_prefix_inner(Some(&namespaces), prefix, false)
3247            .await
3248    }
3249
3250    pub async fn resolve_prefix_including_deleted(
3251        &self,
3252        token: &NamespaceToken,
3253        prefix: &str,
3254    ) -> RuntimeResult<Option<Uuid>> {
3255        let namespaces = [token.namespace().as_str().to_owned()];
3256        self.resolve_prefix_inner(Some(&namespaces), prefix, true)
3257            .await
3258    }
3259
3260    /// Resolve a short UUID prefix (8+ hex chars) to a full UUID with NO
3261    /// namespace filter at all: mirrors `resolve_by_id`'s by-ID contract:
3262    /// by-ID resolution is namespace-agnostic, since the Gate (not
3263    /// storage-layer filtering) is the authz seam. Used by the four by-ID
3264    /// CRUD verbs (get/update/delete/merge) so their prefix path matches
3265    /// their already-unfiltered full-UUID path. No token param: unlike
3266    /// `resolve_prefix`, there is no namespace to derive from one.
3267    pub async fn resolve_prefix_unfiltered(&self, prefix: &str) -> RuntimeResult<Option<Uuid>> {
3268        self.resolve_prefix_inner(None, prefix, false).await
3269    }
3270
3271    /// `resolve_prefix_unfiltered`, including soft-deleted rows — used by the
3272    /// hard-delete by-ID path.
3273    pub async fn resolve_prefix_unfiltered_including_deleted(
3274        &self,
3275        prefix: &str,
3276    ) -> RuntimeResult<Option<Uuid>> {
3277        self.resolve_prefix_inner(None, prefix, true).await
3278    }
3279
3280    /// Shared prefix-scan implementation over an explicit namespace set.
3281    ///
3282    /// `namespaces` selects the scan scope: `Some(&[ns])` reproduces the
3283    /// historical primary-only behaviour (`resolve_prefix` /
3284    /// `resolve_prefix_including_deleted`); `None` applies
3285    /// no namespace predicate at all (`resolve_prefix_unfiltered*`).
3286    /// Ambiguity (a prefix matching more than one UUID, even across
3287    /// different namespaces in the set, or across all namespaces when
3288    /// unfiltered) is still an error: UUIDs are globally unique, so two
3289    /// distinct rows sharing a prefix always requires caller disambiguation —
3290    /// no cross-namespace dedup is needed or performed.
3291    async fn resolve_prefix_inner(
3292        &self,
3293        namespaces: Option<&[String]>,
3294        prefix: &str,
3295        include_deleted: bool,
3296    ) -> RuntimeResult<Option<Uuid>> {
3297        use khive_storage::types::{SqlStatement, SqlValue};
3298
3299        // Every caller is expected to pre-validate hex-only input, but this is
3300        // the single choke point every `resolve_prefix*` variant funnels
3301        // through, so re-validate here too. A prefix containing anything other
3302        // than hex digits and
3303        // canonical hyphen separators (`%`, `_`, or other LIKE-wildcard /
3304        // injection-shaped input) never matches a real id and is rejected
3305        // before it can reach the LIKE pattern, instead of relying on bound
3306        // params alone to neutralize wildcard semantics.
3307        if !prefix.chars().all(|c| c.is_ascii_hexdigit() || c == '-') {
3308            return Ok(None);
3309        }
3310
3311        let pattern = format!("{}%", hex_prefix_to_uuid_pattern(prefix));
3312
3313        let tables = [
3314            ("entities", true),
3315            ("notes", true),
3316            ("events", false),
3317            ("graph_edges", false),
3318        ];
3319
3320        let ns_clause = namespaces.map(|ns| {
3321            let placeholders: Vec<String> = (0..ns.len()).map(|i| format!("?{}", i + 2)).collect();
3322            format!(" AND namespace IN ({})", placeholders.join(", "))
3323        });
3324
3325        // A UUID can legitimately exist in more than one scanned table
3326        // (e.g. an entity id string that also happens to be an edge id — the
3327        // scan is purely a text-prefix LIKE across independent tables, not a
3328        // substrate-exclusive lookup). Without dedup, a single record hit
3329        // twice across tables inflated `matches.len()` past 1 and produced a
3330        // false `AmbiguousPrefix` naming the SAME UUID twice. `seen` tracks
3331        // UUIDs already pushed so `matches` (and thus every length check,
3332        // including the early-exit below) reflects DISTINCT UUIDs only.
3333        let mut matches: Vec<String> = Vec::new();
3334        let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
3335        let mut reader = self.sql().reader().await.map_err(RuntimeError::Storage)?;
3336
3337        for (table, has_deleted_at) in tables {
3338            let deleted_filter = if has_deleted_at && !include_deleted {
3339                " AND deleted_at IS NULL"
3340            } else {
3341                ""
3342            };
3343            let mut params = vec![SqlValue::Text(pattern.clone())];
3344            if let Some(ns) = namespaces {
3345                params.extend(ns.iter().map(|n| SqlValue::Text(n.clone())));
3346            }
3347            let sql = SqlStatement {
3348                sql: format!(
3349                    "SELECT id FROM {table} WHERE id LIKE ?1{ns_clause}{deleted_filter} LIMIT 2",
3350                    ns_clause = ns_clause.as_deref().unwrap_or("")
3351                ),
3352                params,
3353                label: Some("resolve_prefix".into()),
3354            };
3355            match reader.query_all(sql).await {
3356                Ok(rows) => {
3357                    for row in rows {
3358                        if let Some(col) = row.columns.first() {
3359                            if let SqlValue::Text(s) = &col.value {
3360                                if seen.insert(s.clone()) {
3361                                    matches.push(s.clone());
3362                                }
3363                            }
3364                        }
3365                    }
3366                }
3367                Err(e) => {
3368                    let msg = e.to_string();
3369                    if msg.contains("no such table") {
3370                        continue;
3371                    }
3372                    return Err(RuntimeError::Storage(e));
3373                }
3374            }
3375            if matches.len() > 1 {
3376                break;
3377            }
3378        }
3379
3380        match matches.len() {
3381            0 => Ok(None),
3382            1 => {
3383                let uuid = Uuid::from_str(&matches[0])
3384                    .map_err(|e| RuntimeError::Internal(format!("stored UUID is invalid: {e}")))?;
3385                Ok(Some(uuid))
3386            }
3387            _ => {
3388                let uuids: Vec<uuid::Uuid> = matches
3389                    .iter()
3390                    .filter_map(|s| Uuid::from_str(s).ok())
3391                    .collect();
3392                Err(RuntimeError::AmbiguousPrefix {
3393                    prefix: prefix.to_string(),
3394                    matches: uuids,
3395                })
3396            }
3397        }
3398    }
3399
3400    /// Resolve a UUID to its substrate kind with NO namespace filter.
3401    ///
3402    /// By-ID contract: UUID v4 is globally unique: by-ID substrate
3403    /// inference must return the record regardless of caller namespace.  Used by
3404    /// the public `update` and `delete` verb handlers when no explicit `kind` is
3405    /// supplied.
3406    ///
3407    /// Does NOT consult the visible set or the primary-namespace check.  The
3408    /// token is still required to route to the correct backend pool but its
3409    /// namespace value is not used as a filter.
3410    pub async fn resolve_by_id(
3411        &self,
3412        token: &NamespaceToken,
3413        id: Uuid,
3414    ) -> RuntimeResult<Option<Resolved>> {
3415        // Entity: direct by-UUID fetch (ID-only, no namespace check).
3416        if let Some(entity) = self.entities(token)?.get_entity(id).await? {
3417            return Ok(Some(Resolved::Entity(entity)));
3418        }
3419
3420        // Note: direct by-UUID fetch (ID-only).
3421        if let Some(note) = self.notes(token)?.get_note(id).await? {
3422            return Ok(Some(Resolved::Note(note)));
3423        }
3424
3425        // Edges and events are not returned here; the caller's `_` arm handles
3426        // those with a separate get_edge / get_event check.
3427        Ok(None)
3428    }
3429
3430    /// Resolve a UUID to its substrate kind with NO namespace filter, including
3431    /// soft-deleted rows.
3432    ///
3433    /// Used by the hard-delete path when no explicit `kind` is supplied, so
3434    /// already-soft-deleted records can still be located by UUID alone.
3435    pub async fn resolve_by_id_including_deleted(
3436        &self,
3437        token: &NamespaceToken,
3438        id: Uuid,
3439    ) -> RuntimeResult<Option<Resolved>> {
3440        // Entity: including soft-deleted, no namespace check.
3441        if let Some(entity) = self
3442            .entities(token)?
3443            .get_entity_including_deleted(id)
3444            .await?
3445        {
3446            return Ok(Some(Resolved::Entity(entity)));
3447        }
3448
3449        // Note: including soft-deleted, no namespace check.
3450        if let Some(note) = self.notes(token)?.get_note_including_deleted(id).await? {
3451            return Ok(Some(Resolved::Note(note)));
3452        }
3453
3454        // Edges and events are not returned here; the caller's `_` arm handles
3455        // those with a separate get_edge_including_deleted check.
3456        Ok(None)
3457    }
3458
3459    /// Resolve a UUID to its substrate kind by trying entity, then note, then event stores.
3460    ///
3461    /// Returns `None` if the UUID is not found in any substrate.
3462    /// Cost: at most 3 store lookups per call (cheap for v0.1).
3463    pub async fn resolve(
3464        &self,
3465        token: &NamespaceToken,
3466        id: Uuid,
3467    ) -> RuntimeResult<Option<Resolved>> {
3468        // Entity: use the namespace-checked getter (errors on mismatch/absent).
3469        match self.get_entity(token, id).await {
3470            Ok(entity) => return Ok(Some(Resolved::Entity(entity))),
3471            Err(RuntimeError::NotFound(_) | RuntimeError::NamespaceMismatch { .. }) => {}
3472            Err(e) => return Err(e),
3473        }
3474
3475        // Note: storage get_note is ID-only — verify against visible set.
3476        if let Some(note) = self.notes(token)?.get_note(id).await? {
3477            if Self::ensure_namespace_visible(&note.namespace, token).is_ok() {
3478                return Ok(Some(Resolved::Note(note)));
3479            }
3480        }
3481
3482        // Event: storage get_event is ID-only — verify against visible set.
3483        if let Some(event) = self.events(token)?.get_event(id).await? {
3484            if Self::ensure_namespace_visible(&event.namespace, token).is_ok() {
3485                return Ok(Some(Resolved::Event(event)));
3486            }
3487        }
3488
3489        Ok(None)
3490    }
3491
3492    /// Resolve a UUID to its substrate kind with NO namespace filter, for edge
3493    /// endpoint validation.
3494    ///
3495    /// `link` and `create`'s `annotates` targets consume by-ID endpoints, so
3496    /// their existence check must follow the same by-ID contract as `get()`:
3497    /// by-ID ops are namespace-agnostic: the Gate, not storage-layer
3498    /// filtering, is the authz seam. Mirrors `resolve_by_id`
3499    /// (entity + note, unfiltered) and additionally resolves events,
3500    /// unfiltered, so edge endpoint validation resolves exactly what `get()`
3501    /// resolves regardless of the caller's namespace.
3502    pub async fn resolve_edge_endpoint(
3503        &self,
3504        token: &NamespaceToken,
3505        id: Uuid,
3506    ) -> RuntimeResult<Option<Resolved>> {
3507        if let Some(resolved) = self.resolve_by_id(token, id).await? {
3508            return Ok(Some(resolved));
3509        }
3510        if let Some(event) = self.events(token)?.get_event(id).await? {
3511            return Ok(Some(Resolved::Event(event)));
3512        }
3513        Ok(None)
3514    }
3515
3516    /// Resolve a UUID to its substrate kind using primary-namespace-only enforcement.
3517    ///
3518    /// Unlike `resolve`, never consults the visible set. Use from GTD dependency
3519    /// validation paths where strict primary ownership is required.
3520    pub async fn resolve_primary(
3521        &self,
3522        token: &NamespaceToken,
3523        id: Uuid,
3524    ) -> RuntimeResult<Option<Resolved>> {
3525        let ns = token.namespace().as_str();
3526
3527        // Entity: primary-only check (exclude entities in visible-only namespaces).
3528        if let Some(entity) = self.entities(token)?.get_entity(id).await? {
3529            if Self::ensure_namespace(&entity.namespace, ns).is_ok() {
3530                return Ok(Some(Resolved::Entity(entity)));
3531            }
3532        }
3533
3534        // Note: primary-only check.
3535        if let Some(note) = self.notes(token)?.get_note(id).await? {
3536            if Self::ensure_namespace(&note.namespace, ns).is_ok() {
3537                return Ok(Some(Resolved::Note(note)));
3538            }
3539        }
3540
3541        // Event: primary-only check.
3542        if let Some(event) = self.events(token)?.get_event(id).await? {
3543            if Self::ensure_namespace(&event.namespace, ns).is_ok() {
3544                return Ok(Some(Resolved::Event(event)));
3545            }
3546        }
3547
3548        Ok(None)
3549    }
3550
3551    /// Resolve a UUID to its substrate kind, including soft-deleted rows.
3552    ///
3553    /// Used exclusively by the hard-delete path to locate records that have
3554    /// already been soft-deleted. Namespace isolation is still enforced.
3555    pub async fn resolve_including_deleted(
3556        &self,
3557        token: &NamespaceToken,
3558        id: Uuid,
3559    ) -> RuntimeResult<Option<Resolved>> {
3560        let ns = token.namespace().as_str();
3561
3562        if let Some(entity) = self
3563            .entities(token)?
3564            .get_entity_including_deleted(id)
3565            .await?
3566        {
3567            if Self::ensure_namespace(&entity.namespace, ns).is_ok() {
3568                return Ok(Some(Resolved::Entity(entity)));
3569            }
3570        }
3571
3572        if let Some(note) = self.notes(token)?.get_note_including_deleted(id).await? {
3573            if Self::ensure_namespace(&note.namespace, ns).is_ok() {
3574                return Ok(Some(Resolved::Note(note)));
3575            }
3576        }
3577
3578        if let Some(event) = self.events(token)?.get_event(id).await? {
3579            if Self::ensure_namespace(&event.namespace, ns).is_ok() {
3580                return Ok(Some(Resolved::Event(event)));
3581            }
3582        }
3583
3584        Ok(None)
3585    }
3586
3587    /// Hard-delete a single graph node (entity, note, or edge-as-node row)
3588    /// AND purge its incident edges in ONE write transaction.
3589    ///
3590    /// The endpoint row delete and the incident-edge cascade used
3591    /// to run as two independently-committing storage calls. A concurrent
3592    /// guarded write (`upsert_edge_guarded`/`upsert_edges_guarded`) landing
3593    /// between them could see the endpoint still live, insert a fresh edge
3594    /// against it, and then survive the cascade that already ran — a
3595    /// durably dangling edge with no second purge. Routing both statements
3596    /// through one [`run_atomic_unit`] call closes the window: since every
3597    /// write (this one and the guarded insert) funnels through the same
3598    /// single-writer queue, a concurrent guarded write either fully commits
3599    /// before this unit starts (and its edge is then swept by the purge
3600    /// below, in the same transaction as the row delete) or fully commits
3601    /// after this unit has already committed (and its own endpoint-existence
3602    /// check then sees the endpoint gone and refuses the write) — there is
3603    /// no state in which it can observe the endpoint alive with edges
3604    /// already purged.
3605    ///
3606    /// `row_statement` is the exact hard-delete `DELETE` for the target row
3607    /// (entity, note, or edge). Returns `Ok(true)` if the row was deleted,
3608    /// `Ok(false)` if it no longer existed (lost a race with a concurrent
3609    /// delete of the same row) — never an error for that case, matching the
3610    /// non-atomic bool-returning shape callers had before this fix.
3611    async fn atomic_hard_delete_with_edge_purge(
3612        &self,
3613        row_statement: SqlStatement,
3614        node_id: Uuid,
3615    ) -> RuntimeResult<bool> {
3616        let plan = AtomicOpPlan::Delete(DeletePlan {
3617            target_id: node_id,
3618            statements: vec![
3619                PlanStatement {
3620                    statement: row_statement,
3621                    guard: Some(AffectedRowGuard::exactly(1)),
3622                },
3623                PlanStatement {
3624                    statement: purge_incident_edges_statement(node_id),
3625                    guard: None,
3626                },
3627            ],
3628            post_commit: PostCommitEffect::None,
3629        });
3630        match run_atomic_unit(self.sql().as_ref(), vec![plan]).await {
3631            Ok(AtomicRunOutcome::Committed { .. }) => Ok(true),
3632            Ok(AtomicRunOutcome::RolledBack {
3633                failure: AtomicOpFailure::GuardFailed { .. },
3634                ..
3635            }) => Ok(false),
3636            Ok(AtomicRunOutcome::RolledBack {
3637                failure: AtomicOpFailure::SqlError { message, .. },
3638                ..
3639            }) => Err(RuntimeError::Internal(format!(
3640                "hard delete + edge purge for {node_id} failed: {message}"
3641            ))),
3642            Err(e) => Err(RuntimeError::Internal(format!(
3643                "hard delete + edge purge for {node_id}: atomic unit seam failure: {}",
3644                e.0
3645            ))),
3646        }
3647    }
3648
3649    /// Soft-delete or hard-delete a note by ID.
3650    ///
3651    /// On hard delete, cascades to remove all incident edges (both inbound and
3652    /// outbound) and cleans up FTS and vector indexes, preventing dangling
3653    /// references for `annotates` edges that target this note.
3654    /// Soft delete also cleans FTS and vector indexes; edges are left in place.
3655    ///
3656    /// UUID v4 is globally unique: no namespace filter on by-ID ops.
3657    /// Cascade and index cleanup target the RECORD's stored namespace, not the caller token's.
3658    /// Returns `Ok(false)` if the note does not exist.
3659    pub async fn delete_note(
3660        &self,
3661        token: &NamespaceToken,
3662        id: Uuid,
3663        hard: bool,
3664    ) -> RuntimeResult<bool> {
3665        let note_store = self.notes(token)?;
3666        let note = if hard {
3667            match note_store.get_note_including_deleted(id).await? {
3668                Some(n) => n,
3669                None => return Ok(false),
3670            }
3671        } else {
3672            match note_store.get_note(id).await? {
3673                Some(n) => n,
3674                None => return Ok(false),
3675            }
3676        };
3677        let mode = if hard {
3678            DeleteMode::Hard
3679        } else {
3680            DeleteMode::Soft
3681        };
3682
3683        // Route index cleanup through the RECORD's namespace, not the caller's.
3684        let record_tok = NamespaceToken::for_namespace(
3685            khive_types::Namespace::parse(&note.namespace)
3686                .map_err(|e| RuntimeError::Internal(format!("note namespace invalid: {e}")))?,
3687        );
3688        let record_ns = note.namespace.clone();
3689
3690        // On hard delete, the row delete and the incident-edge cascade (including
3691        // already-soft-deleted edges) run as ONE write transaction: see
3692        // `atomic_hard_delete_with_edge_purge`. Index cleanup follows the
3693        // commit; it is best-effort and idempotent, unlike the row/edge pair.
3694        let deleted = if hard {
3695            let deleted = self
3696                .atomic_hard_delete_with_edge_purge(note_hard_delete_statement(id), id)
3697                .await?;
3698            self.text_for_notes(&record_tok)?
3699                .delete_document(&record_ns, id)
3700                .await?;
3701            // Scoped delete: iterate over EVERY registered embedding model's
3702            // vector store so non-default vectors don't orphan when the note is deleted.
3703            for model_name in self.registered_embedding_model_names() {
3704                self.vectors_for_model(&record_tok, &model_name)?
3705                    .delete(id)
3706                    .await?;
3707            }
3708            deleted
3709        } else {
3710            let deleted = note_store.delete_note(id, mode).await?;
3711            if deleted {
3712                self.text_for_notes(&record_tok)?
3713                    .delete_document(&record_ns, id)
3714                    .await?;
3715                for model_name in self.registered_embedding_model_names() {
3716                    self.vectors_for_model(&record_tok, &model_name)?
3717                        .delete(id)
3718                        .await?;
3719                }
3720            }
3721            deleted
3722        };
3723        if deleted {
3724            let event_store = self.events(token)?;
3725            let event = khive_storage::event::Event::new(
3726                record_ns.clone(),
3727                "delete",
3728                EventKind::NoteDeleted,
3729                SubstrateKind::Note,
3730                "",
3731            )
3732            .with_target(id)
3733            .with_payload(serde_json::json!({"id": id, "namespace": record_ns, "hard": hard}));
3734            event_store.append_event(event).await.map_err(|e| {
3735                RuntimeError::Internal(format!("delete_note: event store write failed: {e}"))
3736            })?;
3737            // A soft OR hard delete removes the note's vectors/FTS document
3738            // above: any pack-owned vector-derived cache (e.g.
3739            // khive-pack-memory's warm ANN index) needs to know the corpus
3740            // changed, reached via this generic hook so khive-runtime never
3741            // takes a dependency on khive-pack-memory. No-op when no pack has
3742            // installed a hook.
3743            self.fire_note_mutation_hook(&note.kind, id).await;
3744        }
3745        Ok(deleted)
3746    }
3747
3748    /// Row-first compensating delete for rolling back a partially-written note
3749    /// (e.g. `dual_write_message` rollback after a later delivery step fails).
3750    /// Unlike [`KhiveRuntime::delete_note`], which cleans up graph/FTS/
3751    /// vector indexes *before* removing the row, this removes the row first so
3752    /// that a cleanup failure afterward cannot leave the compensated note live.
3753    ///
3754    /// Returns `Ok(())` once the row is gone (whether or not cleanup fully
3755    /// succeeded). Returns `Err(RuntimeError::Internal)` naming the failed
3756    /// cleanup legs when row removal succeeded but cleanup did not — the
3757    /// message is gone, but stale index entries may remain and should be
3758    /// surfaced to the caller rather than silently discarded.
3759    ///
3760    /// Returns `Ok(())` immediately, with no cleanup attempted, if the note
3761    /// does not exist (nothing to compensate).
3762    ///
3763    /// Not a general-purpose replacement for `delete_note(..., hard=true)`:
3764    /// normal hard delete still needs cleanup-first semantics (no dangling
3765    /// references) since a caller-visible error there should not remove the row.
3766    pub async fn delete_note_row_first_for_compensation(
3767        &self,
3768        token: &NamespaceToken,
3769        id: Uuid,
3770    ) -> RuntimeResult<()> {
3771        let note_store = self.notes(token)?;
3772        let Some(note) = note_store.get_note_including_deleted(id).await? else {
3773            return Ok(());
3774        };
3775        let record_tok = NamespaceToken::for_namespace(
3776            khive_types::Namespace::parse(&note.namespace)
3777                .map_err(|e| RuntimeError::Internal(format!("note namespace invalid: {e}")))?,
3778        );
3779        let record_ns = note.namespace.clone();
3780
3781        // Critical ordering: remove the row before any cleanup that can fail.
3782        note_store.delete_note(id, DeleteMode::Hard).await?;
3783
3784        #[cfg(any(test, feature = "fault-injection"))]
3785        {
3786            let armed = ROLLBACK_CLEANUP_FAIL_NS.lock().unwrap().take();
3787            if armed.as_deref() == Some(record_ns.as_str()) {
3788                return Err(RuntimeError::Internal(
3789                    "row removed but compensation cleanup failed: injected=true".to_string(),
3790                ));
3791            }
3792        }
3793
3794        let mut cleanup_errors = Vec::new();
3795        if let Err(e) = self.graph(&record_tok)?.purge_incident_edges(id).await {
3796            cleanup_errors.push(format!("graph={e}"));
3797        }
3798        if let Err(e) = self
3799            .text_for_notes(&record_tok)?
3800            .delete_document(&record_ns, id)
3801            .await
3802        {
3803            cleanup_errors.push(format!("fts={e}"));
3804        }
3805        for model_name in self.registered_embedding_model_names() {
3806            if let Err(e) = self
3807                .vectors_for_model(&record_tok, &model_name)?
3808                .delete(id)
3809                .await
3810            {
3811                cleanup_errors.push(format!("vector[{model_name}]={e}"));
3812            }
3813        }
3814        if cleanup_errors.is_empty() {
3815            Ok(())
3816        } else {
3817            Err(RuntimeError::Internal(format!(
3818                "row removed but compensation cleanup failed: {}",
3819                cleanup_errors.join("; ")
3820            )))
3821        }
3822    }
3823}
3824
3825/// Result of a GQL/SPARQL query with optional validation warnings.
3826#[derive(Clone, Debug, Serialize)]
3827pub struct QueryResult {
3828    pub rows: Vec<SqlRow>,
3829    #[serde(skip_serializing_if = "Vec::is_empty")]
3830    pub warnings: Vec<String>,
3831}
3832
3833impl KhiveRuntime {
3834    // ---- Query operations ----
3835
3836    /// Execute a GQL or SPARQL query string, returning raw SQL rows.
3837    ///
3838    /// The query is compiled to SQL with the namespace scope applied.
3839    /// GQL syntax: `MATCH (a:concept)-[e:extends]->(b) RETURN a, b LIMIT 10`
3840    /// SPARQL syntax: `SELECT ?a WHERE { ?a :kind "concept" . }`
3841    pub async fn query(&self, token: &NamespaceToken, query: &str) -> RuntimeResult<Vec<SqlRow>> {
3842        Ok(self
3843            .query_with_metadata(token, query, khive_query::CompileOptions::default())
3844            .await?
3845            .rows)
3846    }
3847
3848    /// Execute a GQL/SPARQL query, returning rows and any validation warnings.
3849    pub async fn query_with_metadata(
3850        &self,
3851        token: &NamespaceToken,
3852        query: &str,
3853        mut opts: khive_query::CompileOptions,
3854    ) -> RuntimeResult<QueryResult> {
3855        use khive_query::QueryValue;
3856        use khive_storage::types::SqlValue;
3857
3858        let ast = khive_query::parse_auto(query)?;
3859        opts.scopes = token
3860            .visible_namespaces()
3861            .iter()
3862            .map(|ns| ns.as_str().to_string())
3863            .collect();
3864        let compiled = khive_query::compile(&ast, &opts)?;
3865        let mut warnings = compiled.warnings;
3866        let truncation_check = compiled.truncation_check;
3867
3868        // Convert QueryValue params (query-layer type) to SqlValue (storage-layer type)
3869        // at the query–storage boundary.
3870        let params: Vec<SqlValue> = compiled
3871            .params
3872            .into_iter()
3873            .map(|qv| match qv {
3874                QueryValue::Null => SqlValue::Null,
3875                QueryValue::Integer(n) => SqlValue::Integer(n),
3876                QueryValue::Float(f) => SqlValue::Float(f),
3877                QueryValue::Text(s) => SqlValue::Text(s),
3878                QueryValue::Blob(b) => SqlValue::Blob(b),
3879            })
3880            .collect();
3881
3882        let mut reader = self.sql().reader().await?;
3883        let stmt = SqlStatement {
3884            sql: compiled.sql,
3885            params,
3886            label: None,
3887        };
3888        let mut rows = reader.query_all(stmt).await?;
3889
3890        // When the server-side cap was the binding constraint, the compiled
3891        // SQL asked for one extra (sentinel) row. Its presence in the actual
3892        // result set — not the requested LIMIT — is the truncation signal
3893        // (a `LIMIT 1000` that only matches 20 rows must not warn, and a
3894        // query with no `LIMIT` that matches 501+ rows must).
3895        if let Some(check) = truncation_check {
3896            if rows.len() > check.max_limit {
3897                rows.truncate(check.max_limit);
3898                warnings.push(match check.requested_limit {
3899                    Some(requested) => format!(
3900                        "result set capped at {} rows; requested limit {requested} exceeds the \
3901                         cap — use LIMIT/OFFSET to page through the remaining results",
3902                        check.max_limit
3903                    ),
3904                    None => format!(
3905                        "result set capped at {} rows; more than {} rows matched with no LIMIT \
3906                         clause — use LIMIT/OFFSET to page through the remaining results",
3907                        check.max_limit, check.max_limit
3908                    ),
3909                });
3910            }
3911        }
3912
3913        Ok(QueryResult { rows, warnings })
3914    }
3915
3916    /// Soft-delete or hard-delete an entity by ID (soft delete by default).
3917    ///
3918    /// On hard delete, cascades to remove all incident edges (both inbound and
3919    /// outbound) to prevent dangling references. Soft delete also cleans FTS
3920    /// and vector indexes; edges are left in place.
3921    ///
3922    /// UUID v4 is globally unique: no namespace filter on by-ID ops.
3923    pub async fn delete_entity(
3924        &self,
3925        token: &NamespaceToken,
3926        id: Uuid,
3927        hard: bool,
3928    ) -> RuntimeResult<bool> {
3929        let entity = if hard {
3930            match self
3931                .entities(token)?
3932                .get_entity_including_deleted(id)
3933                .await?
3934            {
3935                Some(e) => e,
3936                None => return Ok(false),
3937            }
3938        } else {
3939            match self.entities(token)?.get_entity(id).await? {
3940                Some(e) => e,
3941                None => return Ok(false),
3942            }
3943        };
3944        let mode = if hard {
3945            DeleteMode::Hard
3946        } else {
3947            DeleteMode::Soft
3948        };
3949
3950        // Route cascade and index cleanup through the RECORD's namespace, not the caller's.
3951        let record_tok = NamespaceToken::for_namespace(
3952            khive_types::Namespace::parse(&entity.namespace)
3953                .map_err(|e| RuntimeError::Internal(format!("entity namespace invalid: {e}")))?,
3954        );
3955
3956        // On hard delete, the row delete and the incident-edge cascade (including
3957        // already-soft-deleted edges) run as ONE write transaction: see
3958        // `atomic_hard_delete_with_edge_purge`. Index cleanup follows the
3959        // commit; it is best-effort and idempotent, unlike the row/edge pair.
3960        let deleted = if hard {
3961            let deleted = self
3962                .atomic_hard_delete_with_edge_purge(entity_hard_delete_statement(id), id)
3963                .await?;
3964            self.remove_from_indexes(&record_tok, id).await?;
3965            deleted
3966        } else {
3967            let deleted = self.entities(token)?.delete_entity(id, mode).await?;
3968            if deleted {
3969                self.remove_from_indexes(&record_tok, id).await?;
3970            }
3971            deleted
3972        };
3973        if deleted {
3974            let event_store = self.events(token)?;
3975            let ns = entity.namespace.clone();
3976            let event = khive_storage::event::Event::new(
3977                ns.clone(),
3978                "delete",
3979                EventKind::EntityDeleted,
3980                SubstrateKind::Entity,
3981                "",
3982            )
3983            .with_target(id)
3984            .with_payload(serde_json::json!({"id": id, "namespace": ns, "hard": hard}));
3985            event_store.append_event(event).await.map_err(|e| {
3986                RuntimeError::Internal(format!("delete_entity: event store write failed: {e}"))
3987            })?;
3988        }
3989        Ok(deleted)
3990    }
3991
3992    /// Count entities in a namespace, optionally filtered.
3993    pub async fn count_entities(
3994        &self,
3995        token: &NamespaceToken,
3996        kind: Option<&str>,
3997    ) -> RuntimeResult<u64> {
3998        let ns_strs: Vec<String> = token
3999            .visible_namespaces()
4000            .iter()
4001            .map(|ns| ns.as_str().to_owned())
4002            .collect();
4003        let filter = EntityFilter {
4004            kinds: match kind {
4005                Some(k) => vec![k.to_string()],
4006                None => vec![],
4007            },
4008            namespaces: ns_strs,
4009            ..Default::default()
4010        };
4011        Ok(self
4012            .entities(token)?
4013            .count_entities(token.namespace().as_str(), filter)
4014            .await?)
4015    }
4016
4017    // ---- Edge CRUD operations ----
4018
4019    /// Fetch a single edge by id.
4020    ///
4021    /// UUID v4 is globally unique: returns the edge regardless of which
4022    /// namespace the token carries. `Ok(None)` means the edge does not exist at all.
4023    pub async fn get_edge(
4024        &self,
4025        _token: &NamespaceToken,
4026        edge_id: Uuid,
4027    ) -> RuntimeResult<Option<Edge>> {
4028        let mut reader = self.sql().reader().await?;
4029        let record_ns = reader
4030            .query_scalar(SqlStatement {
4031                sql: "SELECT namespace FROM graph_edges \
4032                      WHERE id = ?1 AND deleted_at IS NULL LIMIT 1"
4033                    .into(),
4034                params: vec![SqlValue::Text(edge_id.to_string())],
4035                label: Some("get_edge_namespace".into()),
4036            })
4037            .await?;
4038
4039        let Some(SqlValue::Text(record_ns)) = record_ns else {
4040            return Ok(None);
4041        };
4042        // Route the storage fetch through the record's own namespace — the token is
4043        // just the caller context; by-ID ops cross namespace boundaries.
4044        let record_tok = NamespaceToken::for_namespace(
4045            khive_types::Namespace::parse(&record_ns)
4046                .map_err(|e| RuntimeError::Internal(format!("edge namespace invalid: {e}")))?,
4047        );
4048        Ok(self
4049            .graph(&record_tok)?
4050            .get_edge(LinkId::from(edge_id))
4051            .await?)
4052    }
4053
4054    /// Fetch a single edge by id.
4055    ///
4056    /// Delegates to `get_edge`: no visible-set check.  By-ID ops are
4057    /// namespace-agnostic; UUID v4 is globally unique.
4058    pub async fn get_edge_visible(
4059        &self,
4060        token: &NamespaceToken,
4061        edge_id: Uuid,
4062    ) -> RuntimeResult<Option<Edge>> {
4063        self.get_edge(token, edge_id).await
4064    }
4065
4066    /// Fetch an edge by UUID including soft-deleted rows.
4067    ///
4068    /// Returns the edge regardless of which namespace the token carries:
4069    /// UUID v4 is globally unique. Used by the hard-delete path so that a
4070    /// soft-deleted edge can still be purged via its edge ID.
4071    pub async fn get_edge_including_deleted(
4072        &self,
4073        _token: &NamespaceToken,
4074        edge_id: Uuid,
4075    ) -> RuntimeResult<Option<Edge>> {
4076        let mut reader = self.sql().reader().await?;
4077        let record_ns = reader
4078            .query_scalar(SqlStatement {
4079                sql: "SELECT namespace FROM graph_edges WHERE id = ?1 LIMIT 1".into(),
4080                params: vec![SqlValue::Text(edge_id.to_string())],
4081                label: Some("get_edge_including_deleted_namespace".into()),
4082            })
4083            .await?;
4084
4085        let Some(SqlValue::Text(record_ns)) = record_ns else {
4086            return Ok(None);
4087        };
4088        // Route through the record's own namespace store (no namespace equality check).
4089        let record_tok = NamespaceToken::for_namespace(
4090            khive_types::Namespace::parse(&record_ns)
4091                .map_err(|e| RuntimeError::Internal(format!("edge namespace invalid: {e}")))?,
4092        );
4093        Ok(self
4094            .graph(&record_tok)?
4095            .get_edge_including_deleted(LinkId::from(edge_id))
4096            .await?)
4097    }
4098
4099    /// Maximum rows returned by a single [`Self::list_edges`] /
4100    /// [`Self::list_edges_after`] page. A lower bound the docs promise callers
4101    /// can rely on; kept as a named constant so tests can exercise pagination
4102    /// (page tiling, out-of-range offsets) without needing >1000 real rows.
4103    pub const EDGE_LIST_MAX_LIMIT: u32 = 1000;
4104
4105    /// List edges matching `filter`, paging by `offset`. `limit` is capped at
4106    /// [`Self::EDGE_LIST_MAX_LIMIT`]; defaults to 100.
4107    ///
4108    /// `offset` pages through the full matching set (previously hard-coded to
4109    /// 0, so every page returned the same first rows). For
4110    /// O(1)-at-depth walks over large edge populations, prefer
4111    /// [`Self::list_edges_after`] instead of paging offset deep.
4112    pub async fn list_edges(
4113        &self,
4114        token: &NamespaceToken,
4115        filter: crate::curation::EdgeListFilter,
4116        limit: u32,
4117        offset: u32,
4118    ) -> RuntimeResult<Vec<Edge>> {
4119        let limit = limit.clamp(1, Self::EDGE_LIST_MAX_LIMIT);
4120        let visible = token.visible_namespaces();
4121
4122        // Common case: a single visible namespace — page directly against the
4123        // store so `offset`/`limit` reach SQL unmodified.
4124        if let [ns] = visible {
4125            let temp = NamespaceToken::for_namespace(ns.clone());
4126            let page = self
4127                .graph(&temp)?
4128                .query_edges(
4129                    filter.into(),
4130                    vec![SortOrder {
4131                        field: EdgeSortField::CreatedAt,
4132                        direction: khive_storage::types::SortDirection::Asc,
4133                    }],
4134                    PageRequest {
4135                        offset: offset.into(),
4136                        limit,
4137                    },
4138                )
4139                .await?;
4140            return Ok(page.items);
4141        }
4142
4143        // Multi-namespace visibility: `offset` must apply to the combined,
4144        // deduplicated set rather than per-namespace pages, so fetch enough
4145        // of each namespace's page to cover it, merge, then slice.
4146        let fetch_limit = offset.saturating_add(limit);
4147        let mut results = Vec::new();
4148        for ns in visible {
4149            let temp = NamespaceToken::for_namespace(ns.clone());
4150            let page = self
4151                .graph(&temp)?
4152                .query_edges(
4153                    filter.clone().into(),
4154                    vec![SortOrder {
4155                        field: EdgeSortField::CreatedAt,
4156                        direction: khive_storage::types::SortDirection::Asc,
4157                    }],
4158                    PageRequest {
4159                        offset: 0,
4160                        limit: fetch_limit,
4161                    },
4162                )
4163                .await?;
4164            results.extend(page.items);
4165        }
4166        results.sort_by_key(|e| Uuid::from(e.id));
4167        results.dedup_by_key(|e| Uuid::from(e.id));
4168        let start = (offset as usize).min(results.len());
4169        let end = (start + limit as usize).min(results.len());
4170        Ok(results[start..end].to_vec())
4171    }
4172
4173    /// Keyset (seek) page of edges matching `filter`, ordered by edge `id`
4174    /// ascending. `after` is the last edge id from the previous page
4175    /// (exclusive); omit to start from the beginning. Returns
4176    /// `(items, next_after)` — `next_after` is `Some` when more rows remain
4177    /// past this page.
4178    ///
4179    /// Unlike [`Self::list_edges`], this is O(log n + limit) at any depth: the
4180    /// underlying store issues an indexed `id > ?` range scan instead of an
4181    /// `OFFSET` skip, avoiding the O(offset) daemon CPU cost of a naive
4182    /// offset-based paging loop over a large edge population.
4183    pub async fn list_edges_after(
4184        &self,
4185        token: &NamespaceToken,
4186        filter: crate::curation::EdgeListFilter,
4187        after: Option<Uuid>,
4188        limit: u32,
4189    ) -> RuntimeResult<(Vec<Edge>, Option<Uuid>)> {
4190        let limit = limit.clamp(1, Self::EDGE_LIST_MAX_LIMIT);
4191        let visible = token.visible_namespaces();
4192        let limit_usize = limit as usize;
4193
4194        if let [ns] = visible {
4195            let temp = NamespaceToken::for_namespace(ns.clone());
4196            let page = self
4197                .graph(&temp)?
4198                .query_edges_after(filter.into(), after, limit)
4199                .await?;
4200            return Ok((page.items, page.next_after));
4201        }
4202
4203        // Multi-namespace visibility: seek each namespace from the same
4204        // cursor (ids are globally unique UUIDs), merge, then take the head
4205        // of the merged set as this page.
4206        let probe_limit = limit + 1;
4207        let mut results = Vec::new();
4208        for ns in visible {
4209            let temp = NamespaceToken::for_namespace(ns.clone());
4210            let page = self
4211                .graph(&temp)?
4212                .query_edges_after(filter.clone().into(), after, probe_limit)
4213                .await?;
4214            results.extend(page.items);
4215        }
4216        results.sort_by_key(|e| Uuid::from(e.id));
4217        results.dedup_by_key(|e| Uuid::from(e.id));
4218        let has_more = results.len() > limit_usize;
4219        if has_more {
4220            results.truncate(limit_usize);
4221        }
4222        let next_after = if has_more {
4223            results.last().map(|e| Uuid::from(e.id))
4224        } else {
4225            None
4226        };
4227        Ok((results, next_after))
4228    }
4229
4230    /// Count edges by relation, ignoring soft-deleted rows. Used by
4231    /// `stats()` to report the true per-relation population so full-graph
4232    /// audits know what they're sampling from before they walk it.
4233    pub async fn count_edges_by_relation(
4234        &self,
4235        token: &NamespaceToken,
4236    ) -> RuntimeResult<std::collections::HashMap<String, u64>> {
4237        let mut totals: std::collections::HashMap<String, u64> = std::collections::HashMap::new();
4238        for ns in token.visible_namespaces() {
4239            let temp = NamespaceToken::for_namespace(ns.clone());
4240            for (relation, count) in self.graph(&temp)?.count_edges_by_relation().await? {
4241                *totals.entry(relation.to_string()).or_insert(0) += count;
4242            }
4243        }
4244        Ok(totals)
4245    }
4246
4247    /// DML-only body of the symmetric-relation conflict-resolution path in
4248    /// [`Self::update_edge`]. Runs the conflict-check SELECT, then either the
4249    /// DELETE+UPDATE (case b, a canonical row already exists) or the
4250    /// in-place UPDATE (case a, no conflict). Callers own the surrounding transaction
4251    /// boundary — this function issues DML only, no `BEGIN`/`COMMIT`/`ROLLBACK`.
4252    ///
4253    /// Returns `Ok(Some(existing_id))` when a canonical conflict was absorbed (the
4254    /// requested edge was deleted, the existing canonical row refreshed), or
4255    /// `Ok(None)` when the requested edge was updated in place.
4256    ///
4257    /// DML text is the single source of truth shared with the atomic
4258    /// `prepare_update_edge` symmetric branch:
4259    /// [`khive_db::stores::graph::EDGE_SYMMETRIC_CONFLICT_PROBE_SQL`] /
4260    /// `EDGE_SYMMETRIC_DELETE_NONCANONICAL_SQL` /
4261    /// `EDGE_SYMMETRIC_REFRESH_CANONICAL_SQL` /
4262    /// `EDGE_SYMMETRIC_UPDATE_INPLACE_SQL` — this function binds them against
4263    /// `rusqlite::params!` (it runs inside an existing transaction on a
4264    /// borrowed `&rusqlite::Connection`), the atomic path binds the same text
4265    /// via `SqlValue` plan params; see the constants' doc comment in
4266    /// `khive-db` for why a single bridge type isn't used for both.
4267    #[allow(clippy::too_many_arguments)]
4268    fn update_edge_symmetric_dml(
4269        conn: &rusqlite::Connection,
4270        ns: &str,
4271        edge_id_str: &str,
4272        canon_src_str: &str,
4273        canon_tgt_str: &str,
4274        relation_str: &str,
4275        weight: f64,
4276        metadata: Option<String>,
4277        target_backend: Option<String>,
4278    ) -> Result<Option<String>, SqliteError> {
4279        // `updated_at` is stored in MICROSECONDS on `graph_edges` (every other
4280        // write path — `edge_upsert_statement`, `edge_soft_delete_statement` —
4281        // uses `timestamp_micros()`; the column is read back via
4282        // `micros_to_datetime`). `timestamp()` (seconds) here was a
4283        // pre-existing bug in this raw-SQL path, found while unifying it with
4284        // the atomic builder (which already used `timestamp_micros()`
4285        // correctly).
4286        let now_ts = chrono::Utc::now().timestamp_micros();
4287
4288        // Check for a conflicting canonical row (same namespace + natural key,
4289        // different id). This catches conflicts whether or not endpoints were flipped.
4290        let conflict_id: Option<String> = conn
4291            .query_row(
4292                khive_db::stores::graph::EDGE_SYMMETRIC_CONFLICT_PROBE_SQL,
4293                rusqlite::params![
4294                    &ns,
4295                    &canon_src_str,
4296                    &canon_tgt_str,
4297                    &relation_str,
4298                    &edge_id_str
4299                ],
4300                |row| row.get(0),
4301            )
4302            .optional()
4303            .map_err(SqliteError::Rusqlite)?;
4304
4305        if let Some(existing_id) = conflict_id {
4306            // Case (b): canonical row already exists — delete the non-canonical
4307            // edge and refresh the existing canonical row. Return the surviving
4308            // id so the caller can re-fetch it (never the deleted edge's id).
4309            conn.execute(
4310                khive_db::stores::graph::EDGE_SYMMETRIC_DELETE_NONCANONICAL_SQL,
4311                rusqlite::params![&ns, &edge_id_str],
4312            )
4313            .map_err(SqliteError::Rusqlite)?;
4314            let affected = conn
4315                .execute(
4316                    khive_db::stores::graph::EDGE_SYMMETRIC_REFRESH_CANONICAL_SQL,
4317                    rusqlite::params![weight, now_ts, target_backend, metadata, &ns, &existing_id],
4318                )
4319                .map_err(SqliteError::Rusqlite)?;
4320            if affected == 0 {
4321                return Err(SqliteError::InvalidData(format!(
4322                    "update_edge: surviving canonical row {existing_id} vanished during update"
4323                )));
4324            }
4325            Ok(Some(existing_id))
4326        } else {
4327            // Case (a): no conflict — update source_id/target_id in-place,
4328            // preserving the original edge UUID.
4329            let affected = conn
4330                .execute(
4331                    khive_db::stores::graph::EDGE_SYMMETRIC_UPDATE_INPLACE_SQL,
4332                    rusqlite::params![
4333                        &canon_src_str,
4334                        &canon_tgt_str,
4335                        &relation_str,
4336                        weight,
4337                        now_ts,
4338                        metadata,
4339                        &ns,
4340                        &edge_id_str,
4341                    ],
4342                )
4343                .map_err(SqliteError::Rusqlite)?;
4344            if affected == 0 {
4345                // The edge row was not found under the record's namespace.
4346                // This must never happen because ns = record_ns (fetched above).
4347                return Err(SqliteError::InvalidData(format!(
4348                    "update_edge: zero rows affected updating edge {edge_id_str} \
4349                     in namespace {ns} — row vanished between fetch and update"
4350                )));
4351            }
4352            Ok(None)
4353        }
4354    }
4355
4356    /// Patch-style edge update. Only `Some(_)` fields are applied.
4357    ///
4358    /// When `relation` is `Some(new_rel)`, validates that the edge's existing endpoints
4359    /// are legal for `new_rel` before persisting. Weight-only updates (`relation = None`)
4360    /// skip validation. Returns `InvalidInput` if the new relation would violate the
4361    /// three-case endpoint contract; the edge is NOT mutated on error.
4362    ///
4363    /// For symmetric relations (`competes_with`, `composed_with`), endpoint order is
4364    /// canonicalised to `source_uuid < target_uuid` after validation. If a canonical
4365    /// row already exists at the target triple, the non-canonical edge is deleted and
4366    /// the existing canonical row is refreshed (DELETE + UPDATE pattern, mirroring
4367    /// `merge_entity_sql`).
4368    pub async fn update_edge(
4369        &self,
4370        token: &NamespaceToken,
4371        edge_id: Uuid,
4372        patch: crate::curation::EdgePatch,
4373    ) -> RuntimeResult<Edge> {
4374        // Fetch the edge by UUID: ID-only, no namespace check.
4375        // get_edge already uses the record's stored namespace internally.
4376        let graph_for_fetch = self.graph(token)?;
4377        let mut edge = graph_for_fetch
4378            .get_edge(LinkId::from(edge_id))
4379            .await?
4380            .ok_or_else(|| crate::RuntimeError::NotFound(format!("edge {edge_id}")))?;
4381
4382        // After fetching, all mutations and validation must use the
4383        // RECORD's namespace, not the caller's.  Derive record_tok from the stored edge
4384        // namespace so that endpoint validation, raw-SQL predicates, and graph routing
4385        // all address the correct backend partition.
4386        let record_ns: String = edge.namespace.clone();
4387        let record_tok = NamespaceToken::for_namespace(
4388            khive_types::Namespace::parse(&record_ns)
4389                .map_err(|e| RuntimeError::Internal(format!("edge namespace invalid: {e}")))?,
4390        );
4391        let graph = self.graph(&record_tok)?;
4392
4393        let mut changed_fields: Vec<&'static str> = Vec::new();
4394        if let Some(r) = patch.relation {
4395            // Validate before mutating — use the existing endpoints with the new relation.
4396            // Use record_tok so that endpoint existence checks look in the edge's own namespace.
4397            self.validate_edge_relation_endpoints(&record_tok, edge.source_id, edge.target_id, r)
4398                .await?;
4399            edge.relation = r;
4400            changed_fields.push("relation");
4401        }
4402        if let Some(w) = patch.weight {
4403            // Reject non-finite or out-of-range weight explicitly; do not silently
4404            // clamp invalid caller input (coding-standards §608-622).
4405            if !w.is_finite() || !(0.0..=1.0).contains(&w) {
4406                return Err(RuntimeError::InvalidInput(format!(
4407                    "edge weight must be a finite value in [0.0, 1.0]; got {w}"
4408                )));
4409            }
4410            edge.weight = w;
4411            changed_fields.push("weight");
4412        }
4413        if let Some(props) = patch.properties {
4414            edge.metadata = Some(props);
4415        }
4416
4417        // For symmetric relations, canonicalise endpoint order and check
4418        // for natural-key conflicts regardless of whether endpoints were flipped.
4419        //
4420        // The raw-SQL path is used for ALL symmetric relations because `upsert_edge`
4421        // resolves ON CONFLICT(namespace,id) first and cannot detect a duplicate at
4422        // the natural key (namespace, source_id, target_id, relation) with a different
4423        // id. Bug-fix: this path must also run when endpoints are already canonical
4424        // (endpoints_flipped=false) to catch conflicts arising from a relation change
4425        // that collides with an existing canonical row.
4426        let (canon_src, canon_tgt) =
4427            canonical_edge_endpoints(edge.relation, edge.source_id, edge.target_id);
4428
4429        if edge.relation.is_symmetric() {
4430            // Raw-SQL path (mirrors merge_entity_sql).
4431            // Use record_ns (the stored edge namespace) — NOT token.namespace() — so that
4432            // WHERE namespace = ?N predicates match the actual row.
4433            let ns = record_ns.clone();
4434            let edge_id_str = edge_id.to_string();
4435            let relation_str = edge.relation.to_string();
4436            let canon_src_str = canon_src.to_string();
4437            let canon_tgt_str = canon_tgt.to_string();
4438            let weight = edge.weight;
4439            let metadata = edge
4440                .metadata
4441                .as_ref()
4442                .map(|v| serde_json::to_string(v).unwrap_or_default());
4443            let target_backend = edge.target_backend.clone();
4444
4445            let pool = self.backend().pool_arc();
4446            // Route through the single-writer task when the write queue is
4447            // enabled; best-effort lookup degrades to the legacy pool-mutex
4448            // path (mirrors merge_entity/merge_note above).
4449            let writer_task = pool.writer_task_handle().ok().flatten();
4450
4451            // Some(surviving_id) when a canonical conflict was absorbed (the requested
4452            // edge was deleted, existing canonical row refreshed), or None when the
4453            // requested edge was updated in-place.
4454            let surviving_id: Option<String> = if let Some(writer_task) = writer_task {
4455                writer_task
4456                    .send(move |conn| {
4457                        Self::update_edge_symmetric_dml(
4458                            conn,
4459                            &ns,
4460                            &edge_id_str,
4461                            &canon_src_str,
4462                            &canon_tgt_str,
4463                            &relation_str,
4464                            weight,
4465                            metadata,
4466                            target_backend,
4467                        )
4468                        .map_err(|e| {
4469                            khive_storage::StorageError::driver(
4470                                khive_storage::StorageCapability::Graph,
4471                                "update_edge",
4472                                e,
4473                            )
4474                        })
4475                    })
4476                    .await
4477                    .map_err(RuntimeError::Storage)?
4478            } else {
4479                tokio::task::spawn_blocking(move || {
4480                    let guard = pool.writer()?;
4481                    guard.transaction(|conn| {
4482                        Self::update_edge_symmetric_dml(
4483                            conn,
4484                            &ns,
4485                            &edge_id_str,
4486                            &canon_src_str,
4487                            &canon_tgt_str,
4488                            &relation_str,
4489                            weight,
4490                            metadata,
4491                            target_backend,
4492                        )
4493                    })
4494                })
4495                .await
4496                .map_err(|e| {
4497                    RuntimeError::Internal(format!("update_edge: spawn_blocking join: {e}"))
4498                })?
4499                .map_err(RuntimeError::Sqlite)?
4500            };
4501
4502            if let Some(sid) = surviving_id {
4503                // A conflict was absorbed: re-fetch the surviving canonical row so the
4504                // caller receives its real id.
4505                // Use record_tok — the surviving row lives in the same namespace as the original.
4506                let surviving_uuid = Uuid::parse_str(&sid).map_err(|e| {
4507                    RuntimeError::Internal(format!("update_edge: surviving id parse failed: {e}"))
4508                })?;
4509                edge = self
4510                    .get_edge(&record_tok, surviving_uuid)
4511                    .await?
4512                    .ok_or_else(|| {
4513                        RuntimeError::Internal(format!(
4514                            "update_edge: surviving canonical row {surviving_uuid} vanished after update"
4515                        ))
4516                    })?;
4517            } else {
4518                // Reflect canonical endpoints in the returned edge (no conflict absorbed).
4519                edge.source_id = canon_src;
4520                edge.target_id = canon_tgt;
4521            }
4522        } else {
4523            // Non-symmetric: upsert_edge takes namespace from edge.namespace (not from the
4524            // graph store's routing namespace), so this is already record-namespace correct.
4525            // `graph` is already self.graph(&record_tok)?.
4526            graph.upsert_edge(edge.clone()).await?;
4527        }
4528
4529        // Audit event: use the record's namespace (record_ns) for the event payload.
4530        let event_store = self.events(&record_tok)?;
4531        let event = khive_storage::event::Event::new(
4532            record_ns.clone(),
4533            "update",
4534            EventKind::EdgeUpdated,
4535            SubstrateKind::Entity,
4536            "",
4537        )
4538        .with_target(edge_id)
4539        .with_payload(
4540            serde_json::json!({"id": edge_id, "namespace": record_ns, "changed_fields": changed_fields}),
4541        );
4542        event_store.append_event(event).await.map_err(|e| {
4543            RuntimeError::Internal(format!("update_edge: event store write failed: {e}"))
4544        })?;
4545
4546        Ok(edge)
4547    }
4548
4549    /// Hard-delete an edge by id.
4550    ///
4551    /// Cascades to remove any `annotates` edges whose target is the deleted edge
4552    /// (`annotates` is note → anything; deleting an edge target leaves annotation
4553    /// edges dangling if not cleaned up). Returns `true` if the primary
4554    /// edge was removed.
4555    ///
4556    /// If `edge_id` does not refer to an edge (e.g. the caller passes an entity or
4557    /// note UUID by mistake), this method returns `Ok(false)` immediately with no
4558    /// side effects — it does **not** cascade inbound edges of the non-edge record.
4559    pub async fn delete_edge(
4560        &self,
4561        token: &NamespaceToken,
4562        edge_id: Uuid,
4563        hard: bool,
4564    ) -> RuntimeResult<bool> {
4565        let mode = if hard {
4566            DeleteMode::Hard
4567        } else {
4568            DeleteMode::Soft
4569        };
4570
4571        // Fetch the edge first to obtain the record's own namespace.
4572        // By-ID ops cross namespace boundaries; all graph routing and audit
4573        // events must use the record namespace, not the caller's (mirrors update_edge).
4574        // For hard delete we also check soft-deleted rows so a soft-deleted edge
4575        // can still be purged via its edge ID.
4576        let edge = if hard {
4577            self.get_edge_including_deleted(token, edge_id).await?
4578        } else {
4579            self.get_edge(token, edge_id).await?
4580        };
4581        let Some(edge) = edge else {
4582            return Ok(false);
4583        };
4584
4585        // Derive record_ns / record_tok from the fetched edge (mirrors update_edge).
4586        let record_ns: String = edge.namespace.clone();
4587        let record_tok = NamespaceToken::for_namespace(
4588            khive_types::Namespace::parse(&record_ns)
4589                .map_err(|e| RuntimeError::Internal(format!("edge namespace invalid: {e}")))?,
4590        );
4591        let graph = self.graph(&record_tok)?;
4592
4593        // Cascade: on hard delete, remove ALL annotates edges targeting this edge — including
4594        // already-soft-deleted ones: to prevent dangling graph_edges rows. The row
4595        // delete and the cascade purge run as ONE write transaction: see
4596        // `atomic_hard_delete_with_edge_purge`.
4597        // On soft delete the cascade is skipped (data-vs-view principle: soft-deleting the base
4598        // edge does not cascade to annotation edges; only a hard purge cleans up incident rows).
4599        let deleted = if hard {
4600            self.atomic_hard_delete_with_edge_purge(edge_hard_delete_statement(edge_id), edge_id)
4601                .await?
4602        } else {
4603            graph.delete_edge(LinkId::from(edge_id), mode).await?
4604        };
4605        if deleted {
4606            // Audit event: use the record's namespace (record_ns), not the caller's namespace.
4607            let event_store = self.events(&record_tok)?;
4608            let event = khive_storage::event::Event::new(
4609                record_ns.clone(),
4610                "delete",
4611                EventKind::EdgeDeleted,
4612                SubstrateKind::Entity,
4613                "",
4614            )
4615            .with_target(edge_id)
4616            .with_payload(serde_json::json!({"id": edge_id, "namespace": record_ns, "hard": hard}));
4617            event_store.append_event(event).await.map_err(|e| {
4618                RuntimeError::Internal(format!("delete_edge: event store write failed: {e}"))
4619            })?;
4620        }
4621        Ok(deleted)
4622    }
4623
4624    /// Count edges matching `filter`, summed across the caller's visible
4625    /// namespaces (mirrors [`Self::count_edges_by_relation`] and
4626    /// [`Self::list_edges`] so `stats().edges` reconciles with a full `list`
4627    /// keyset walk under the same token).
4628    pub async fn count_edges(
4629        &self,
4630        token: &NamespaceToken,
4631        filter: crate::curation::EdgeListFilter,
4632    ) -> RuntimeResult<u64> {
4633        let mut total = 0u64;
4634        for ns in token.visible_namespaces() {
4635            let temp = NamespaceToken::for_namespace(ns.clone());
4636            total += self
4637                .graph(&temp)?
4638                .count_edges(filter.clone().into())
4639                .await?;
4640        }
4641        Ok(total)
4642    }
4643
4644    /// Validate and construct an edge from a [`LinkSpec`] without writing to storage.
4645    ///
4646    /// Applies the full edge contract (endpoint validation, symmetric
4647    /// canonicalization, `dependency_kind` inference and metadata validation).
4648    /// Returns the constructed `Edge` on success; the caller is responsible for
4649    /// persisting it (e.g. via `upsert_edge` or `link_many`).
4650    ///
4651    /// The `token` must be a pre-authorized namespace token from the dispatch
4652    /// layer. If `spec.namespace` is set it must match `token.namespace()`;
4653    /// a mismatch returns `RuntimeError::InvalidInput`.
4654    pub async fn build_edge(&self, token: &NamespaceToken, spec: &LinkSpec) -> RuntimeResult<Edge> {
4655        let ns_str = match &spec.namespace {
4656            Some(s) => {
4657                let spec_ns = crate::Namespace::parse(s)
4658                    .map_err(|e| RuntimeError::InvalidInput(format!("invalid namespace: {e}")))?;
4659                if &spec_ns != token.namespace() {
4660                    return Err(RuntimeError::InvalidInput(
4661                        "LinkSpec namespace does not match token namespace".into(),
4662                    ));
4663                }
4664                s.as_str()
4665            }
4666            None => token.namespace().as_str(),
4667        };
4668        self.validate_edge_relation_endpoints(token, spec.source_id, spec.target_id, spec.relation)
4669            .await?;
4670        let (source_id, target_id) =
4671            canonical_edge_endpoints(spec.relation, spec.source_id, spec.target_id);
4672        let metadata = if spec.relation == EdgeRelation::DependsOn {
4673            // By-ID, unfiltered — matches the namespace-agnostic endpoint validation
4674            // above. The visible-set-scoped `resolve` would silently drop the
4675            // dependency_kind inference for endpoints validation now allows outside
4676            // the caller's visible set.
4677            match (
4678                self.resolve_edge_endpoint(token, source_id).await?,
4679                self.resolve_edge_endpoint(token, target_id).await?,
4680            ) {
4681                (Some(Resolved::Entity(src_e)), Some(Resolved::Entity(tgt_e))) => {
4682                    merge_dependency_kind(&src_e.kind, &tgt_e.kind, spec.metadata.clone())
4683                }
4684                _ => spec.metadata.clone(),
4685            }
4686        } else {
4687            spec.metadata.clone()
4688        };
4689        validate_edge_metadata(spec.relation, metadata.as_ref())?;
4690        let now = chrono::Utc::now();
4691        Ok(Edge {
4692            id: LinkId::from(Uuid::new_v4()),
4693            namespace: ns_str.to_string(),
4694            source_id,
4695            target_id,
4696            relation: spec.relation,
4697            weight: spec.weight,
4698            created_at: now,
4699            updated_at: now,
4700            deleted_at: None,
4701            metadata,
4702            target_backend: None,
4703        })
4704    }
4705
4706    /// Validate and atomically upsert a batch of edges.
4707    ///
4708    /// All edges are validated and constructed with `build_edge` before any
4709    /// write. If validation fails for any entry the entire batch is rejected
4710    /// (no writes occur). On success, all edges are persisted in a single
4711    /// atomic transaction via `upsert_edges`.
4712    ///
4713    /// After the bulk upsert, each edge is read back by its natural key
4714    /// (namespace, source_id, target_id, relation) so that the returned IDs
4715    /// are always the persisted row IDs, not the locally-generated UUIDs that
4716    /// may have been displaced by an ON CONFLICT DO UPDATE. This mirrors the
4717    /// same read-back applied to singleton `link()` and prevents phantom-ID
4718    /// exposure when callers upsert overlapping triples with `verbose=true`.
4719    ///
4720    /// All specs must share the same namespace; the namespace is taken from
4721    /// `token` (or validated against it if `spec.namespace` is set).
4722    pub async fn link_many(
4723        &self,
4724        token: &NamespaceToken,
4725        specs: Vec<LinkSpec>,
4726    ) -> RuntimeResult<Vec<Edge>> {
4727        if specs.is_empty() {
4728            return Ok(vec![]);
4729        }
4730        let mut edges = Vec::with_capacity(specs.len());
4731        for spec in &specs {
4732            edges.push(self.build_edge(token, spec).await?);
4733        }
4734        // `upsert_edges_guarded` re-checks every edge's endpoints as part of the
4735        // same write, not the separate per-spec `build_edge` validation reads
4736        // above. A concurrent hard-delete of any endpoint landing between those
4737        // reads and this write aborts the whole batch (all-or-nothing, no
4738        // partial write) instead of persisting a dangling edge. The failing
4739        // entry's index and its missing endpoint(s) come from the guard's own
4740        // in-transaction pre-check (`GuardedBatchOutcome::refused`), not a
4741        // post-hoc re-read of the batch after the write already failed.
4742        let outcome = self
4743            .graph(token)?
4744            .upsert_edges_guarded(edges.clone())
4745            .await?;
4746        if let Some(refusal) = outcome.refused {
4747            return Err(RuntimeError::GuardedWriteFailed(GuardedWriteFailure {
4748                entry_index: Some(refusal.entry_index),
4749                missing_source: refusal
4750                    .missing
4751                    .source
4752                    .then_some(edges[refusal.entry_index].source_id),
4753                missing_target: refusal
4754                    .missing
4755                    .target
4756                    .then_some(edges[refusal.entry_index].target_id),
4757            }));
4758        }
4759        if outcome.summary.affected != edges.len() as u64 {
4760            return Err(RuntimeError::NotFound(format!(
4761                "link_many: one or more edge endpoints no longer exist at write time: {}",
4762                outcome.summary.first_error
4763            )));
4764        }
4765
4766        // Read back each persisted edge by natural key so callers always
4767        // receive the stored row ID, not the pre-upsert generated UUID.
4768        let mut persisted = Vec::with_capacity(edges.len());
4769        for edge in &edges {
4770            let row = self
4771                .list_edges(
4772                    token,
4773                    crate::curation::EdgeListFilter {
4774                        source_id: Some(edge.source_id),
4775                        target_id: Some(edge.target_id),
4776                        relations: vec![edge.relation],
4777                        ..Default::default()
4778                    },
4779                    1,
4780                    0,
4781                )
4782                .await?
4783                .into_iter()
4784                .next()
4785                .ok_or_else(|| {
4786                    crate::RuntimeError::Internal(format!(
4787                        "upsert_edges succeeded but natural-key lookup for ({}, {}, {}) returned nothing",
4788                        edge.source_id, edge.target_id, edge.relation.as_str()
4789                    ))
4790                })?;
4791            persisted.push(row);
4792        }
4793        Ok(persisted)
4794    }
4795
4796    /// Create a batch of entities atomically.
4797    ///
4798    /// All specs are validated before any write. If ANY spec fails validation
4799    /// (unknown kind, empty name, secret-gate violation), the method returns
4800    /// that error and no entities are written.
4801    ///
4802    /// Storage writes are issued as ONE `upsert_entities` call followed by ONE
4803    /// `upsert_documents` call — the same primitives that the single-entity path
4804    /// uses, but batched. Embedding is intentionally skipped: bulk structural
4805    /// ingest is the expected use-case, and dense vectors are backfilled later
4806    /// via a `reindex` call. Callers that need immediate vector search
4807    /// immediately after creation should use per-entity `create_entity` instead.
4808    ///
4809    /// On FTS failure, every newly written entity row is hard-deleted to maintain
4810    /// consistency (mirrors the single-entity rollback in `create_entity`).
4811    pub async fn create_many(
4812        &self,
4813        token: &NamespaceToken,
4814        specs: Vec<EntityCreateSpec>,
4815    ) -> RuntimeResult<Vec<Entity>> {
4816        if specs.is_empty() {
4817            return Ok(vec![]);
4818        }
4819        let ns = token.namespace().as_str();
4820
4821        // Phase 1: validate ALL specs before any write.
4822        // Includes entity-type validation via the pack-installed validator when available.
4823        // Any validation failure here guarantees zero rows are written.
4824        let mut entities = Vec::with_capacity(specs.len());
4825        for spec in &specs {
4826            self.validate_entity_kind(&spec.kind)?;
4827            // Validate entity_type at the runtime layer via pack-installed callback.
4828            // When no validator is installed (bare runtime, unit tests without packs),
4829            // the type passes through unchanged — same skip-when-absent pattern as
4830            // validate_entity_kind. The handler layer remains the primary enforcement point.
4831            let validated_type =
4832                self.validate_entity_type_for_kind(&spec.kind, spec.entity_type.as_deref())?;
4833            if spec.name.trim().is_empty() {
4834                return Err(RuntimeError::InvalidInput("name must not be empty".into()));
4835            }
4836            crate::secret_gate::check(&spec.name)?;
4837            if let Some(d) = &spec.description {
4838                crate::secret_gate::check(d)?;
4839            }
4840            if let Some(ref p) = spec.properties {
4841                crate::secret_gate::check_json(p)?;
4842            }
4843            crate::secret_gate::check_tags(&spec.tags)?;
4844
4845            let mut entity =
4846                Entity::new(ns, &spec.kind, &spec.name).with_entity_type(validated_type.as_deref());
4847            if let Some(d) = &spec.description {
4848                entity = entity.with_description(d);
4849            }
4850            if let Some(p) = spec.properties.clone() {
4851                entity = entity.with_properties(p);
4852            }
4853            if !spec.tags.is_empty() {
4854                entity = entity.with_tags(spec.tags.clone());
4855            }
4856            entities.push(entity);
4857        }
4858
4859        // Phase 2: single bulk entity write.
4860        // Capture the BatchWriteSummary to detect partial failures.
4861        // The store commits the transaction even when some rows fail (per-row error
4862        // isolation). If any row failed, compensate by hard-deleting the rows that DID
4863        // land, then return Err so the caller sees zero net writes.
4864        //
4865        // NOTE: this compensation path (delete-on-partial-failure) is a stopgap until
4866        // a true single-transaction bulk primitive is available in the entity store.
4867        // That primitive (writing entity rows and FTS rows in one SQL transaction) is
4868        // tracked as a follow-up issue.
4869        let entity_summary = self
4870            .entities(token)?
4871            .upsert_entities(entities.clone())
4872            .await?;
4873
4874        if entity_summary.failed > 0 {
4875            // Compensate: hard-delete any entity rows that did land.
4876            if let Ok(store) = self.entities(token) {
4877                for entity in &entities {
4878                    if let Err(ce) = store.delete_entity(entity.id, DeleteMode::Hard).await {
4879                        tracing::error!(
4880                            error = %ce,
4881                            id = %entity.id,
4882                            "create_many: failed to roll back entity row after partial entity write"
4883                        );
4884                    }
4885                }
4886            }
4887            return Err(RuntimeError::Internal(format!(
4888                "create_many: {}/{} entity rows failed to write (first error: {}); \
4889                 all rows rolled back",
4890                entity_summary.failed, entity_summary.attempted, entity_summary.first_error
4891            )));
4892        }
4893
4894        // Phase 3: single bulk FTS write.
4895        //
4896        // The FTS store commits partial batches and signals per-document failures
4897        // via BatchWriteSummary.failed (same as the entity store in Phase 2).
4898        // We must capture the summary and treat failed > 0 as an error.
4899        //
4900        // Compensation is symmetric: on any FTS failure (Err or failed > 0),
4901        // we first delete any FTS documents that may have landed, then
4902        // hard-delete the entity rows.  This order matters: the entity delete
4903        // is the authoritative write; FTS is a derived index.  Cleaning FTS
4904        // first avoids a window where entity rows are gone but stale FTS rows
4905        // survive.
4906        let docs: Vec<_> = entities.iter().map(entity_fts_document).collect();
4907
4908        #[cfg(any(test, feature = "fault-injection"))]
4909        let fts_many_inject = {
4910            let mut g = FTS_FAIL_MANY_NS.lock().unwrap();
4911            if g.as_deref() == Some(ns) {
4912                *g = None;
4913                true
4914            } else {
4915                false
4916            }
4917        };
4918        #[cfg(not(any(test, feature = "fault-injection")))]
4919        let fts_many_inject = false;
4920
4921        // Partial-failure seam: returns Ok(summary) with failed > 0 so the
4922        // `summary.failed > 0` rollback branch is exercised in tests.
4923        #[cfg(any(test, feature = "fault-injection"))]
4924        let fts_many_inject_partial = {
4925            let mut g = FTS_FAIL_MANY_PARTIAL_NS.lock().unwrap();
4926            if g.as_deref() == Some(ns) {
4927                *g = None;
4928                true
4929            } else {
4930                false
4931            }
4932        };
4933        #[cfg(not(any(test, feature = "fault-injection")))]
4934        let fts_many_inject_partial = false;
4935
4936        let fts_summary_result: RuntimeResult<BatchWriteSummary> = if fts_many_inject {
4937            Err(RuntimeError::Internal(
4938                "injected FTS failure for create_many".to_string(),
4939            ))
4940        } else if fts_many_inject_partial {
4941            Ok(BatchWriteSummary {
4942                attempted: docs.len() as u64,
4943                affected: docs.len().saturating_sub(1) as u64,
4944                failed: 1,
4945                first_error: "injected partial FTS failure for create_many".to_string(),
4946            })
4947        } else {
4948            match self.text(token) {
4949                Ok(fts) => fts.upsert_documents(docs).await.map_err(RuntimeError::from),
4950                Err(e) => Err(e),
4951            }
4952        };
4953
4954        let fts_err: Option<RuntimeError> = match fts_summary_result {
4955            Err(e) => Some(e),
4956            Ok(summary) if summary.failed > 0 => Some(RuntimeError::Internal(format!(
4957                "create_many: {}/{} FTS rows failed to index (first error: {}); \
4958                 all rows rolled back",
4959                summary.failed, summary.attempted, summary.first_error
4960            ))),
4961            Ok(_) => None,
4962        };
4963
4964        if let Some(e) = fts_err {
4965            // Clean up any FTS docs that landed before deleting entity rows.
4966            if let Ok(fts) = self.text(token) {
4967                for entity in &entities {
4968                    if let Err(ce) = fts.delete_document(ns, entity.id).await {
4969                        tracing::error!(
4970                            error = %ce,
4971                            id = %entity.id,
4972                            "create_many: failed to remove FTS doc during rollback"
4973                        );
4974                    }
4975                }
4976            }
4977            if let Ok(store) = self.entities(token) {
4978                for entity in &entities {
4979                    if let Err(ce) = store.delete_entity(entity.id, DeleteMode::Hard).await {
4980                        tracing::error!(
4981                            error = %ce,
4982                            id = %entity.id,
4983                            "create_many: failed to roll back entity row after FTS failure"
4984                        );
4985                    }
4986                }
4987            }
4988            return Err(e);
4989        }
4990
4991        // Embedding is skipped intentionally — see doc comment above.
4992        Ok(entities)
4993    }
4994}
4995
4996/// Fully specified edge creation request — input to [`KhiveRuntime::build_edge`]
4997/// and [`KhiveRuntime::link_many`].
4998#[derive(Clone, Debug)]
4999pub struct LinkSpec {
5000    pub namespace: Option<String>,
5001    pub source_id: Uuid,
5002    pub target_id: Uuid,
5003    pub relation: EdgeRelation,
5004    pub weight: f64,
5005    pub metadata: Option<serde_json::Value>,
5006}
5007
5008/// Fully specified entity creation request — input to [`KhiveRuntime::create_many`].
5009///
5010/// `entity_type` is validated at the runtime layer by the pack-installed
5011/// entity-type validator. When a validator
5012/// is installed (e.g. by `KgPack`), unknown types are rejected with the valid
5013/// set listed. When no validator is installed (bare runtime without packs),
5014/// the value passes through — the handler layer is the primary enforcement point.
5015#[derive(Clone, Debug)]
5016pub struct EntityCreateSpec {
5017    pub kind: String,
5018    pub entity_type: Option<String>,
5019    pub name: String,
5020    pub description: Option<String>,
5021    pub properties: Option<serde_json::Value>,
5022    pub tags: Vec<String>,
5023}
5024
5025// INLINE TEST JUSTIFICATION: tests here exercise private helpers (canonical_edge_endpoints,
5026// validate_edge_metadata, merge_dependency_kind, link-fail injection) and runtime methods
5027// that require pub(crate) KhiveRuntime construction. Moving them to tests/ would require
5028// pub-exporting those private helpers, which would widen the crate's public API surface
5029// undesirably. Broad behavioral tests live in tests/integration.rs.
5030#[cfg(test)]
5031mod tests {
5032    use super::*;
5033    use crate::curation::EdgeListFilter;
5034    use crate::embedder_registry::EmbedderProvider;
5035    use crate::error::RuntimeError;
5036    use crate::runtime::{KhiveRuntime, NamespaceToken};
5037    use crate::{ActorRef, Namespace};
5038    use async_trait::async_trait;
5039    use khive_storage::types::PathNode;
5040    use lattice_embed::{EmbedError, EmbeddingModel, EmbeddingService};
5041    use std::sync::atomic::{AtomicUsize, Ordering};
5042    use std::sync::Arc;
5043
5044    fn rt() -> KhiveRuntime {
5045        KhiveRuntime::memory().unwrap()
5046    }
5047
5048    // ── Custom embedder fan-out regression ──────────────────────────────────
5049    // A runtime with no `config.embedding_model` but a custom registered
5050    // embedder must fan out create_note through that embedder and store a
5051    // vector so recall can find the note.
5052
5053    /// Trivial constant-vector embedding service.  The model argument is ignored;
5054    /// the service always returns a synthetic `dims × 1.0f32` vector.
5055    struct ConstVecService {
5056        dims: usize,
5057    }
5058
5059    #[async_trait]
5060    impl EmbeddingService for ConstVecService {
5061        async fn embed(
5062            &self,
5063            texts: &[String],
5064            _model: EmbeddingModel,
5065        ) -> std::result::Result<Vec<Vec<f32>>, EmbedError> {
5066            Ok(texts.iter().map(|_| vec![1.0_f32; self.dims]).collect())
5067        }
5068
5069        fn supports_model(&self, _model: EmbeddingModel) -> bool {
5070            true
5071        }
5072
5073        fn name(&self) -> &'static str {
5074            "const-vec"
5075        }
5076    }
5077
5078    struct ConstVecProvider {
5079        provider_name: String,
5080        dims: usize,
5081        pub build_count: Arc<AtomicUsize>,
5082    }
5083
5084    impl ConstVecProvider {
5085        fn new(name: &str, dims: usize) -> (Self, Arc<AtomicUsize>) {
5086            let counter = Arc::new(AtomicUsize::new(0));
5087            let provider = Self {
5088                provider_name: name.to_owned(),
5089                dims,
5090                build_count: Arc::clone(&counter),
5091            };
5092            (provider, counter)
5093        }
5094    }
5095
5096    #[async_trait]
5097    impl EmbedderProvider for ConstVecProvider {
5098        fn name(&self) -> &str {
5099            &self.provider_name
5100        }
5101
5102        fn dimensions(&self) -> usize {
5103            self.dims
5104        }
5105
5106        async fn build(&self) -> crate::error::RuntimeResult<Arc<dyn EmbeddingService>> {
5107            self.build_count.fetch_add(1, Ordering::SeqCst);
5108            Ok(Arc::new(ConstVecService { dims: self.dims }))
5109        }
5110    }
5111
5112    /// Custom embedder with no lattice model in config must participate in
5113    /// fan-out: the gate must check `registered_embedding_model_names()`, not
5114    /// `config().embedding_model.is_some()`: the latter falls through to
5115    /// `vec![]` when only a custom provider is registered.
5116    #[tokio::test]
5117    async fn custom_embedder_only_runtime_fanout_stores_vector() {
5118        const MODEL_NAME: &str = "test-custom-encoder";
5119        const DIMS: usize = 8;
5120
5121        // Build a runtime with no lattice embedding_model.
5122        let rt = KhiveRuntime::memory().unwrap();
5123
5124        // Register the custom provider — this is the only embedder configured.
5125        let (provider, _counter) = ConstVecProvider::new(MODEL_NAME, DIMS);
5126        rt.register_embedder(provider);
5127
5128        // Sanity: config.embedding_model is None, but the registry has one entry.
5129        assert!(rt.config().embedding_model.is_none());
5130        assert_eq!(rt.registered_embedding_model_names(), vec![MODEL_NAME]);
5131
5132        let tok = NamespaceToken::local();
5133
5134        // create_note should fan out to the custom embedder and store a vector.
5135        let note = rt
5136            .create_note(
5137                &tok,
5138                "memory",
5139                None,
5140                "custom embedder integration test content",
5141                Some(0.7),
5142                None,
5143                vec![],
5144            )
5145            .await
5146            .expect("create_note with custom-only embedder must succeed");
5147
5148        // Verify: a vector was written in the custom model's store.
5149        use khive_storage::types::VectorSearchRequest;
5150        let query_vec = vec![1.0_f32; DIMS];
5151        let hits = rt
5152            .vectors_for_model(&tok, MODEL_NAME)
5153            .expect("vector store for custom model must be accessible")
5154            .search(VectorSearchRequest {
5155                query_vectors: vec![query_vec],
5156                top_k: 5,
5157                namespace: Some(tok.namespace().as_str().to_string()),
5158                kind: Some(khive_types::SubstrateKind::Note),
5159                embedding_model: Some(MODEL_NAME.to_string()),
5160                filter: None,
5161                backend_hints: None,
5162            })
5163            .await
5164            .expect("vector search succeeds");
5165
5166        assert!(
5167            hits.iter().any(|h| h.subject_id == note.id),
5168            "custom embedder must have written a vector for note {}: hits={hits:?}",
5169            note.id
5170        );
5171    }
5172
5173    /// Custom-only embedder participates in `embed_with_model` so recall
5174    /// fan-out also works: the lattice alias parse must be optional, with
5175    /// the embedder registry consulted directly, since requiring a lattice
5176    /// alias would reject valid custom provider names with `UnknownModel`.
5177    #[tokio::test]
5178    async fn embed_with_model_accepts_custom_provider_name() {
5179        const MODEL_NAME: &str = "my-custom-enc";
5180        const DIMS: usize = 4;
5181
5182        let rt = KhiveRuntime::memory().unwrap();
5183        let (provider, _counter) = ConstVecProvider::new(MODEL_NAME, DIMS);
5184        rt.register_embedder(provider);
5185
5186        let result = rt
5187            .embed_with_model(MODEL_NAME, "hello world")
5188            .await
5189            .expect("embed_with_model must accept custom provider names");
5190
5191        assert_eq!(
5192            result.len(),
5193            DIMS,
5194            "embedding dimension must match provider"
5195        );
5196        assert!(
5197            result.iter().all(|&v| (v - 1.0_f32).abs() < 1e-6),
5198            "ConstVecService must produce all-ones vector; got: {result:?}"
5199        );
5200    }
5201
5202    /// `embed_with_model` must still reject names that are not in the
5203    /// registry (neither lattice aliases nor custom providers).
5204    #[tokio::test]
5205    async fn embed_with_model_rejects_unregistered_name() {
5206        let rt = KhiveRuntime::memory().unwrap();
5207        let result = rt.embed_with_model("nonexistent-model", "hello").await;
5208        assert!(
5209            matches!(result.unwrap_err(), RuntimeError::UnknownModel(ref n) if n == "nonexistent-model"),
5210            "unregistered model name must return UnknownModel"
5211        );
5212    }
5213
5214    // ── No-embeddings config regression ─────────────────────────────────────
5215    // `RuntimeConfig::no_embeddings()` must register zero embedders, so
5216    // `create_note` never attempts to lazily build a lattice embedding model —
5217    // this is what lets `memory.remember` succeed on a machine with no local
5218    // model files present.
5219
5220    #[tokio::test]
5221    async fn no_embeddings_config_registers_zero_embedders() {
5222        let config = crate::config::RuntimeConfig {
5223            db_path: None,
5224            packs: vec!["kg".to_string()],
5225            ..crate::config::RuntimeConfig::no_embeddings()
5226        };
5227        let rt = KhiveRuntime::new(config).expect("runtime construction must succeed");
5228
5229        assert!(rt.config().embedding_model.is_none());
5230        assert!(
5231            rt.registered_embedding_model_names().is_empty(),
5232            "no_embeddings() runtime must register zero embedders"
5233        );
5234    }
5235
5236    #[tokio::test]
5237    async fn no_embeddings_runtime_create_note_succeeds_without_model_fanout() {
5238        let config = crate::config::RuntimeConfig {
5239            db_path: None,
5240            packs: vec!["kg".to_string()],
5241            ..crate::config::RuntimeConfig::no_embeddings()
5242        };
5243        let rt = KhiveRuntime::new(config).expect("runtime construction must succeed");
5244        let tok = NamespaceToken::local();
5245
5246        // With zero registered embedders, create_note's embed fan-out list is
5247        // empty and no lattice model build is ever attempted -- the write must
5248        // succeed, degrading to FTS-only.
5249        let note = rt
5250            .create_note(
5251                &tok,
5252                "memory",
5253                None,
5254                "issue-396 regression: model-less remember must succeed",
5255                Some(0.7),
5256                None,
5257                vec![],
5258            )
5259            .await
5260            .expect("create_note must succeed with zero registered embedders");
5261
5262        assert_eq!(
5263            note.content,
5264            "issue-396 regression: model-less remember must succeed"
5265        );
5266    }
5267
5268    #[tokio::test]
5269    async fn update_edge_changes_weight() {
5270        let rt = rt();
5271        let tok = NamespaceToken::local();
5272        let a = rt
5273            .create_entity(&tok, "concept", None, "A", None, None, vec![])
5274            .await
5275            .unwrap();
5276        let b = rt
5277            .create_entity(&tok, "concept", None, "B", None, None, vec![])
5278            .await
5279            .unwrap();
5280        let edge = rt
5281            .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
5282            .await
5283            .unwrap();
5284        let edge_id: Uuid = edge.id.into();
5285
5286        let updated = rt
5287            .update_edge(
5288                &tok,
5289                edge_id,
5290                crate::curation::EdgePatch {
5291                    weight: Some(0.5),
5292                    ..Default::default()
5293                },
5294            )
5295            .await
5296            .unwrap();
5297        assert!((updated.weight - 0.5).abs() < 0.001);
5298    }
5299
5300    /// Regression test: `update_edge_symmetric_dml` previously stored
5301    /// `updated_at` via `chrono::Utc::now().timestamp()` (SECONDS) while every
5302    /// other `graph_edges` write path (`edge_upsert_statement`,
5303    /// `edge_soft_delete_statement`) uses `timestamp_micros()`: a genuine
5304    /// pre-existing bug, found while unifying this raw-SQL path with the
5305    /// atomic builder (which already used `timestamp_micros()` correctly)
5306    /// onto the shared `EDGE_SYMMETRIC_*_SQL` text. A seconds value misread as
5307    /// microseconds round-trips to a date a few minutes after the Unix epoch,
5308    /// not "now".
5309    #[tokio::test]
5310    async fn update_edge_symmetric_relation_stores_microsecond_updated_at() {
5311        let rt = rt();
5312        let tok = NamespaceToken::local();
5313        let a = rt
5314            .create_entity(&tok, "concept", None, "A", None, None, vec![])
5315            .await
5316            .unwrap();
5317        let b = rt
5318            .create_entity(&tok, "concept", None, "B", None, None, vec![])
5319            .await
5320            .unwrap();
5321        let edge = rt
5322            .link(&tok, a.id, b.id, EdgeRelation::CompetesWith, 1.0, None)
5323            .await
5324            .unwrap();
5325        let edge_id: Uuid = edge.id.into();
5326
5327        let before = chrono::Utc::now();
5328        let updated = rt
5329            .update_edge(
5330                &tok,
5331                edge_id,
5332                crate::curation::EdgePatch {
5333                    weight: Some(0.5),
5334                    ..Default::default()
5335                },
5336            )
5337            .await
5338            .unwrap();
5339
5340        let drift = (updated.updated_at - before).num_seconds().abs();
5341        assert!(
5342            drift < 60,
5343            "updated_at must round-trip as a recent timestamp (micros, not \
5344             seconds); got {:?}, expected within 60s of {:?}",
5345            updated.updated_at,
5346            before
5347        );
5348    }
5349
5350    #[tokio::test]
5351    async fn update_edge_changes_relation() {
5352        let rt = rt();
5353        let tok = NamespaceToken::local();
5354        let a = rt
5355            .create_entity(&tok, "concept", None, "A", None, None, vec![])
5356            .await
5357            .unwrap();
5358        let b = rt
5359            .create_entity(&tok, "concept", None, "B", None, None, vec![])
5360            .await
5361            .unwrap();
5362        let edge = rt
5363            .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
5364            .await
5365            .unwrap();
5366        let edge_id: Uuid = edge.id.into();
5367
5368        let updated = rt
5369            .update_edge(
5370                &tok,
5371                edge_id,
5372                crate::curation::EdgePatch {
5373                    relation: Some(EdgeRelation::VariantOf),
5374                    ..Default::default()
5375                },
5376            )
5377            .await
5378            .unwrap();
5379        assert_eq!(updated.relation, EdgeRelation::VariantOf);
5380    }
5381
5382    // ---- update_edge endpoint validation ----
5383
5384    // update_edge: note→entity annotates → set relation=Supersedes → InvalidInput (crossing).
5385    // Edge must NOT be mutated in the store.
5386    #[tokio::test]
5387    async fn update_edge_annotates_note_to_entity_set_supersedes_returns_invalid_input() {
5388        let rt = rt();
5389        let tok = NamespaceToken::local();
5390        let note = rt
5391            .create_note(&tok, "observation", None, "a note", Some(0.5), None, vec![])
5392            .await
5393            .unwrap();
5394        let entity = rt
5395            .create_entity(&tok, "concept", None, "E", None, None, vec![])
5396            .await
5397            .unwrap();
5398        // Create a valid note→entity annotates edge.
5399        let edge = rt
5400            .link(&tok, note.id, entity.id, EdgeRelation::Annotates, 1.0, None)
5401            .await
5402            .unwrap();
5403        let edge_id: Uuid = edge.id.into();
5404
5405        // Attempt to change relation to Supersedes (crossing substrates → invalid).
5406        let result = rt
5407            .update_edge(
5408                &tok,
5409                edge_id,
5410                crate::curation::EdgePatch {
5411                    relation: Some(EdgeRelation::Supersedes),
5412                    ..Default::default()
5413                },
5414            )
5415            .await;
5416        assert!(
5417            matches!(result, Err(RuntimeError::InvalidInput(_))),
5418            "update to Supersedes on note→entity edge must return InvalidInput, got {result:?}"
5419        );
5420
5421        // Edge must NOT be mutated — re-fetch and verify relation unchanged.
5422        let fetched = rt.get_edge(&tok, edge_id).await.unwrap().unwrap();
5423        assert_eq!(
5424            fetched.relation,
5425            EdgeRelation::Annotates,
5426            "edge relation must be unchanged after failed update"
5427        );
5428    }
5429
5430    // update_edge: entity→entity extends → set relation=Annotates → InvalidInput
5431    // (annotates source must be a note).
5432    #[tokio::test]
5433    async fn update_edge_entity_to_entity_set_annotates_returns_invalid_input() {
5434        let rt = rt();
5435        let tok = NamespaceToken::local();
5436        let a = rt
5437            .create_entity(&tok, "concept", None, "A", None, None, vec![])
5438            .await
5439            .unwrap();
5440        let b = rt
5441            .create_entity(&tok, "concept", None, "B", None, None, vec![])
5442            .await
5443            .unwrap();
5444        let edge = rt
5445            .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
5446            .await
5447            .unwrap();
5448        let edge_id: Uuid = edge.id.into();
5449
5450        let result = rt
5451            .update_edge(
5452                &tok,
5453                edge_id,
5454                crate::curation::EdgePatch {
5455                    relation: Some(EdgeRelation::Annotates),
5456                    ..Default::default()
5457                },
5458            )
5459            .await;
5460        assert!(
5461            matches!(result, Err(RuntimeError::InvalidInput(_))),
5462            "update to Annotates on entity→entity edge must return InvalidInput, got {result:?}"
5463        );
5464    }
5465
5466    // update_edge: entity→entity extends → set relation=Supersedes → Ok
5467    // (entity→entity is valid for supersedes).
5468    #[tokio::test]
5469    async fn update_edge_entity_to_entity_set_supersedes_succeeds() {
5470        let rt = rt();
5471        let tok = NamespaceToken::local();
5472        let a = rt
5473            .create_entity(&tok, "concept", None, "A", None, None, vec![])
5474            .await
5475            .unwrap();
5476        let b = rt
5477            .create_entity(&tok, "concept", None, "B", None, None, vec![])
5478            .await
5479            .unwrap();
5480        let edge = rt
5481            .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
5482            .await
5483            .unwrap();
5484        let edge_id: Uuid = edge.id.into();
5485
5486        let updated = rt
5487            .update_edge(
5488                &tok,
5489                edge_id,
5490                crate::curation::EdgePatch {
5491                    relation: Some(EdgeRelation::Supersedes),
5492                    ..Default::default()
5493                },
5494            )
5495            .await
5496            .unwrap();
5497        assert_eq!(updated.relation, EdgeRelation::Supersedes);
5498
5499        // Verify persisted.
5500        let fetched = rt.get_edge(&tok, edge_id).await.unwrap().unwrap();
5501        assert_eq!(fetched.relation, EdgeRelation::Supersedes);
5502    }
5503
5504    // update_edge: weight-only (relation = None) → Ok, no validation, unchanged relation.
5505    #[tokio::test]
5506    async fn update_edge_weight_only_skips_validation() {
5507        let rt = rt();
5508        let tok = NamespaceToken::local();
5509        let a = rt
5510            .create_entity(&tok, "concept", None, "A", None, None, vec![])
5511            .await
5512            .unwrap();
5513        let b = rt
5514            .create_entity(&tok, "concept", None, "B", None, None, vec![])
5515            .await
5516            .unwrap();
5517        let edge = rt
5518            .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
5519            .await
5520            .unwrap();
5521        let edge_id: Uuid = edge.id.into();
5522
5523        let updated = rt
5524            .update_edge(
5525                &tok,
5526                edge_id,
5527                crate::curation::EdgePatch {
5528                    weight: Some(0.3),
5529                    ..Default::default()
5530                },
5531            )
5532            .await
5533            .unwrap();
5534        assert_eq!(updated.relation, EdgeRelation::Extends);
5535        assert!((updated.weight - 0.3).abs() < 0.001);
5536    }
5537
5538    // update_edge: entity→entity extends → set relation=VariantOf (same class) → Ok.
5539    #[tokio::test]
5540    async fn update_edge_same_class_relation_change_succeeds() {
5541        let rt = rt();
5542        let tok = NamespaceToken::local();
5543        let a = rt
5544            .create_entity(&tok, "concept", None, "A", None, None, vec![])
5545            .await
5546            .unwrap();
5547        let b = rt
5548            .create_entity(&tok, "concept", None, "B", None, None, vec![])
5549            .await
5550            .unwrap();
5551        let edge = rt
5552            .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
5553            .await
5554            .unwrap();
5555        let edge_id: Uuid = edge.id.into();
5556
5557        let updated = rt
5558            .update_edge(
5559                &tok,
5560                edge_id,
5561                crate::curation::EdgePatch {
5562                    relation: Some(EdgeRelation::VariantOf),
5563                    ..Default::default()
5564                },
5565            )
5566            .await
5567            .unwrap();
5568        assert_eq!(updated.relation, EdgeRelation::VariantOf);
5569    }
5570
5571    #[tokio::test]
5572    async fn list_edges_filters_by_relation() {
5573        let rt = rt();
5574        let tok = NamespaceToken::local();
5575        let a = rt
5576            .create_entity(&tok, "concept", None, "A", None, None, vec![])
5577            .await
5578            .unwrap();
5579        let b = rt
5580            .create_entity(&tok, "concept", None, "B", None, None, vec![])
5581            .await
5582            .unwrap();
5583        let c = rt
5584            .create_entity(&tok, "concept", None, "C", None, None, vec![])
5585            .await
5586            .unwrap();
5587
5588        rt.link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
5589            .await
5590            .unwrap();
5591        rt.link(&tok, a.id, c.id, EdgeRelation::Enables, 1.0, None)
5592            .await
5593            .unwrap();
5594
5595        let filter = EdgeListFilter {
5596            relations: vec![EdgeRelation::Extends],
5597            ..Default::default()
5598        };
5599        let edges = rt.list_edges(&tok, filter, 100, 0).await.unwrap();
5600        assert_eq!(edges.len(), 1);
5601        assert_eq!(edges[0].relation, EdgeRelation::Extends);
5602    }
5603
5604    #[tokio::test]
5605    async fn list_edges_filters_by_source() {
5606        let rt = rt();
5607        let tok = NamespaceToken::local();
5608        let a = rt
5609            .create_entity(&tok, "concept", None, "A", None, None, vec![])
5610            .await
5611            .unwrap();
5612        let b = rt
5613            .create_entity(&tok, "concept", None, "B", None, None, vec![])
5614            .await
5615            .unwrap();
5616        let c = rt
5617            .create_entity(&tok, "concept", None, "C", None, None, vec![])
5618            .await
5619            .unwrap();
5620        let d = rt
5621            .create_entity(&tok, "concept", None, "D", None, None, vec![])
5622            .await
5623            .unwrap();
5624
5625        rt.link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
5626            .await
5627            .unwrap();
5628        rt.link(&tok, c.id, d.id, EdgeRelation::Extends, 1.0, None)
5629            .await
5630            .unwrap();
5631
5632        let filter = EdgeListFilter {
5633            source_id: Some(a.id),
5634            ..Default::default()
5635        };
5636        let edges = rt.list_edges(&tok, filter, 100, 0).await.unwrap();
5637        assert_eq!(edges.len(), 1);
5638        let src: Uuid = edges[0].source_id;
5639        assert_eq!(src, a.id);
5640    }
5641
5642    /// Regression: `offset` was hard-coded to 0 in `list_edges`, so every
5643    /// page returned the identical first rows. Pages must now tile the full
5644    /// matching set with no gaps or duplicates, and an out-of-range offset
5645    /// must return empty rather than page 1.
5646    #[tokio::test]
5647    async fn list_edges_offset_pages_through_full_set() {
5648        let rt = rt();
5649        let tok = NamespaceToken::local();
5650        let a = rt
5651            .create_entity(&tok, "concept", None, "A", None, None, vec![])
5652            .await
5653            .unwrap();
5654        for i in 0..5 {
5655            let t = rt
5656                .create_entity(&tok, "concept", None, &format!("T{i}"), None, None, vec![])
5657                .await
5658                .unwrap();
5659            rt.link(&tok, a.id, t.id, EdgeRelation::Extends, 1.0, None)
5660                .await
5661                .unwrap();
5662        }
5663
5664        let filter = EdgeListFilter {
5665            source_id: Some(a.id),
5666            relations: vec![EdgeRelation::Extends],
5667            ..Default::default()
5668        };
5669
5670        let page0 = rt.list_edges(&tok, filter.clone(), 2, 0).await.unwrap();
5671        let page1 = rt.list_edges(&tok, filter.clone(), 2, 2).await.unwrap();
5672        let page2 = rt.list_edges(&tok, filter.clone(), 2, 4).await.unwrap();
5673        assert_eq!(page0.len(), 2);
5674        assert_eq!(page1.len(), 2);
5675        assert_eq!(page2.len(), 1);
5676
5677        let ids = |p: &[Edge]| p.iter().map(|e| Uuid::from(e.id)).collect::<Vec<_>>();
5678        assert_ne!(ids(&page0), ids(&page1), "page 2 must differ from page 1");
5679
5680        let mut all_ids: Vec<Uuid> = ids(&page0)
5681            .into_iter()
5682            .chain(ids(&page1))
5683            .chain(ids(&page2))
5684            .collect();
5685        all_ids.sort();
5686        all_ids.dedup();
5687        assert_eq!(all_ids.len(), 5, "pages must tile the full edge set");
5688
5689        let empty = rt.list_edges(&tok, filter.clone(), 2, 100).await.unwrap();
5690        assert!(
5691            empty.is_empty(),
5692            "offset past the end must return empty, not page 1"
5693        );
5694    }
5695
5696    /// `list_edges_after` seeks via `id > cursor` against the
5697    /// `(namespace, id)` primary key index instead of paging through OFFSET,
5698    /// so cost does not grow with how deep the walk goes.
5699    #[tokio::test]
5700    async fn list_edges_after_keyset_tiles_full_set() {
5701        let rt = rt();
5702        let tok = NamespaceToken::local();
5703        let a = rt
5704            .create_entity(&tok, "concept", None, "A", None, None, vec![])
5705            .await
5706            .unwrap();
5707        for i in 0..5 {
5708            let t = rt
5709                .create_entity(&tok, "concept", None, &format!("K{i}"), None, None, vec![])
5710                .await
5711                .unwrap();
5712            rt.link(&tok, a.id, t.id, EdgeRelation::Extends, 1.0, None)
5713                .await
5714                .unwrap();
5715        }
5716
5717        let filter = EdgeListFilter {
5718            source_id: Some(a.id),
5719            relations: vec![EdgeRelation::Extends],
5720            ..Default::default()
5721        };
5722
5723        let mut seen = Vec::new();
5724        let mut cursor: Option<Uuid> = None;
5725        for _ in 0..20 {
5726            let (page, next) = rt
5727                .list_edges_after(&tok, filter.clone(), cursor, 2)
5728                .await
5729                .unwrap();
5730            if page.is_empty() {
5731                break;
5732            }
5733            seen.extend(page.iter().map(|e| Uuid::from(e.id)));
5734            if next.is_none() {
5735                break;
5736            }
5737            cursor = next;
5738        }
5739        seen.sort();
5740        seen.dedup();
5741        assert_eq!(seen.len(), 5, "keyset walk must tile the full edge set");
5742
5743        // Stability: repeating the same cursor returns the same page — no
5744        // drift under a fixed snapshot. The seek is `WHERE id > ?` against
5745        // the `(namespace, id)` primary key index with `ORDER BY id ASC`
5746        // matching the index order, so this is an indexed range scan, not a
5747        // full-table scan+sort (see `query_edges_after` in khive-db).
5748        let (first_a, next_a) = rt
5749            .list_edges_after(&tok, filter.clone(), None, 2)
5750            .await
5751            .unwrap();
5752        let (first_b, next_b) = rt
5753            .list_edges_after(&tok, filter.clone(), None, 2)
5754            .await
5755            .unwrap();
5756        assert_eq!(
5757            first_a.iter().map(|e| e.id.0).collect::<Vec<_>>(),
5758            first_b.iter().map(|e| e.id.0).collect::<Vec<_>>(),
5759        );
5760        assert_eq!(next_a, next_b);
5761    }
5762
5763    #[tokio::test]
5764    async fn list_edges_after_single_namespace_exact_final_page_has_no_next_after() {
5765        let rt = rt();
5766        let tok = NamespaceToken::local();
5767        let a = rt
5768            .create_entity(&tok, "concept", None, "SingleCursorA", None, None, vec![])
5769            .await
5770            .unwrap();
5771        for i in 0..4 {
5772            let t = rt
5773                .create_entity(
5774                    &tok,
5775                    "concept",
5776                    None,
5777                    &format!("SingleCursorT{i}"),
5778                    None,
5779                    None,
5780                    vec![],
5781                )
5782                .await
5783                .unwrap();
5784            rt.link(&tok, a.id, t.id, EdgeRelation::Extends, 1.0, None)
5785                .await
5786                .unwrap();
5787        }
5788
5789        let filter = EdgeListFilter {
5790            source_id: Some(a.id),
5791            relations: vec![EdgeRelation::Extends],
5792            ..Default::default()
5793        };
5794
5795        let (page1, next1) = rt
5796            .list_edges_after(&tok, filter.clone(), None, 2)
5797            .await
5798            .unwrap();
5799        assert_eq!(page1.len(), 2);
5800        let cursor = next1.expect("first page must report a cursor when two rows remain");
5801
5802        let (page2, next2) = rt
5803            .list_edges_after(&tok, filter, Some(cursor), 2)
5804            .await
5805            .unwrap();
5806        assert_eq!(page2.len(), 2);
5807        assert_eq!(
5808            next2, None,
5809            "an exact-size final single-namespace page must not report a cursor"
5810        );
5811    }
5812
5813    #[tokio::test]
5814    async fn list_edges_after_multi_namespace_exact_final_page_has_no_next_after() {
5815        let rt = rt();
5816        let ns_a = Namespace::parse("cursor-ns-a").unwrap();
5817        let ns_b = Namespace::parse("cursor-ns-b").unwrap();
5818        let tok_a = NamespaceToken::for_namespace(ns_a.clone());
5819        let tok_b = NamespaceToken::for_namespace(ns_b.clone());
5820        let visible = NamespaceToken::mint_with_visibility(ns_a, vec![ns_b], ActorRef::anonymous());
5821
5822        for (tok, prefix) in [(&tok_a, "A"), (&tok_b, "B")] {
5823            let source = rt
5824                .create_entity(
5825                    tok,
5826                    "concept",
5827                    None,
5828                    &format!("MultiCursor{prefix}Source"),
5829                    None,
5830                    None,
5831                    vec![],
5832                )
5833                .await
5834                .unwrap();
5835            for i in 0..2 {
5836                let target = rt
5837                    .create_entity(
5838                        tok,
5839                        "concept",
5840                        None,
5841                        &format!("MultiCursor{prefix}Target{i}"),
5842                        None,
5843                        None,
5844                        vec![],
5845                    )
5846                    .await
5847                    .unwrap();
5848                rt.link(tok, source.id, target.id, EdgeRelation::Extends, 1.0, None)
5849                    .await
5850                    .unwrap();
5851            }
5852        }
5853
5854        let filter = EdgeListFilter {
5855            relations: vec![EdgeRelation::Extends],
5856            ..Default::default()
5857        };
5858        let (page1, next1) = rt
5859            .list_edges_after(&visible, filter.clone(), None, 2)
5860            .await
5861            .unwrap();
5862        assert_eq!(page1.len(), 2);
5863        let cursor = next1.expect("first merged page must report a cursor when rows remain");
5864
5865        let (page2, next2) = rt
5866            .list_edges_after(&visible, filter, Some(cursor), 2)
5867            .await
5868            .unwrap();
5869        assert_eq!(page2.len(), 2);
5870        assert_eq!(
5871            next2, None,
5872            "an exact-size final multi-namespace page must not report a cursor"
5873        );
5874    }
5875
5876    /// `stats()` should be able to report a per-relation breakdown so
5877    /// auditors know the true population per relation before sampling.
5878    #[tokio::test]
5879    async fn count_edges_by_relation_matches_fixtures() {
5880        let rt = rt();
5881        let tok = NamespaceToken::local();
5882        let a = rt
5883            .create_entity(&tok, "concept", None, "A", None, None, vec![])
5884            .await
5885            .unwrap();
5886        let b = rt
5887            .create_entity(&tok, "concept", None, "B", None, None, vec![])
5888            .await
5889            .unwrap();
5890        let c = rt
5891            .create_entity(&tok, "concept", None, "C", None, None, vec![])
5892            .await
5893            .unwrap();
5894
5895        rt.link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
5896            .await
5897            .unwrap();
5898        rt.link(&tok, a.id, c.id, EdgeRelation::Extends, 1.0, None)
5899            .await
5900            .unwrap();
5901        rt.link(&tok, b.id, c.id, EdgeRelation::Enables, 1.0, None)
5902            .await
5903            .unwrap();
5904
5905        let counts = rt.count_edges_by_relation(&tok).await.unwrap();
5906        assert_eq!(counts.get("extends").copied(), Some(2));
5907        assert_eq!(counts.get("enables").copied(), Some(1));
5908    }
5909
5910    #[tokio::test]
5911    async fn delete_edge_removes_from_storage() {
5912        let rt = rt();
5913        let tok = NamespaceToken::local();
5914        let a = rt
5915            .create_entity(&tok, "concept", None, "A", None, None, vec![])
5916            .await
5917            .unwrap();
5918        let b = rt
5919            .create_entity(&tok, "concept", None, "B", None, None, vec![])
5920            .await
5921            .unwrap();
5922        let edge = rt
5923            .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
5924            .await
5925            .unwrap();
5926        let edge_id: Uuid = edge.id.into();
5927
5928        let deleted = rt.delete_edge(&tok, edge_id, true).await.unwrap();
5929        assert!(deleted);
5930
5931        let fetched = rt.get_edge(&tok, edge_id).await.unwrap();
5932        assert!(fetched.is_none(), "edge should be gone after delete");
5933    }
5934
5935    #[tokio::test]
5936    async fn count_edges_matches_filter() {
5937        let rt = rt();
5938        let tok = NamespaceToken::local();
5939        let a = rt
5940            .create_entity(&tok, "concept", None, "A", None, None, vec![])
5941            .await
5942            .unwrap();
5943        let b = rt
5944            .create_entity(&tok, "concept", None, "B", None, None, vec![])
5945            .await
5946            .unwrap();
5947        let c = rt
5948            .create_entity(&tok, "concept", None, "C", None, None, vec![])
5949            .await
5950            .unwrap();
5951
5952        rt.link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
5953            .await
5954            .unwrap();
5955        rt.link(&tok, a.id, c.id, EdgeRelation::Enables, 1.0, None)
5956            .await
5957            .unwrap();
5958
5959        let all = rt
5960            .count_edges(&tok, EdgeListFilter::default())
5961            .await
5962            .unwrap();
5963        assert_eq!(all, 2);
5964
5965        let just_extends = rt
5966            .count_edges(
5967                &tok,
5968                EdgeListFilter {
5969                    relations: vec![EdgeRelation::Extends],
5970                    ..Default::default()
5971                },
5972            )
5973            .await
5974            .unwrap();
5975        assert_eq!(just_extends, 1);
5976    }
5977
5978    // ---- substrate_exists_in_ns must use get_edge_visible ----
5979
5980    /// An edge owned by a visible (non-primary) namespace must be found by
5981    /// `substrate_exists_in_ns` and therefore usable as a graph root in
5982    /// `neighbors` and `traverse`.
5983    #[tokio::test]
5984    async fn edge_in_visible_namespace_reachable_as_graph_root() {
5985        let rt = rt();
5986        let ns_a = Namespace::parse("vis-edge-a").unwrap();
5987        let ns_b = Namespace::parse("vis-edge-b").unwrap();
5988
5989        // Create two entities and an edge in namespace B.
5990        let tok_b = NamespaceToken::for_namespace(ns_b.clone());
5991        let src = rt
5992            .create_entity(&tok_b, "concept", None, "SrcB", None, None, vec![])
5993            .await
5994            .unwrap();
5995        let tgt = rt
5996            .create_entity(&tok_b, "concept", None, "TgtB", None, None, vec![])
5997            .await
5998            .unwrap();
5999        let edge = rt
6000            .link(&tok_b, src.id, tgt.id, EdgeRelation::Extends, 1.0, None)
6001            .await
6002            .unwrap();
6003
6004        // Namespace A with B in its visible set should be able to get the
6005        // edge and use it as a traverse root.
6006        let tok_a_vis = rt
6007            .authorize_with_visibility(ns_a.clone(), vec![ns_b.clone()])
6008            .unwrap();
6009
6010        // Direct get of the edge must succeed (visible namespace).
6011        let got = rt.get_edge_visible(&tok_a_vis, edge.id.0).await.unwrap();
6012        assert!(
6013            got.is_some(),
6014            "edge in visible namespace must be retrievable via get_edge_visible"
6015        );
6016
6017        // neighbors/traverse use substrate_exists_in_ns which now calls
6018        // get_edge_visible — they must not return empty for a visible-ns edge root.
6019        let neighbors = rt
6020            .neighbors(&tok_a_vis, src.id, Direction::Out, Some(16), None)
6021            .await
6022            .unwrap();
6023        assert!(
6024            neighbors.iter().any(|h| h.node_id == tgt.id),
6025            "neighbors of visible-ns node must include its visible-ns neighbor; got: {neighbors:?}"
6026        );
6027    }
6028
6029    // By-ID ops do not enforce namespace isolation. Shared-brain OSS model:
6030    // UUID is globally unique; get/update/delete find the record regardless
6031    // of caller's token namespace.
6032    #[tokio::test]
6033    async fn get_entity_cross_namespace_no_longer_denied() {
6034        let rt = rt();
6035        let ns_a = NamespaceToken::for_namespace(Namespace::parse("ns-a").unwrap());
6036        let ns_b = NamespaceToken::for_namespace(Namespace::parse("ns-b").unwrap());
6037        let entity = rt
6038            .create_entity(&ns_a, "concept", None, "Alpha", None, None, vec![])
6039            .await
6040            .unwrap();
6041
6042        // Same namespace: still works.
6043        let found = rt.get_entity(&ns_a, entity.id).await;
6044        assert!(found.is_ok(), "same-namespace get must succeed");
6045
6046        // Different namespace: now also returns the entity (shared brain).
6047        let cross = rt.get_entity(&ns_b, entity.id).await;
6048        assert!(
6049            cross.is_ok(),
6050            "cross-namespace get must succeed in shared-brain OSS (ADR-007 rule 2)"
6051        );
6052        assert_eq!(cross.unwrap().id, entity.id);
6053    }
6054
6055    #[tokio::test]
6056    async fn delete_entity_cross_namespace_no_longer_denied() {
6057        let rt = rt();
6058        let ns_a = NamespaceToken::for_namespace(Namespace::parse("ns-a").unwrap());
6059        let ns_b = NamespaceToken::for_namespace(Namespace::parse("ns-b").unwrap());
6060        let entity = rt
6061            .create_entity(&ns_a, "concept", None, "Beta", None, None, vec![])
6062            .await
6063            .unwrap();
6064
6065        // Cross-namespace delete now succeeds (shared brain).
6066        let cross_ns_result = rt.delete_entity(&ns_b, entity.id, true).await;
6067        assert!(
6068            cross_ns_result.is_ok(),
6069            "cross-namespace delete must succeed in shared-brain OSS; got {:?}",
6070            cross_ns_result
6071        );
6072        assert!(cross_ns_result.unwrap(), "delete must return true");
6073
6074        // Entity is gone — even from the original namespace.
6075        let gone = rt.get_entity(&ns_a, entity.id).await;
6076        assert!(gone.is_err(), "entity must be gone after delete");
6077    }
6078
6079    // ---- Note annotation tests ----
6080
6081    #[tokio::test]
6082    async fn create_note_indexes_into_fts5() {
6083        let rt = rt();
6084        let tok = NamespaceToken::local();
6085        let note = rt
6086            .create_note(
6087                &tok,
6088                "observation",
6089                None,
6090                "FlashAttention reduces memory by using tiling",
6091                Some(0.8),
6092                None,
6093                vec![],
6094            )
6095            .await
6096            .unwrap();
6097
6098        // FTS5 should have indexed the note content.
6099        let ns = tok.namespace().as_str().to_string();
6100        let hits = rt
6101            .text_for_notes(&tok)
6102            .unwrap()
6103            .search(khive_storage::types::TextSearchRequest {
6104                query: "FlashAttention".to_string(),
6105                mode: khive_storage::types::TextQueryMode::Plain,
6106                filter: Some(khive_storage::types::TextFilter {
6107                    namespaces: vec![ns],
6108                    ..Default::default()
6109                }),
6110                top_k: 10,
6111                snippet_chars: 100,
6112            })
6113            .await
6114            .unwrap();
6115
6116        assert!(
6117            hits.iter().any(|h| h.subject_id == note.id),
6118            "note should be indexed in FTS5 after create"
6119        );
6120    }
6121
6122    /// #916: `@` used to reach SQLite FTS5's bareword parser raw and error
6123    /// (`sanitize_fts5_query` did not strip it), surfacing as
6124    /// `RuntimeError::InvalidInput` per #569's fail-loud policy.
6125    /// `sanitize_fts5_token_group`'s bareword-safety gate now routes it
6126    /// through the quoted-phrase alternative instead, so `search_notes`
6127    /// succeeds and finds the seeded content.
6128    #[tokio::test]
6129    async fn search_notes_with_residual_fts5_char_now_sanitized() {
6130        let rt = rt();
6131        let tok = NamespaceToken::local();
6132        rt.create_note(
6133            &tok,
6134            "observation",
6135            None,
6136            "use foo@bar to chain calls",
6137            Some(0.5),
6138            None,
6139            vec![],
6140        )
6141        .await
6142        .unwrap();
6143
6144        let result = rt
6145            .search_notes(&tok, "foo@bar", None, 10, None, false, &[], None)
6146            .await;
6147
6148        let hits = result.unwrap_or_else(|e| {
6149            panic!("#916 search_notes must not fail on an '@'-bearing query, got: {e:?}")
6150        });
6151        assert!(
6152            !hits.is_empty(),
6153            "#916 '@'-bearing query must still find the seeded 'foo@bar' content via the \
6154             quoted-phrase alternative"
6155        );
6156    }
6157
6158    /// The `search_notes` FTS fail-open arm must only degrade genuine FTS5
6159    /// parser syntax errors. A non-parser `StorageError`: e.g. a pool
6160    /// exhaustion or connection timeout on the text-search backend — is not a
6161    /// bad query and must propagate as `Err`, not be silently swallowed into
6162    /// an empty (falsely "successful") result set. `search_notes`,
6163    /// `hybrid_search`, `hybrid_search_with_strategy`, and
6164    /// `collect_recall_text_hits` all share the same `is_fts5_syntax_error()`
6165    /// gate on `StorageError`, so this case generalizes to all four call sites.
6166    #[tokio::test]
6167    async fn search_notes_propagates_non_parser_fts_error() {
6168        let rt = rt();
6169        let tok = NamespaceToken::local();
6170        rt.create_note(
6171            &tok,
6172            "observation",
6173            None,
6174            "FlashAttention reduces memory by using tiling",
6175            Some(0.8),
6176            None,
6177            vec![],
6178        )
6179        .await
6180        .unwrap();
6181
6182        let ns = tok.namespace().as_str().to_string();
6183        arm_fts_search_fail(&ns);
6184
6185        let result = rt
6186            .search_notes(&tok, "FlashAttention", None, 10, None, false, &[], None)
6187            .await;
6188
6189        assert!(
6190            result.is_err(),
6191            "search_notes must propagate a non-parser FTS StorageError (Timeout) \
6192             as Err, not silently degrade it to an empty result, got: {:?}",
6193            result.ok()
6194        );
6195        assert!(
6196            matches!(
6197                result.unwrap_err(),
6198                RuntimeError::Storage(khive_storage::StorageError::Timeout { .. })
6199            ),
6200            "propagated error must be the injected StorageError::Timeout, unwrapped \
6201             through RuntimeError::Storage"
6202        );
6203    }
6204
6205    #[tokio::test]
6206    async fn create_note_with_properties() {
6207        let rt = rt();
6208        let tok = NamespaceToken::local();
6209        let props = serde_json::json!({"source": "arxiv:2205.14135"});
6210        let note = rt
6211            .create_note(
6212                &tok,
6213                "insight",
6214                None,
6215                "FlashAttention is IO-aware",
6216                Some(0.9),
6217                Some(props.clone()),
6218                vec![],
6219            )
6220            .await
6221            .unwrap();
6222
6223        assert_eq!(note.properties.as_ref().unwrap(), &props);
6224    }
6225
6226    #[tokio::test]
6227    async fn create_note_creates_annotates_edges() {
6228        let rt = rt();
6229        let tok = NamespaceToken::local();
6230        let entity = rt
6231            .create_entity(&tok, "concept", None, "FlashAttention", None, None, vec![])
6232            .await
6233            .unwrap();
6234
6235        let note = rt
6236            .create_note(
6237                &tok,
6238                "observation",
6239                None,
6240                "FlashAttention uses SRAM tiling for memory efficiency",
6241                Some(0.9),
6242                None,
6243                vec![entity.id],
6244            )
6245            .await
6246            .unwrap();
6247
6248        // The note should have an outbound `annotates` edge to the entity.
6249        let out_neighbors = rt
6250            .neighbors(
6251                &tok,
6252                note.id,
6253                Direction::Out,
6254                None,
6255                Some(vec![EdgeRelation::Annotates]),
6256            )
6257            .await
6258            .unwrap();
6259        assert_eq!(out_neighbors.len(), 1);
6260        assert_eq!(out_neighbors[0].node_id, entity.id);
6261        assert_eq!(out_neighbors[0].relation, EdgeRelation::Annotates);
6262
6263        // The entity should have an inbound `annotates` edge from the note.
6264        let in_neighbors = rt
6265            .neighbors(
6266                &tok,
6267                entity.id,
6268                Direction::In,
6269                None,
6270                Some(vec![EdgeRelation::Annotates]),
6271            )
6272            .await
6273            .unwrap();
6274        assert_eq!(in_neighbors.len(), 1);
6275        assert_eq!(in_neighbors[0].node_id, note.id);
6276    }
6277
6278    #[tokio::test]
6279    async fn neighbors_without_relation_filter_returns_all() {
6280        let rt = rt();
6281        let tok = NamespaceToken::local();
6282        let a = rt
6283            .create_entity(&tok, "concept", None, "A", None, None, vec![])
6284            .await
6285            .unwrap();
6286        let b = rt
6287            .create_entity(&tok, "concept", None, "B", None, None, vec![])
6288            .await
6289            .unwrap();
6290        let c = rt
6291            .create_entity(&tok, "concept", None, "C", None, None, vec![])
6292            .await
6293            .unwrap();
6294
6295        rt.link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
6296            .await
6297            .unwrap();
6298        rt.link(&tok, a.id, c.id, EdgeRelation::Enables, 1.0, None)
6299            .await
6300            .unwrap();
6301
6302        let all = rt
6303            .neighbors(&tok, a.id, Direction::Out, None, None)
6304            .await
6305            .unwrap();
6306        assert_eq!(all.len(), 2);
6307    }
6308
6309    #[tokio::test]
6310    async fn neighbors_with_relation_filter_returns_subset() {
6311        let rt = rt();
6312        let tok = NamespaceToken::local();
6313        let a = rt
6314            .create_entity(&tok, "concept", None, "A", None, None, vec![])
6315            .await
6316            .unwrap();
6317        let b = rt
6318            .create_entity(&tok, "concept", None, "B", None, None, vec![])
6319            .await
6320            .unwrap();
6321        let c = rt
6322            .create_entity(&tok, "concept", None, "C", None, None, vec![])
6323            .await
6324            .unwrap();
6325
6326        rt.link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
6327            .await
6328            .unwrap();
6329        rt.link(&tok, a.id, c.id, EdgeRelation::Enables, 1.0, None)
6330            .await
6331            .unwrap();
6332
6333        let filtered = rt
6334            .neighbors(
6335                &tok,
6336                a.id,
6337                Direction::Out,
6338                None,
6339                Some(vec![EdgeRelation::Extends]),
6340            )
6341            .await
6342            .unwrap();
6343        assert_eq!(filtered.len(), 1);
6344        assert_eq!(filtered[0].node_id, b.id);
6345        assert_eq!(filtered[0].relation, EdgeRelation::Extends);
6346    }
6347
6348    /// Self-loop direction parity:
6349    /// `neighbors_with_query_directed`'s post-merge dedup must not collapse a
6350    /// self-loop edge's Out row and In row into one — they share `(node_id,
6351    /// edge_id)` but are opposite directions, matching what a separate `Out`
6352    /// call plus a separate `In` call would return for the same edge. The
6353    /// self-loop edge is inserted directly through the graph store (`link()`
6354    /// rejects source_id == target_id) to exercise the merge/dedup path.
6355    #[tokio::test]
6356    async fn neighbors_with_query_directed_preserves_self_loop_direction_parity() {
6357        let rt = rt();
6358        let tok = NamespaceToken::local();
6359        let centre = rt
6360            .create_entity(&tok, "concept", None, "Centre", None, None, vec![])
6361            .await
6362            .unwrap();
6363
6364        let now = chrono::Utc::now();
6365        rt.graph(&tok)
6366            .unwrap()
6367            .upsert_edge(Edge {
6368                id: LinkId::from(Uuid::new_v4()),
6369                namespace: "local".to_string(),
6370                source_id: centre.id,
6371                target_id: centre.id,
6372                relation: EdgeRelation::Extends,
6373                weight: 0.7,
6374                created_at: now,
6375                updated_at: now,
6376                deleted_at: None,
6377                metadata: None,
6378                target_backend: None,
6379            })
6380            .await
6381            .unwrap();
6382
6383        let directed = rt
6384            .neighbors_with_query_directed(
6385                &tok,
6386                centre.id,
6387                NeighborQuery {
6388                    direction: Direction::Both,
6389                    relations: None,
6390                    limit: None,
6391                    min_weight: None,
6392                },
6393            )
6394            .await
6395            .unwrap();
6396
6397        assert_eq!(
6398            directed.len(),
6399            2,
6400            "a self-loop edge must produce both an Out hit and an In hit, not one collapsed hit"
6401        );
6402        let directions: Vec<Direction> = directed.iter().map(|(_, d)| d.clone()).collect();
6403        assert!(
6404            directions.contains(&Direction::Out),
6405            "self-loop must retain its Out-tagged hit"
6406        );
6407        assert!(
6408            directions.contains(&Direction::In),
6409            "self-loop must retain its In-tagged hit"
6410        );
6411    }
6412
6413    #[tokio::test]
6414    async fn search_notes_returns_relevant_note() {
6415        let rt = rt();
6416        let tok = NamespaceToken::local();
6417        rt.create_note(
6418            &tok,
6419            "observation",
6420            None,
6421            "GQA reduces KV cache memory for large models",
6422            Some(0.8),
6423            None,
6424            vec![],
6425        )
6426        .await
6427        .unwrap();
6428
6429        let results = rt
6430            .search_notes(&tok, "GQA KV cache", None, 10, None, false, &[], None)
6431            .await
6432            .unwrap();
6433
6434        assert!(!results.is_empty(), "search should return the indexed note");
6435        let hit = &results[0];
6436        assert!(
6437            hit.title.is_some(),
6438            "note hit title should be populated (falls back to content)"
6439        );
6440        assert!(
6441            hit.snippet.is_some(),
6442            "note hit snippet should be populated"
6443        );
6444    }
6445
6446    #[tokio::test]
6447    async fn search_notes_excludes_soft_deleted() {
6448        let rt = rt();
6449        let tok = NamespaceToken::local();
6450        let note = rt
6451            .create_note(
6452                &tok,
6453                "observation",
6454                None,
6455                "RoPE positional encoding rotary embeddings",
6456                Some(0.7),
6457                None,
6458                vec![],
6459            )
6460            .await
6461            .unwrap();
6462
6463        // Soft-delete the note.
6464        rt.notes(&tok)
6465            .unwrap()
6466            .delete_note(note.id, DeleteMode::Soft)
6467            .await
6468            .unwrap();
6469
6470        let results = rt
6471            .search_notes(
6472                &tok,
6473                "RoPE rotary positional",
6474                None,
6475                10,
6476                None,
6477                false,
6478                &[],
6479                None,
6480            )
6481            .await
6482            .unwrap();
6483
6484        assert!(
6485            results.iter().all(|h| h.note_id != note.id),
6486            "soft-deleted note should be excluded from search"
6487        );
6488    }
6489
6490    // ---- predicate pushdown before truncation (note branch) ----
6491
6492    /// Regression: notes store tags inside `properties["tags"]`: there is no
6493    /// separate tags column. Without pushdown, the tag filter is applied after
6494    /// `hits.truncate(limit)`, so a tag-matching note ranked beyond `limit` in
6495    /// the raw RRF fusion is silently dropped.
6496    ///
6497    /// Scenario: `limit=1`, tags_any=["note-target-tag"]. Two notes are inserted:
6498    ///   - decoy: high FTS rank (repeats query terms), NO target tag.
6499    ///   - target: lower FTS rank, HAS "note-target-tag" in `properties["tags"]`.
6500    ///
6501    /// Without pushdown: decoy occupies the slot, target is dropped.
6502    /// With pushdown: decoy is excluded in the alive-note loop, target survives, returned.
6503    #[tokio::test]
6504    async fn search_notes_tag_filter_pushed_before_truncation() {
6505        let rt = rt();
6506        let tok = NamespaceToken::local();
6507
6508        // Decoy note: repeats query tokens → higher FTS rank. No target tag.
6509        rt.create_note(
6510            &tok,
6511            "observation",
6512            None,
6513            "kappa lambda mu note decoy kappa lambda mu note decoy kappa lambda mu",
6514            Some(0.5),
6515            Some(serde_json::json!({"tags": ["other-note-tag"]})),
6516            vec![],
6517        )
6518        .await
6519        .unwrap();
6520
6521        // Target note: fewer query tokens → lower FTS rank. Has the target tag.
6522        let target = rt
6523            .create_note(
6524                &tok,
6525                "observation",
6526                None,
6527                "kappa lambda mu note target",
6528                Some(0.5),
6529                Some(serde_json::json!({"tags": ["note-target-tag"]})),
6530                vec![],
6531            )
6532            .await
6533            .unwrap();
6534
6535        // With limit=1 and tags_any, the fix must return the target note despite the
6536        // decoy ranking higher in raw FTS.
6537        let hits = rt
6538            .search_notes(
6539                &tok,
6540                "kappa lambda mu note",
6541                None,
6542                1,
6543                None,
6544                false,
6545                &["note-target-tag".to_string()],
6546                None,
6547            )
6548            .await
6549            .unwrap();
6550
6551        assert_eq!(
6552            hits.len(),
6553            1,
6554            "exactly one hit expected (tag-matching note)"
6555        );
6556        assert_eq!(
6557            hits[0].note_id, target.id,
6558            "tag-filtered note must be returned even when ranked below limit in raw fusion"
6559        );
6560    }
6561
6562    /// Regression: without pushdown, the properties filter is applied after truncation; a matching
6563    /// note ranked beyond `limit` is silently dropped.
6564    ///
6565    /// Scenario: `limit=1`, properties_filter={{"source": "target"}}. Two notes:
6566    ///   - decoy: high FTS rank, properties {{"source": "other"}}.
6567    ///   - target: lower FTS rank, properties {{"source": "target"}}.
6568    #[tokio::test]
6569    async fn search_notes_props_filter_pushed_before_truncation() {
6570        let rt = rt();
6571        let tok = NamespaceToken::local();
6572
6573        rt.create_note(
6574            &tok,
6575            "observation",
6576            None,
6577            "nu xi omicron note decoy nu xi omicron note decoy nu xi omicron",
6578            Some(0.5),
6579            Some(serde_json::json!({"source": "other"})),
6580            vec![],
6581        )
6582        .await
6583        .unwrap();
6584
6585        let target = rt
6586            .create_note(
6587                &tok,
6588                "observation",
6589                None,
6590                "nu xi omicron note target",
6591                Some(0.5),
6592                Some(serde_json::json!({"source": "target"})),
6593                vec![],
6594            )
6595            .await
6596            .unwrap();
6597
6598        let filter = serde_json::json!({"source": "target"});
6599        let hits = rt
6600            .search_notes(
6601                &tok,
6602                "nu xi omicron note",
6603                None,
6604                1,
6605                None,
6606                false,
6607                &[],
6608                Some(&filter),
6609            )
6610            .await
6611            .unwrap();
6612
6613        assert_eq!(
6614            hits.len(),
6615            1,
6616            "exactly one hit expected (properties-matching note)"
6617        );
6618        assert_eq!(
6619            hits[0].note_id, target.id,
6620            "properties-filtered note must be returned even when ranked below limit"
6621        );
6622    }
6623
6624    #[tokio::test]
6625    async fn resolve_returns_entity() {
6626        let rt = rt();
6627        let tok = NamespaceToken::local();
6628        let entity = rt
6629            .create_entity(&tok, "concept", None, "LoRA", None, None, vec![])
6630            .await
6631            .unwrap();
6632
6633        let resolved = rt.resolve(&tok, entity.id).await.unwrap();
6634        match resolved {
6635            Some(Resolved::Entity(e)) => assert_eq!(e.id, entity.id),
6636            other => panic!("expected Resolved::Entity, got {:?}", other),
6637        }
6638    }
6639
6640    #[tokio::test]
6641    async fn resolve_returns_note() {
6642        let rt = rt();
6643        let tok = NamespaceToken::local();
6644        let note = rt
6645            .create_note(
6646                &tok,
6647                "observation",
6648                None,
6649                "LoRA fine-tunes LLMs with low-rank adapters",
6650                Some(0.85),
6651                None,
6652                vec![],
6653            )
6654            .await
6655            .unwrap();
6656
6657        let resolved = rt.resolve(&tok, note.id).await.unwrap();
6658        match resolved {
6659            Some(Resolved::Note(n)) => assert_eq!(n.id, note.id),
6660            other => panic!("expected Resolved::Note, got {:?}", other),
6661        }
6662    }
6663
6664    #[tokio::test]
6665    async fn resolve_returns_none_for_unknown_uuid() {
6666        let rt = rt();
6667        let tok = NamespaceToken::local();
6668        let unknown = Uuid::new_v4();
6669        let resolved = rt.resolve(&tok, unknown).await.unwrap();
6670        assert!(resolved.is_none(), "unknown UUID should resolve to None");
6671    }
6672
6673    #[tokio::test]
6674    async fn resolve_prefix_finds_entity_in_own_namespace() {
6675        let rt = rt();
6676        let tok = NamespaceToken::local();
6677        let entity = rt
6678            .create_entity(&tok, "concept", None, "PrefixTest", None, None, vec![])
6679            .await
6680            .unwrap();
6681        let prefix = &entity.id.to_string()[..8];
6682
6683        let resolved = rt.resolve_prefix(&tok, prefix).await.unwrap();
6684        assert_eq!(resolved, Some(entity.id));
6685    }
6686
6687    #[test]
6688    fn hex_prefix_to_uuid_pattern_inserts_hyphens_at_canonical_boundaries() {
6689        let full = "aabbccdd112240008000000000000ab1";
6690        let cases: &[(usize, &str)] = &[
6691            (1, "a"),
6692            (7, "aabbccd"),
6693            (8, "aabbccdd"),
6694            (9, "aabbccdd-1"),
6695            (12, "aabbccdd-1122"),
6696            (13, "aabbccdd-1122-4"),
6697            (16, "aabbccdd-1122-4000"),
6698            (18, "aabbccdd-1122-4000-80"),
6699            (20, "aabbccdd-1122-4000-8000"),
6700            (23, "aabbccdd-1122-4000-8000-000"),
6701            (24, "aabbccdd-1122-4000-8000-0000"),
6702            (28, "aabbccdd-1122-4000-8000-00000000"),
6703            (31, "aabbccdd-1122-4000-8000-000000000ab"),
6704        ];
6705        for (len, expected) in cases {
6706            let input = &full[..*len];
6707            assert_eq!(
6708                hex_prefix_to_uuid_pattern(input),
6709                *expected,
6710                "len={len} input={input:?}"
6711            );
6712        }
6713    }
6714
6715    #[test]
6716    fn hex_prefix_to_uuid_pattern_full_32_char_matches_canonical_uuid() {
6717        let compact = "aabbccdd112240008000000000000ab1";
6718        let compact32 = &compact[..32];
6719        assert_eq!(
6720            hex_prefix_to_uuid_pattern(compact32),
6721            "aabbccdd-1122-4000-8000-000000000ab1"
6722        );
6723    }
6724
6725    /// Input longer than 32 hex chars is NOT truncated: the extra chars land
6726    /// past the canonical 12-char final
6727    /// segment with no further hyphen, so the pattern can never match a real
6728    /// (36-char) stored `id`, instead of silently truncating down to a
6729    /// pattern that matches the valid 32-char UUID.
6730    #[test]
6731    fn hex_prefix_to_uuid_pattern_overlong_input_is_not_truncated() {
6732        let compact32 = "aabbccdd112240008000000000000ab1";
6733        let overlong = format!("{compact32}extrahex");
6734        let pattern = hex_prefix_to_uuid_pattern(&overlong);
6735        assert_eq!(
6736            pattern, "aabbccdd-1122-4000-8000-000000000ab1extrahex",
6737            "overlong input must keep its extra chars, not truncate to the valid UUID"
6738        );
6739        assert_ne!(
6740            pattern, "aabbccdd-1122-4000-8000-000000000ab1",
6741            "overlong pattern must not collapse to the canonical 36-char UUID form"
6742        );
6743    }
6744
6745    #[test]
6746    fn hex_prefix_to_uuid_pattern_passes_through_hyphenated_input() {
6747        let hyphenated = "aabbccdd-1122-4000-8000-000000000ab1";
6748        assert_eq!(hex_prefix_to_uuid_pattern(hyphenated), hyphenated);
6749
6750        let partial = "aabbccdd-11";
6751        assert_eq!(hex_prefix_to_uuid_pattern(partial), partial);
6752    }
6753
6754    #[tokio::test]
6755    async fn resolve_prefix_compact_9_to_31_char_matches() {
6756        let rt = rt();
6757        let tok = NamespaceToken::local();
6758        let entity = rt
6759            .create_entity(
6760                &tok,
6761                "concept",
6762                None,
6763                "CompactPrefixTest",
6764                None,
6765                None,
6766                vec![],
6767            )
6768            .await
6769            .unwrap();
6770        let compact = entity.id.simple().to_string();
6771
6772        for len in [9, 12, 16, 20, 24, 28, 31] {
6773            let prefix = &compact[..len];
6774            let resolved = rt.resolve_prefix(&tok, prefix).await.unwrap();
6775            assert_eq!(
6776                resolved,
6777                Some(entity.id),
6778                "compact prefix of len {len} should resolve"
6779            );
6780        }
6781    }
6782
6783    #[tokio::test]
6784    async fn resolve_prefix_compact_full_32_char_matches() {
6785        let rt = rt();
6786        let tok = NamespaceToken::local();
6787        let entity = rt
6788            .create_entity(&tok, "concept", None, "Full32Test", None, None, vec![])
6789            .await
6790            .unwrap();
6791        let compact = entity.id.simple().to_string();
6792        assert_eq!(compact.len(), 32);
6793
6794        let resolved = rt.resolve_prefix(&tok, &compact).await.unwrap();
6795        assert_eq!(resolved, Some(entity.id));
6796    }
6797
6798    /// A valid 32-char compact id with extra trailing hex chars appended must
6799    /// fail to resolve, not silently resolve to the valid entity via truncation.
6800    #[tokio::test]
6801    async fn resolve_prefix_rejects_overlong_all_hex_input() {
6802        let rt = rt();
6803        let tok = NamespaceToken::local();
6804        let entity = rt
6805            .create_entity(&tok, "concept", None, "OverlongTest", None, None, vec![])
6806            .await
6807            .unwrap();
6808        let compact = entity.id.simple().to_string();
6809        assert_eq!(compact.len(), 32);
6810
6811        let overlong = format!("{compact}ab");
6812        let resolved = rt.resolve_prefix(&tok, &overlong).await.unwrap();
6813        assert_eq!(
6814            resolved, None,
6815            "a 32-char id plus extra hex chars must not resolve to the valid entity"
6816        );
6817    }
6818
6819    /// The `resolve_prefix*` boundary rejects
6820    /// non-hex/non-hyphen input (e.g. LIKE wildcards `%`/`_`) instead of
6821    /// letting it reach the bound `LIKE` pattern unfiltered — covers callers
6822    /// (like a git-integration pack's ingest path) that resolve raw input without
6823    /// their own all-hex gate.
6824    #[tokio::test]
6825    async fn resolve_prefix_rejects_like_wildcard_input() {
6826        let rt = rt();
6827        let tok = NamespaceToken::local();
6828        let entity = rt
6829            .create_entity(&tok, "concept", None, "WildcardTest", None, None, vec![])
6830            .await
6831            .unwrap();
6832        let compact = entity.id.simple().to_string();
6833        // A caller that forgot to hex-gate might pass a `%`-bearing string
6834        // straight through, hoping to broaden a scan; the resolver boundary
6835        // must reject it instead of running it as a wildcard LIKE.
6836        let wildcard_prefix = format!("{}%", &compact[..8]);
6837
6838        let resolved = rt.resolve_prefix(&tok, &wildcard_prefix).await.unwrap();
6839        assert_eq!(
6840            resolved, None,
6841            "prefix containing a LIKE wildcard must be rejected, not resolved"
6842        );
6843    }
6844
6845    #[tokio::test]
6846    async fn resolve_prefix_boundary_at_hyphen_positions() {
6847        let rt = rt();
6848        let tok = NamespaceToken::local();
6849        let entity = rt
6850            .create_entity(&tok, "concept", None, "BoundaryTest", None, None, vec![])
6851            .await
6852            .unwrap();
6853        let compact = entity.id.simple().to_string();
6854
6855        for len in [8, 12, 16, 20, 24] {
6856            let prefix = &compact[..len];
6857            let resolved = rt.resolve_prefix(&tok, prefix).await.unwrap();
6858            assert_eq!(
6859                resolved,
6860                Some(entity.id),
6861                "boundary prefix of len {len} should resolve"
6862            );
6863        }
6864    }
6865
6866    #[tokio::test]
6867    async fn resolve_prefix_ambiguous_still_detected_after_normalization() {
6868        use khive_storage::entity::Entity;
6869
6870        let rt = rt();
6871        let tok = NamespaceToken::local();
6872        let id_a = Uuid::parse_str("aabbccdd-1111-4000-8000-000000000001").unwrap();
6873        let id_b = Uuid::parse_str("aabbccdd-1111-4000-8000-000000000002").unwrap();
6874
6875        let mut entity_a = Entity::new("local", "concept", "AmbigCompactA");
6876        entity_a.id = id_a;
6877        let mut entity_b = Entity::new("local", "concept", "AmbigCompactB");
6878        entity_b.id = id_b;
6879
6880        let store = rt.entities(&tok).unwrap();
6881        store.upsert_entity(entity_a).await.unwrap();
6882        store.upsert_entity(entity_b).await.unwrap();
6883
6884        // Shared 20-char compact prefix (past the first hyphen boundary).
6885        let shared_compact = &id_a.simple().to_string()[..20];
6886        let err = rt.resolve_prefix(&tok, shared_compact).await.unwrap_err();
6887        assert!(
6888            matches!(
6889                err,
6890                RuntimeError::AmbiguousPrefix { ref matches, .. } if matches.len() == 2
6891            ),
6892            "shared compact prefix must still return AmbiguousPrefix; got {err:?}"
6893        );
6894    }
6895
6896    #[tokio::test]
6897    async fn resolve_prefix_invisible_across_namespaces() {
6898        let rt = rt();
6899        let ns_a = NamespaceToken::for_namespace(Namespace::parse("ns-a").unwrap());
6900        let ns_b = NamespaceToken::for_namespace(Namespace::parse("ns-b").unwrap());
6901        let entity = rt
6902            .create_entity(&ns_a, "concept", None, "Invisible", None, None, vec![])
6903            .await
6904            .unwrap();
6905        let prefix = &entity.id.to_string()[..8];
6906
6907        // From ns_b, the entity in ns_a should not be visible.
6908        let resolved = rt.resolve_prefix(&ns_b, prefix).await.unwrap();
6909        assert_eq!(resolved, None);
6910    }
6911
6912    #[tokio::test]
6913    async fn resolve_prefix_ambiguous_same_namespace() {
6914        use khive_storage::entity::Entity;
6915
6916        let rt = rt();
6917        let tok = NamespaceToken::local();
6918        // Two entities with UUIDs sharing the same 8-char prefix "aabbccdd".
6919        let id_a = Uuid::parse_str("aabbccdd-1111-4000-8000-000000000001").unwrap();
6920        let id_b = Uuid::parse_str("aabbccdd-2222-4000-8000-000000000002").unwrap();
6921
6922        let mut entity_a = Entity::new("local", "concept", "AmbigA");
6923        entity_a.id = id_a;
6924        let mut entity_b = Entity::new("local", "concept", "AmbigB");
6925        entity_b.id = id_b;
6926
6927        let store = rt.entities(&tok).unwrap();
6928        store.upsert_entity(entity_a).await.unwrap();
6929        store.upsert_entity(entity_b).await.unwrap();
6930
6931        let err = rt.resolve_prefix(&tok, "aabbccdd").await.unwrap_err();
6932        assert!(
6933            matches!(
6934                err,
6935                RuntimeError::AmbiguousPrefix { ref prefix, ref matches }
6936                    if prefix == "aabbccdd" && matches.len() == 2
6937            ),
6938            "shared 8-char prefix must return AmbiguousPrefix; got {err:?}"
6939        );
6940    }
6941
6942    /// A single UUID legitimately present in TWO scanned tables (entities and
6943    /// notes here) must resolve cleanly to that one UUID, not a false
6944    /// `AmbiguousPrefix` naming the same UUID twice: without cross-table
6945    /// dedup, `matches.len()` becomes 2 for a single record.
6946    #[tokio::test]
6947    async fn resolve_prefix_cross_table_duplicate_uuid_resolves_cleanly() {
6948        use khive_storage::entity::Entity;
6949
6950        let rt = rt();
6951        let tok = NamespaceToken::local();
6952        let shared_id = Uuid::parse_str("ccddeeff-1111-4000-8000-000000000001").unwrap();
6953
6954        let mut entity = Entity::new("local", "concept", "Nvk749Entity");
6955        entity.id = shared_id;
6956        rt.entities(&tok)
6957            .unwrap()
6958            .upsert_entity(entity)
6959            .await
6960            .unwrap();
6961
6962        let mut note = Note::new("local", "observation", "nvk749 note with the same id");
6963        note.id = shared_id;
6964        rt.notes(&tok).unwrap().upsert_note(note).await.unwrap();
6965
6966        let resolved = rt
6967            .resolve_prefix(&tok, "ccddeeff")
6968            .await
6969            .expect("#749: a UUID present in two tables must not be reported as ambiguous");
6970        assert_eq!(
6971            resolved,
6972            Some(shared_id),
6973            "#749: cross-table duplicate must resolve to the single shared UUID"
6974        );
6975    }
6976
6977    /// The early-exit inside the per-table scan loop (`if matches.len()
6978    /// > 1 { break }`) must also operate on DEDUPED state — otherwise a
6979    /// cross-table duplicate could still short-circuit the scan before a
6980    /// later table contributes the SAME UUID again, which would have masked
6981    /// the bug rather than exercising it. This drives the duplicate through
6982    /// the earliest two tables scanned (entities, notes) so the early-exit
6983    /// path is the one under test, not a post-loop dedup applied too late.
6984    #[tokio::test]
6985    async fn resolve_prefix_early_exit_uses_deduped_match_count() {
6986        use khive_storage::entity::Entity;
6987
6988        let rt = rt();
6989        let tok = NamespaceToken::local();
6990        let shared_id = Uuid::parse_str("ddeeff11-2222-4000-8000-000000000002").unwrap();
6991
6992        // entities and notes are the first two tables scanned inside
6993        // resolve_prefix_inner — the same UUID in both must not trip the
6994        // mid-scan `matches.len() > 1` break as if two distinct UUIDs had
6995        // been found.
6996        let mut entity = Entity::new("local", "concept", "Nvk749bEntity");
6997        entity.id = shared_id;
6998        rt.entities(&tok)
6999            .unwrap()
7000            .upsert_entity(entity)
7001            .await
7002            .unwrap();
7003
7004        let mut note = Note::new("local", "observation", "nvk749b note with the same id");
7005        note.id = shared_id;
7006        rt.notes(&tok).unwrap().upsert_note(note).await.unwrap();
7007
7008        let resolved = rt
7009            .resolve_prefix(&tok, "ddeeff11")
7010            .await
7011            .expect("#749: deduped early-exit must not falsely report ambiguity");
7012        assert_eq!(resolved, Some(shared_id));
7013    }
7014
7015    // ---- Event resolution tests ----
7016    //
7017    // resolve_prefix and handle_get already include events; these tests are
7018    // regression coverage confirming event UUIDs are resolvable and that get()
7019    // returns kind="event".
7020
7021    #[tokio::test]
7022    async fn resolve_finds_event_by_full_uuid() {
7023        use khive_storage::Event;
7024        use khive_types::{EventKind, SubstrateKind};
7025
7026        let rt = rt();
7027        let tok = NamespaceToken::local();
7028        let ns = tok.namespace().as_str();
7029        let event = Event::new(
7030            ns,
7031            "test_verb",
7032            EventKind::Audit,
7033            SubstrateKind::Entity,
7034            "actor",
7035        );
7036        let event_id = event.id;
7037        rt.events(&tok).unwrap().append_event(event).await.unwrap();
7038
7039        let resolved = rt.resolve(&tok, event_id).await.unwrap();
7040        assert!(
7041            matches!(resolved, Some(Resolved::Event(_))),
7042            "event UUID must resolve to Resolved::Event, got {resolved:?}"
7043        );
7044    }
7045
7046    #[tokio::test]
7047    async fn resolve_prefix_finds_event() {
7048        use khive_storage::Event;
7049        use khive_types::{EventKind, SubstrateKind};
7050
7051        let rt = rt();
7052        let tok = NamespaceToken::local();
7053        let ns = tok.namespace().as_str();
7054        let event = Event::new(
7055            ns,
7056            "test_verb",
7057            EventKind::Audit,
7058            SubstrateKind::Entity,
7059            "actor",
7060        );
7061        let event_id = event.id;
7062        rt.events(&tok).unwrap().append_event(event).await.unwrap();
7063
7064        let prefix = &event_id.to_string()[..8];
7065        let resolved = rt.resolve_prefix(&tok, prefix).await.unwrap();
7066        assert_eq!(
7067            resolved,
7068            Some(event_id),
7069            "resolve_prefix must return event UUID for 8-char prefix"
7070        );
7071    }
7072
7073    // ---- Referential integrity tests ----
7074
7075    #[tokio::test]
7076    async fn link_phantom_source_returns_not_found() {
7077        let rt = rt();
7078        let tok = NamespaceToken::local();
7079        let b = rt
7080            .create_entity(&tok, "concept", None, "B", None, None, vec![])
7081            .await
7082            .unwrap();
7083        let phantom = Uuid::new_v4();
7084
7085        let result = rt
7086            .link(&tok, phantom, b.id, EdgeRelation::Extends, 1.0, None)
7087            .await;
7088        match result {
7089            Err(RuntimeError::NotFound(msg)) => {
7090                assert!(
7091                    msg.contains("source"),
7092                    "error message must name 'source': {msg}"
7093                );
7094            }
7095            other => panic!("expected NotFound for phantom source, got {other:?}"),
7096        }
7097    }
7098
7099    #[tokio::test]
7100    async fn link_phantom_target_returns_not_found() {
7101        let rt = rt();
7102        let tok = NamespaceToken::local();
7103        let a = rt
7104            .create_entity(&tok, "concept", None, "A", None, None, vec![])
7105            .await
7106            .unwrap();
7107        let phantom = Uuid::new_v4();
7108
7109        let result = rt
7110            .link(&tok, a.id, phantom, EdgeRelation::Extends, 1.0, None)
7111            .await;
7112        match result {
7113            Err(RuntimeError::NotFound(msg)) => {
7114                assert!(
7115                    msg.contains("target"),
7116                    "error message must name 'target': {msg}"
7117                );
7118            }
7119            other => panic!("expected NotFound for phantom target, got {other:?}"),
7120        }
7121    }
7122
7123    #[tokio::test]
7124    async fn link_real_entities_succeeds() {
7125        let rt = rt();
7126        let tok = NamespaceToken::local();
7127        let a = rt
7128            .create_entity(&tok, "concept", None, "A", None, None, vec![])
7129            .await
7130            .unwrap();
7131        let b = rt
7132            .create_entity(&tok, "concept", None, "B", None, None, vec![])
7133            .await
7134            .unwrap();
7135
7136        let edge = rt
7137            .link(&tok, a.id, b.id, EdgeRelation::Extends, 0.8, None)
7138            .await
7139            .unwrap();
7140        assert_eq!(edge.source_id, a.id);
7141        assert_eq!(edge.target_id, b.id);
7142        assert_eq!(edge.relation, EdgeRelation::Extends);
7143    }
7144
7145    // ---- commit-time endpoint guard vs concurrent hard-delete ----
7146
7147    /// Deterministic form of the regression, exercised directly at the
7148    /// write step `link` performs after prepare-time validation: build the
7149    /// exact `Edge` `link` would build, delete the target the way a
7150    /// concurrent racer would, and confirm the guarded write refuses it.
7151    #[tokio::test]
7152    async fn link_write_time_guard_blocks_dangling_edge_after_target_vanishes() {
7153        let rt = rt();
7154        let tok = NamespaceToken::local();
7155        let a = rt
7156            .create_entity(&tok, "concept", None, "A", None, None, vec![])
7157            .await
7158            .unwrap();
7159        let x = rt
7160            .create_entity(&tok, "concept", None, "X", None, None, vec![])
7161            .await
7162            .unwrap();
7163
7164        rt.validate_edge_relation_endpoints(&tok, a.id, x.id, EdgeRelation::Extends)
7165            .await
7166            .expect("prepare-time validation must pass while X is live");
7167
7168        assert!(rt.delete_entity(&tok, x.id, true).await.unwrap());
7169
7170        let now = chrono::Utc::now();
7171        let edge = Edge {
7172            id: LinkId::from(Uuid::new_v4()),
7173            namespace: tok.namespace().as_str().to_string(),
7174            source_id: a.id,
7175            target_id: x.id,
7176            relation: EdgeRelation::Extends,
7177            weight: 1.0,
7178            created_at: now,
7179            updated_at: now,
7180            deleted_at: None,
7181            metadata: None,
7182            target_backend: None,
7183        };
7184        let outcome = rt
7185            .graph(&tok)
7186            .unwrap()
7187            .upsert_edge_guarded(edge)
7188            .await
7189            .unwrap();
7190        match outcome {
7191            khive_storage::GuardedWriteOutcome::Refused(missing) => {
7192                assert!(missing.target, "target must be reported missing");
7193            }
7194            other => panic!(
7195                "guarded write must refuse an edge whose target vanished before commit, got {other:?}"
7196            ),
7197        }
7198
7199        let edges = rt
7200            .list_edges(
7201                &tok,
7202                crate::curation::EdgeListFilter {
7203                    source_id: Some(a.id),
7204                    target_id: Some(x.id),
7205                    relations: vec![EdgeRelation::Extends],
7206                    ..Default::default()
7207                },
7208                10,
7209                0,
7210            )
7211            .await
7212            .unwrap();
7213        assert!(
7214            edges.is_empty(),
7215            "no dangling edge may be persisted after the guarded write refused it"
7216        );
7217    }
7218
7219    #[tokio::test]
7220    async fn link_many_writes_nothing_when_one_target_vanishes_before_write() {
7221        let rt = rt();
7222        let tok = NamespaceToken::local();
7223        let a = rt
7224            .create_entity(&tok, "concept", None, "A", None, None, vec![])
7225            .await
7226            .unwrap();
7227        let b = rt
7228            .create_entity(&tok, "concept", None, "B", None, None, vec![])
7229            .await
7230            .unwrap();
7231        let x = rt
7232            .create_entity(&tok, "concept", None, "X", None, None, vec![])
7233            .await
7234            .unwrap();
7235
7236        let specs = vec![
7237            LinkSpec {
7238                namespace: None,
7239                source_id: a.id,
7240                target_id: x.id,
7241                relation: EdgeRelation::Extends,
7242                weight: 1.0,
7243                metadata: None,
7244            },
7245            LinkSpec {
7246                namespace: None,
7247                source_id: a.id,
7248                target_id: b.id,
7249                relation: EdgeRelation::Extends,
7250                weight: 1.0,
7251                metadata: None,
7252            },
7253        ];
7254
7255        // Both specs validate fine at build_edge time (X and B both live).
7256        let mut edges = Vec::with_capacity(specs.len());
7257        for spec in &specs {
7258            edges.push(rt.build_edge(&tok, spec).await.unwrap());
7259        }
7260
7261        // X vanishes before the batched write — mirrors a concurrent
7262        // hard-delete landing between per-spec validation and link_many's
7263        // single guarded batch write.
7264        assert!(rt.delete_entity(&tok, x.id, true).await.unwrap());
7265
7266        let outcome = rt
7267            .graph(&tok)
7268            .unwrap()
7269            .upsert_edges_guarded(edges)
7270            .await
7271            .unwrap();
7272        assert_eq!(
7273            outcome.summary.affected, 0,
7274            "no edge from the batch may be persisted when any endpoint vanished"
7275        );
7276        assert!(
7277            outcome.refused.is_some(),
7278            "refused batch entry must be reported"
7279        );
7280
7281        let edges = rt
7282            .list_edges(
7283                &tok,
7284                crate::curation::EdgeListFilter {
7285                    source_id: Some(a.id),
7286                    relations: vec![EdgeRelation::Extends],
7287                    ..Default::default()
7288                },
7289                10,
7290                0,
7291            )
7292            .await
7293            .unwrap();
7294        assert!(
7295            edges.is_empty(),
7296            "link_many's guarded batch must be all-or-nothing: the live A-B edge \
7297             must not have been persisted alongside the doomed A-X edge"
7298        );
7299    }
7300
7301    // ---- hard-delete row + incident-edge purge is ONE transaction ----
7302    //
7303    // Six tests below cover both orderings (write-then-delete, and a
7304    // concurrent write raced against delete via `tokio::join!`) across all
7305    // three hard-delete paths that cascade-purge incident edges: entity,
7306    // note, and edge-as-node. No sleeps — the "concurrent" tests assert an
7307    // invariant that must hold for EITHER interleaving the async scheduler
7308    // picks, rather than forcing one specific interleaving, so they are
7309    // deterministic (never flaky) without a barrier.
7310
7311    fn raw_edge(source_id: Uuid, target_id: Uuid, ns: &str) -> Edge {
7312        let now = chrono::Utc::now();
7313        Edge {
7314            id: LinkId::from(Uuid::new_v4()),
7315            namespace: ns.to_string(),
7316            source_id,
7317            target_id,
7318            relation: EdgeRelation::Extends,
7319            weight: 1.0,
7320            created_at: now,
7321            updated_at: now,
7322            deleted_at: None,
7323            metadata: None,
7324            target_backend: None,
7325        }
7326    }
7327
7328    async fn assert_no_edges_touch(rt: &KhiveRuntime, tok: &NamespaceToken, node_id: Uuid) {
7329        let as_source = rt
7330            .list_edges(
7331                tok,
7332                crate::curation::EdgeListFilter {
7333                    source_id: Some(node_id),
7334                    ..Default::default()
7335                },
7336                10,
7337                0,
7338            )
7339            .await
7340            .unwrap();
7341        let as_target = rt
7342            .list_edges(
7343                tok,
7344                crate::curation::EdgeListFilter {
7345                    target_id: Some(node_id),
7346                    ..Default::default()
7347                },
7348                10,
7349                0,
7350            )
7351            .await
7352            .unwrap();
7353        assert!(
7354            as_source.is_empty() && as_target.is_empty(),
7355            "no edge may reference hard-deleted node {node_id}: source-side={as_source:?} \
7356             target-side={as_target:?}"
7357        );
7358    }
7359
7360    #[tokio::test]
7361    async fn hard_delete_entity_purges_edge_written_before_delete() {
7362        let rt = rt();
7363        let tok = NamespaceToken::local();
7364        let a = rt
7365            .create_entity(&tok, "concept", None, "A", None, None, vec![])
7366            .await
7367            .unwrap();
7368        let x = rt
7369            .create_entity(&tok, "concept", None, "X", None, None, vec![])
7370            .await
7371            .unwrap();
7372
7373        let edge = raw_edge(a.id, x.id, tok.namespace().as_str());
7374        assert_eq!(
7375            rt.graph(&tok)
7376                .unwrap()
7377                .upsert_edge_guarded(edge)
7378                .await
7379                .unwrap(),
7380            khive_storage::GuardedWriteOutcome::Written
7381        );
7382
7383        assert!(rt.delete_entity(&tok, x.id, true).await.unwrap());
7384        assert_no_edges_touch(&rt, &tok, x.id).await;
7385    }
7386
7387    #[tokio::test]
7388    async fn hard_delete_entity_concurrent_with_guarded_write_never_leaves_dangling_edge() {
7389        let rt = std::sync::Arc::new(rt());
7390        let tok = NamespaceToken::local();
7391        let a = rt
7392            .create_entity(&tok, "concept", None, "A", None, None, vec![])
7393            .await
7394            .unwrap();
7395        let x = rt
7396            .create_entity(&tok, "concept", None, "X", None, None, vec![])
7397            .await
7398            .unwrap();
7399
7400        let delete_rt = std::sync::Arc::clone(&rt);
7401        let delete_tok = tok.clone();
7402        let delete_task =
7403            tokio::spawn(async move { delete_rt.delete_entity(&delete_tok, x.id, true).await });
7404
7405        let write_rt = std::sync::Arc::clone(&rt);
7406        let write_tok = tok.clone();
7407        let ns = tok.namespace().as_str().to_string();
7408        let write_task = tokio::spawn(async move {
7409            let edge = raw_edge(a.id, x.id, &ns);
7410            write_rt
7411                .graph(&write_tok)
7412                .unwrap()
7413                .upsert_edge_guarded(edge)
7414                .await
7415        });
7416
7417        let (deleted, _written) = tokio::join!(delete_task, write_task);
7418        deleted.unwrap().unwrap();
7419        assert_no_edges_touch(&rt, &tok, x.id).await;
7420    }
7421
7422    #[tokio::test]
7423    async fn hard_delete_note_purges_edge_written_before_delete() {
7424        let rt = rt();
7425        let tok = NamespaceToken::local();
7426        let a = rt
7427            .create_entity(&tok, "concept", None, "A", None, None, vec![])
7428            .await
7429            .unwrap();
7430        let n = rt
7431            .create_note(
7432                &tok,
7433                "observation",
7434                None,
7435                "note content",
7436                None,
7437                None,
7438                vec![],
7439            )
7440            .await
7441            .unwrap();
7442
7443        let edge = raw_edge(a.id, n.id, tok.namespace().as_str());
7444        assert_eq!(
7445            rt.graph(&tok)
7446                .unwrap()
7447                .upsert_edge_guarded(edge)
7448                .await
7449                .unwrap(),
7450            khive_storage::GuardedWriteOutcome::Written
7451        );
7452
7453        assert!(rt.delete_note(&tok, n.id, true).await.unwrap());
7454        assert_no_edges_touch(&rt, &tok, n.id).await;
7455    }
7456
7457    #[tokio::test]
7458    async fn hard_delete_note_concurrent_with_guarded_write_never_leaves_dangling_edge() {
7459        let rt = std::sync::Arc::new(rt());
7460        let tok = NamespaceToken::local();
7461        let a = rt
7462            .create_entity(&tok, "concept", None, "A", None, None, vec![])
7463            .await
7464            .unwrap();
7465        let n = rt
7466            .create_note(
7467                &tok,
7468                "observation",
7469                None,
7470                "note content",
7471                None,
7472                None,
7473                vec![],
7474            )
7475            .await
7476            .unwrap();
7477
7478        let delete_rt = std::sync::Arc::clone(&rt);
7479        let delete_tok = tok.clone();
7480        let note_id = n.id;
7481        let delete_task =
7482            tokio::spawn(async move { delete_rt.delete_note(&delete_tok, note_id, true).await });
7483
7484        let write_rt = std::sync::Arc::clone(&rt);
7485        let write_tok = tok.clone();
7486        let ns = tok.namespace().as_str().to_string();
7487        let write_task = tokio::spawn(async move {
7488            let edge = raw_edge(a.id, note_id, &ns);
7489            write_rt
7490                .graph(&write_tok)
7491                .unwrap()
7492                .upsert_edge_guarded(edge)
7493                .await
7494        });
7495
7496        let (deleted, _written) = tokio::join!(delete_task, write_task);
7497        deleted.unwrap().unwrap();
7498        assert_no_edges_touch(&rt, &tok, note_id).await;
7499    }
7500
7501    #[tokio::test]
7502    async fn hard_delete_edge_endpoint_purges_annotating_edge_written_before_delete() {
7503        let rt = rt();
7504        let tok = NamespaceToken::local();
7505        let a = rt
7506            .create_entity(&tok, "concept", None, "A", None, None, vec![])
7507            .await
7508            .unwrap();
7509        let b = rt
7510            .create_entity(&tok, "concept", None, "B", None, None, vec![])
7511            .await
7512            .unwrap();
7513        let n = rt
7514            .create_note(
7515                &tok,
7516                "observation",
7517                None,
7518                "note content",
7519                None,
7520                None,
7521                vec![],
7522            )
7523            .await
7524            .unwrap();
7525        let base_edge = rt
7526            .link(&tok, a.id, b.id, EdgeRelation::Extends, 0.8, None)
7527            .await
7528            .unwrap();
7529        let base_edge_id = Uuid::from(base_edge.id);
7530
7531        // An edge whose TARGET is another edge — the "edge-as-node" case
7532        // `delete_edge`'s cascade must sweep.
7533        let annotating = raw_edge(n.id, base_edge_id, tok.namespace().as_str());
7534        assert_eq!(
7535            rt.graph(&tok)
7536                .unwrap()
7537                .upsert_edge_guarded(annotating)
7538                .await
7539                .unwrap(),
7540            khive_storage::GuardedWriteOutcome::Written
7541        );
7542
7543        assert!(rt.delete_edge(&tok, base_edge_id, true).await.unwrap());
7544        assert_no_edges_touch(&rt, &tok, base_edge_id).await;
7545    }
7546
7547    #[tokio::test]
7548    async fn hard_delete_edge_endpoint_concurrent_with_guarded_write_never_leaves_dangling_edge() {
7549        let rt = std::sync::Arc::new(rt());
7550        let tok = NamespaceToken::local();
7551        let a = rt
7552            .create_entity(&tok, "concept", None, "A", None, None, vec![])
7553            .await
7554            .unwrap();
7555        let b = rt
7556            .create_entity(&tok, "concept", None, "B", None, None, vec![])
7557            .await
7558            .unwrap();
7559        let n = rt
7560            .create_note(
7561                &tok,
7562                "observation",
7563                None,
7564                "note content",
7565                None,
7566                None,
7567                vec![],
7568            )
7569            .await
7570            .unwrap();
7571        let base_edge = rt
7572            .link(&tok, a.id, b.id, EdgeRelation::Extends, 0.8, None)
7573            .await
7574            .unwrap();
7575        let base_edge_id = Uuid::from(base_edge.id);
7576
7577        let delete_rt = std::sync::Arc::clone(&rt);
7578        let delete_tok = tok.clone();
7579        let delete_task =
7580            tokio::spawn(
7581                async move { delete_rt.delete_edge(&delete_tok, base_edge_id, true).await },
7582            );
7583
7584        let write_rt = std::sync::Arc::clone(&rt);
7585        let write_tok = tok.clone();
7586        let ns = tok.namespace().as_str().to_string();
7587        let write_task = tokio::spawn(async move {
7588            let edge = raw_edge(n.id, base_edge_id, &ns);
7589            write_rt
7590                .graph(&write_tok)
7591                .unwrap()
7592                .upsert_edge_guarded(edge)
7593                .await
7594        });
7595
7596        let (deleted, _written) = tokio::join!(delete_task, write_task);
7597        deleted.unwrap().unwrap();
7598        assert_no_edges_touch(&rt, &tok, base_edge_id).await;
7599    }
7600
7601    // ---- file-backed, both write-queue configs ----
7602    //
7603    // The six tests above run against `KhiveRuntime::memory()` and race
7604    // delete against the guarded write via `tokio::join!` with no explicit
7605    // ordering control, so the scheduler could run them fully sequentially
7606    // on one thread without ever exercising real interleaving, and neither
7607    // the file-backed storage path nor `write_queue_enabled: true`
7608    // (`KHIVE_WRITE_QUEUE=1`, the `WriterTask`-routed write path in
7609    // `SqlGraphStore`) is covered at all. The four tests below close both
7610    // gaps: file-backed databases, one run with the writer queue off
7611    // (default) and one with it on, each provably forcing the guarded write
7612    // to land on one specific side of the delete — fully committed before
7613    // the delete starts (swept by the delete's cascade) and attempted only
7614    // after the delete has already committed (refused by the guard) — via
7615    // plain `.await` sequencing rather than a race whose outcome the test
7616    // does not control.
7617
7618    fn file_backed_runtime(
7619        dir: &tempfile::TempDir,
7620        name: &str,
7621        write_queue_enabled: bool,
7622    ) -> KhiveRuntime {
7623        let path = dir.path().join(name);
7624        if write_queue_enabled {
7625            std::env::set_var("KHIVE_WRITE_QUEUE", "1");
7626        } else {
7627            std::env::remove_var("KHIVE_WRITE_QUEUE");
7628        }
7629        let rt = KhiveRuntime::new(crate::config::RuntimeConfig {
7630            db_path: Some(path),
7631            packs: vec!["kg".to_string()],
7632            brain_profile: None,
7633            actor_id: None,
7634            ..crate::config::RuntimeConfig::no_embeddings()
7635        })
7636        .unwrap();
7637        std::env::remove_var("KHIVE_WRITE_QUEUE");
7638        rt
7639    }
7640
7641    async fn assert_guarded_write_committed_before_delete_is_swept(rt: &KhiveRuntime) {
7642        let tok = NamespaceToken::local();
7643        let a = rt
7644            .create_entity(&tok, "concept", None, "A", None, None, vec![])
7645            .await
7646            .unwrap();
7647        let x = rt
7648            .create_entity(&tok, "concept", None, "X", None, None, vec![])
7649            .await
7650            .unwrap();
7651
7652        // Write lands fully committed while X is still live — squarely
7653        // inside the window before the delete's cascade runs.
7654        let edge = raw_edge(a.id, x.id, tok.namespace().as_str());
7655        assert_eq!(
7656            rt.graph(&tok)
7657                .unwrap()
7658                .upsert_edge_guarded(edge)
7659                .await
7660                .unwrap(),
7661            khive_storage::GuardedWriteOutcome::Written,
7662            "write must succeed while both endpoints are still live"
7663        );
7664
7665        assert!(rt.delete_entity(&tok, x.id, true).await.unwrap());
7666        assert_no_edges_touch(rt, &tok, x.id).await;
7667    }
7668
7669    async fn assert_guarded_write_attempted_after_delete_is_refused(rt: &KhiveRuntime) {
7670        let tok = NamespaceToken::local();
7671        let a = rt
7672            .create_entity(&tok, "concept", None, "A", None, None, vec![])
7673            .await
7674            .unwrap();
7675        let x = rt
7676            .create_entity(&tok, "concept", None, "X", None, None, vec![])
7677            .await
7678            .unwrap();
7679
7680        // The delete's transaction has fully committed before the guarded
7681        // write is even attempted — squarely after the window has closed.
7682        assert!(rt.delete_entity(&tok, x.id, true).await.unwrap());
7683
7684        let edge = raw_edge(a.id, x.id, tok.namespace().as_str());
7685        let outcome = rt
7686            .graph(&tok)
7687            .unwrap()
7688            .upsert_edge_guarded(edge)
7689            .await
7690            .unwrap();
7691        match outcome {
7692            khive_storage::GuardedWriteOutcome::Refused(missing) => {
7693                assert!(
7694                    missing.target,
7695                    "target must be reported missing once the delete has committed"
7696                );
7697                assert!(!missing.source, "source was never deleted");
7698            }
7699            other => panic!(
7700                "guarded write attempted after the delete committed must be refused, got {other:?}"
7701            ),
7702        }
7703        assert_no_edges_touch(rt, &tok, x.id).await;
7704    }
7705
7706    #[tokio::test]
7707    #[serial_test::serial(khive_write_queue_env)]
7708    async fn guarded_write_before_delete_swept_file_backed_write_queue_off() {
7709        let dir = tempfile::tempdir().unwrap();
7710        let rt = file_backed_runtime(&dir, "guard_before_off.db", false);
7711        assert_guarded_write_committed_before_delete_is_swept(&rt).await;
7712    }
7713
7714    #[tokio::test]
7715    #[serial_test::serial(khive_write_queue_env)]
7716    async fn guarded_write_after_delete_refused_file_backed_write_queue_off() {
7717        let dir = tempfile::tempdir().unwrap();
7718        let rt = file_backed_runtime(&dir, "guard_after_off.db", false);
7719        assert_guarded_write_attempted_after_delete_is_refused(&rt).await;
7720    }
7721
7722    #[tokio::test]
7723    #[serial_test::serial(khive_write_queue_env)]
7724    async fn guarded_write_before_delete_swept_file_backed_write_queue_on() {
7725        let dir = tempfile::tempdir().unwrap();
7726        let rt = file_backed_runtime(&dir, "guard_before_on.db", true);
7727        assert_guarded_write_committed_before_delete_is_swept(&rt).await;
7728    }
7729
7730    #[tokio::test]
7731    #[serial_test::serial(khive_write_queue_env)]
7732    async fn guarded_write_after_delete_refused_file_backed_write_queue_on() {
7733        let dir = tempfile::tempdir().unwrap();
7734        let rt = file_backed_runtime(&dir, "guard_after_on.db", true);
7735        assert_guarded_write_attempted_after_delete_is_refused(&rt).await;
7736    }
7737
7738    #[tokio::test]
7739    async fn create_note_annotates_phantom_returns_not_found() {
7740        let rt = rt();
7741        let tok = NamespaceToken::local();
7742        let phantom = Uuid::new_v4();
7743
7744        let result = rt
7745            .create_note(
7746                &tok,
7747                "observation",
7748                None,
7749                "some content",
7750                Some(0.5),
7751                None,
7752                vec![phantom],
7753            )
7754            .await;
7755        assert!(
7756            matches!(result, Err(RuntimeError::NotFound(_))),
7757            "annotates with phantom uuid must return NotFound, got {result:?}"
7758        );
7759    }
7760
7761    #[tokio::test]
7762    async fn create_note_annotates_real_entity_succeeds() {
7763        let rt = rt();
7764        let tok = NamespaceToken::local();
7765        let entity = rt
7766            .create_entity(&tok, "concept", None, "RealTarget", None, None, vec![])
7767            .await
7768            .unwrap();
7769
7770        let note = rt
7771            .create_note(
7772                &tok,
7773                "observation",
7774                None,
7775                "content",
7776                Some(0.5),
7777                None,
7778                vec![entity.id],
7779            )
7780            .await
7781            .unwrap();
7782
7783        let neighbors = rt
7784            .neighbors(
7785                &tok,
7786                note.id,
7787                Direction::Out,
7788                None,
7789                Some(vec![EdgeRelation::Annotates]),
7790            )
7791            .await
7792            .unwrap();
7793        assert_eq!(neighbors.len(), 1);
7794        assert_eq!(neighbors[0].node_id, entity.id);
7795    }
7796
7797    // Atomicity: multi-target annotates golden path — all edges created, note present.
7798    #[tokio::test]
7799    async fn create_note_multi_annotates_creates_all_edges() {
7800        let rt = rt();
7801        let tok = NamespaceToken::local();
7802        let t1 = rt
7803            .create_entity(&tok, "concept", None, "Target1", None, None, vec![])
7804            .await
7805            .unwrap();
7806        let t2 = rt
7807            .create_entity(&tok, "concept", None, "Target2", None, None, vec![])
7808            .await
7809            .unwrap();
7810
7811        let note = rt
7812            .create_note(
7813                &tok,
7814                "observation",
7815                None,
7816                "content",
7817                Some(0.5),
7818                None,
7819                vec![t1.id, t2.id],
7820            )
7821            .await
7822            .unwrap();
7823
7824        let neighbors = rt
7825            .neighbors(
7826                &tok,
7827                note.id,
7828                Direction::Out,
7829                None,
7830                Some(vec![EdgeRelation::Annotates]),
7831            )
7832            .await
7833            .unwrap();
7834        assert_eq!(
7835            neighbors.len(),
7836            2,
7837            "multi-annotates note must have exactly 2 outbound annotates edges"
7838        );
7839        let target_ids: Vec<Uuid> = neighbors.iter().map(|n| n.node_id).collect();
7840        assert!(target_ids.contains(&t1.id));
7841        assert!(target_ids.contains(&t2.id));
7842    }
7843
7844    /// `link` endpoint existence is a by-ID check and therefore namespace-agnostic:
7845    /// a target living in a different namespace than the caller must still
7846    /// resolve, exactly as `get()` would.
7847    #[tokio::test]
7848    async fn link_target_in_different_namespace_succeeds() {
7849        let rt = rt();
7850        let ns_a = NamespaceToken::for_namespace(Namespace::parse("ns-a").unwrap());
7851        let ns_b = NamespaceToken::for_namespace(Namespace::parse("ns-b").unwrap());
7852        let a = rt
7853            .create_entity(&ns_a, "concept", None, "A", None, None, vec![])
7854            .await
7855            .unwrap();
7856        let b = rt
7857            .create_entity(&ns_b, "concept", None, "B", None, None, vec![])
7858            .await
7859            .unwrap();
7860
7861        // Linking from ns-a: target b lives in ns-b — by-ID resolution finds it anyway.
7862        let result = rt
7863            .link(&ns_a, a.id, b.id, EdgeRelation::Extends, 1.0, None)
7864            .await;
7865        assert!(
7866            result.is_ok(),
7867            "target in a different namespace than the caller must resolve (#631), got {result:?}"
7868        );
7869    }
7870
7871    #[tokio::test]
7872    async fn link_phantom_self_loop_returns_invalid_input() {
7873        let rt = rt();
7874        let tok = NamespaceToken::local();
7875        let phantom = Uuid::new_v4();
7876
7877        let result = rt
7878            .link(&tok, phantom, phantom, EdgeRelation::Extends, 1.0, None)
7879            .await;
7880        match result {
7881            Err(RuntimeError::InvalidInput(msg)) => {
7882                assert!(
7883                    msg.contains("self-loop"),
7884                    "self-loop must be rejected with self-loop message: {msg}"
7885                );
7886            }
7887            other => panic!("expected InvalidInput for self-loop, got {other:?}"),
7888        }
7889    }
7890
7891    // ---- edge target coverage + atomicity ----
7892
7893    #[tokio::test]
7894    async fn link_note_to_edge_annotates_succeeds() {
7895        let rt = rt();
7896        let tok = NamespaceToken::local();
7897        let a = rt
7898            .create_entity(&tok, "concept", None, "A", None, None, vec![])
7899            .await
7900            .unwrap();
7901        let b = rt
7902            .create_entity(&tok, "concept", None, "B", None, None, vec![])
7903            .await
7904            .unwrap();
7905        // Create a real edge between a and b, capture its UUID.
7906        let edge = rt
7907            .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
7908            .await
7909            .unwrap();
7910        let edge_uuid: Uuid = edge.id.into();
7911
7912        // Create a note and annotate the edge itself (edge is a valid substrate target for annotates).
7913        let note = rt
7914            .create_note(
7915                &tok,
7916                "observation",
7917                None,
7918                "edge note",
7919                Some(0.5),
7920                None,
7921                vec![],
7922            )
7923            .await
7924            .unwrap();
7925
7926        let result = rt
7927            .link(&tok, note.id, edge_uuid, EdgeRelation::Annotates, 1.0, None)
7928            .await;
7929        assert!(
7930            result.is_ok(),
7931            "note→edge Annotates must succeed, got {result:?}"
7932        );
7933    }
7934    /// #803: `neighbors(edge_id, direction=In, relations=[Annotates])` must
7935    /// find the annotating note — the storage-layer `graph_edges` query
7936    /// filters on `target_id = node_id` with no substrate-type check, so an
7937    /// edge id works as a neighbor-query node the same as an entity or note
7938    /// id. This is the runtime capability `get(edge_id)`'s new `annotations`
7939    /// field (khive-pack-kg) builds on.
7940    #[tokio::test]
7941    async fn neighbors_edge_id_finds_annotating_note() {
7942        let rt = rt();
7943        let tok = NamespaceToken::local();
7944        let a = rt
7945            .create_entity(&tok, "concept", None, "A", None, None, vec![])
7946            .await
7947            .unwrap();
7948        let b = rt
7949            .create_entity(&tok, "concept", None, "B", None, None, vec![])
7950            .await
7951            .unwrap();
7952        let edge = rt
7953            .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
7954            .await
7955            .unwrap();
7956        let edge_uuid: Uuid = edge.id.into();
7957
7958        let note = rt
7959            .create_note(
7960                &tok,
7961                "observation",
7962                None,
7963                "edge note",
7964                Some(0.5),
7965                None,
7966                vec![edge_uuid],
7967            )
7968            .await
7969            .unwrap();
7970
7971        let neighbors = rt
7972            .neighbors(
7973                &tok,
7974                edge_uuid,
7975                Direction::In,
7976                None,
7977                Some(vec![EdgeRelation::Annotates]),
7978            )
7979            .await
7980            .unwrap();
7981        assert_eq!(neighbors.len(), 1, "expected annotating note to show up");
7982        assert_eq!(neighbors[0].node_id, note.id);
7983    }
7984
7985    #[tokio::test]
7986    async fn create_note_annotates_real_edge_succeeds() {
7987        let rt = rt();
7988        let tok = NamespaceToken::local();
7989        let a = rt
7990            .create_entity(&tok, "concept", None, "A", None, None, vec![])
7991            .await
7992            .unwrap();
7993        let b = rt
7994            .create_entity(&tok, "concept", None, "B", None, None, vec![])
7995            .await
7996            .unwrap();
7997        let edge = rt
7998            .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
7999            .await
8000            .unwrap();
8001        let edge_uuid: Uuid = edge.id.into();
8002
8003        let note = rt
8004            .create_note(
8005                &tok,
8006                "observation",
8007                None,
8008                "annotating an edge",
8009                Some(0.5),
8010                None,
8011                vec![edge_uuid],
8012            )
8013            .await
8014            .unwrap();
8015
8016        let neighbors = rt
8017            .neighbors(
8018                &tok,
8019                note.id,
8020                Direction::Out,
8021                None,
8022                Some(vec![EdgeRelation::Annotates]),
8023            )
8024            .await
8025            .unwrap();
8026        assert_eq!(neighbors.len(), 1);
8027        assert_eq!(neighbors[0].node_id, edge_uuid);
8028    }
8029
8030    #[tokio::test]
8031    async fn create_note_annotates_phantom_is_atomic_no_note_persisted() {
8032        let rt = rt();
8033        let tok = NamespaceToken::local();
8034        let phantom = Uuid::new_v4();
8035
8036        let before_count = rt.list_notes(&tok, None, 1000, 0).await.unwrap().len();
8037
8038        let result = rt
8039            .create_note(
8040                &tok,
8041                "observation",
8042                None,
8043                "should not persist",
8044                Some(0.5),
8045                None,
8046                vec![phantom],
8047            )
8048            .await;
8049        assert!(
8050            matches!(result, Err(RuntimeError::NotFound(_))),
8051            "phantom annotates target must return NotFound, got {result:?}"
8052        );
8053
8054        // Atomicity: the note row must NOT have been written.
8055        let after_count = rt.list_notes(&tok, None, 1000, 0).await.unwrap().len();
8056        assert_eq!(
8057            before_count, after_count,
8058            "failed create_note must not persist any note row (atomicity)"
8059        );
8060
8061        // FTS must not contain the content either.
8062        let search_hits = rt
8063            .search_notes(&tok, "should not persist", None, 10, None, false, &[], None)
8064            .await
8065            .unwrap();
8066        assert!(
8067            search_hits.is_empty(),
8068            "failed create_note must not index into FTS (atomicity)"
8069        );
8070        // Vector-store row: only written when an embedding model is configured; the rt()
8071        // harness has none, so no vector assertion is needed here.
8072    }
8073
8074    // ---- relation-aware endpoint contract ----
8075
8076    // Test #2: entity→entity with non-annotates rejects an edge UUID as target.
8077    #[tokio::test]
8078    async fn link_entity_to_edge_uuid_non_annotates_returns_invalid_input() {
8079        let rt = rt();
8080        let tok = NamespaceToken::local();
8081        let a = rt
8082            .create_entity(&tok, "concept", None, "A", None, None, vec![])
8083            .await
8084            .unwrap();
8085        let b = rt
8086            .create_entity(&tok, "concept", None, "B", None, None, vec![])
8087            .await
8088            .unwrap();
8089        // Create a real edge; capture its UUID as the bad target.
8090        let edge = rt
8091            .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
8092            .await
8093            .unwrap();
8094        let edge_uuid: Uuid = edge.id.into();
8095
8096        let result = rt
8097            .link(&tok, a.id, edge_uuid, EdgeRelation::Extends, 1.0, None)
8098            .await;
8099        match result {
8100            Err(RuntimeError::InvalidInput(msg)) => {
8101                assert!(
8102                    msg.contains("target"),
8103                    "error message must name 'target': {msg}"
8104                );
8105            }
8106            other => {
8107                panic!("expected InvalidInput for edge-uuid target with Extends, got {other:?}")
8108            }
8109        }
8110    }
8111
8112    // Test #3: non-annotates rejects a note UUID as source.
8113    #[tokio::test]
8114    async fn link_note_as_source_non_annotates_returns_invalid_input() {
8115        let rt = rt();
8116        let tok = NamespaceToken::local();
8117        let note = rt
8118            .create_note(&tok, "observation", None, "a note", Some(0.5), None, vec![])
8119            .await
8120            .unwrap();
8121        let entity = rt
8122            .create_entity(&tok, "concept", None, "E", None, None, vec![])
8123            .await
8124            .unwrap();
8125
8126        let result = rt
8127            .link(&tok, note.id, entity.id, EdgeRelation::DependsOn, 1.0, None)
8128            .await;
8129        match result {
8130            Err(RuntimeError::InvalidInput(msg)) => {
8131                assert!(
8132                    msg.contains("source"),
8133                    "error message must name 'source': {msg}"
8134                );
8135            }
8136            other => panic!("expected InvalidInput for note source with DependsOn, got {other:?}"),
8137        }
8138    }
8139
8140    // Test #4: annotates rejects entity as source (source must be a note).
8141    #[tokio::test]
8142    async fn link_entity_as_annotates_source_returns_invalid_input() {
8143        let rt = rt();
8144        let tok = NamespaceToken::local();
8145        let a = rt
8146            .create_entity(&tok, "concept", None, "A", None, None, vec![])
8147            .await
8148            .unwrap();
8149        let b = rt
8150            .create_entity(&tok, "concept", None, "B", None, None, vec![])
8151            .await
8152            .unwrap();
8153
8154        let result = rt
8155            .link(&tok, a.id, b.id, EdgeRelation::Annotates, 1.0, None)
8156            .await;
8157        match result {
8158            Err(RuntimeError::InvalidInput(msg)) => {
8159                assert!(
8160                    msg.contains("source") && msg.contains("note"),
8161                    "error must say source must be a note: {msg}"
8162                );
8163            }
8164            other => {
8165                panic!("expected InvalidInput for entity source with Annotates, got {other:?}")
8166            }
8167        }
8168    }
8169
8170    #[tokio::test]
8171    async fn link_edge_as_annotates_source_returns_invalid_input() {
8172        let rt = rt();
8173        let tok = NamespaceToken::local();
8174        let a = rt
8175            .create_entity(&tok, "concept", None, "A", None, None, vec![])
8176            .await
8177            .unwrap();
8178        let b = rt
8179            .create_entity(&tok, "concept", None, "B", None, None, vec![])
8180            .await
8181            .unwrap();
8182        let edge = rt
8183            .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
8184            .await
8185            .unwrap();
8186        let edge_uuid: Uuid = edge.id.into();
8187
8188        // An existing edge used as an annotates source: wrong kind, not absent.
8189        let result = rt
8190            .link(&tok, edge_uuid, a.id, EdgeRelation::Annotates, 1.0, None)
8191            .await;
8192        match result {
8193            Err(RuntimeError::InvalidInput(msg)) => {
8194                assert!(
8195                    msg.contains("source") && msg.contains("note"),
8196                    "edge-as-annotates-source must report wrong kind, not NotFound: {msg}"
8197                );
8198            }
8199            other => panic!("expected InvalidInput for edge source with Annotates, got {other:?}"),
8200        }
8201    }
8202
8203    // Test #5: note→event with annotates succeeds (event is a valid annotates target).
8204    #[tokio::test]
8205    async fn link_note_to_event_annotates_succeeds() {
8206        use khive_storage::Event;
8207        use khive_types::{EventKind, SubstrateKind};
8208
8209        let rt = rt();
8210        let tok = NamespaceToken::local();
8211        let note = rt
8212            .create_note(
8213                &tok,
8214                "observation",
8215                None,
8216                "observing an event",
8217                Some(0.6),
8218                None,
8219                vec![],
8220            )
8221            .await
8222            .unwrap();
8223
8224        // Build an event directly via the store (no runtime create_event exists).
8225        let ns = tok.namespace().as_str();
8226        let event = Event::new(
8227            ns,
8228            "test_verb",
8229            EventKind::Audit,
8230            SubstrateKind::Entity,
8231            "test_actor",
8232        );
8233        let event_id = event.id;
8234        rt.events(&tok).unwrap().append_event(event).await.unwrap();
8235
8236        let result = rt
8237            .link(&tok, note.id, event_id, EdgeRelation::Annotates, 1.0, None)
8238            .await;
8239        assert!(
8240            result.is_ok(),
8241            "note→event Annotates must succeed, got {result:?}"
8242        );
8243    }
8244
8245    // Test #6: create_note with event as annotates target succeeds.
8246    #[tokio::test]
8247    async fn create_note_annotates_event_succeeds() {
8248        use khive_storage::Event;
8249        use khive_types::{EventKind, SubstrateKind};
8250
8251        let rt = rt();
8252        let tok = NamespaceToken::local();
8253        let ns = tok.namespace().as_str();
8254        let event = Event::new(
8255            ns,
8256            "test_verb",
8257            EventKind::Audit,
8258            SubstrateKind::Entity,
8259            "test_actor",
8260        );
8261        let event_id = event.id;
8262        rt.events(&tok).unwrap().append_event(event).await.unwrap();
8263
8264        let result = rt
8265            .create_note(
8266                &tok,
8267                "observation",
8268                None,
8269                "note annotating an event",
8270                Some(0.5),
8271                None,
8272                vec![event_id],
8273            )
8274            .await;
8275        assert!(
8276            result.is_ok(),
8277            "create_note with event annotates target must succeed, got {result:?}"
8278        );
8279        // Verify the annotates edge was created.
8280        let note = result.unwrap();
8281        let neighbors = rt
8282            .neighbors(
8283                &tok,
8284                note.id,
8285                Direction::Out,
8286                None,
8287                Some(vec![EdgeRelation::Annotates]),
8288            )
8289            .await
8290            .unwrap();
8291        assert_eq!(neighbors.len(), 1);
8292        assert_eq!(neighbors[0].node_id, event_id);
8293    }
8294
8295    // ---- supersedes same-substrate contract ----
8296
8297    // Headline regression: note→note supersedes must succeed (was wrongly rejected before this fix).
8298    #[tokio::test]
8299    async fn link_supersedes_note_to_note_succeeds() {
8300        let rt = rt();
8301        let tok = NamespaceToken::local();
8302        let old_note = rt
8303            .create_note(
8304                &tok,
8305                "observation",
8306                None,
8307                "old observation",
8308                Some(0.7),
8309                None,
8310                vec![],
8311            )
8312            .await
8313            .unwrap();
8314        let new_note = rt
8315            .create_note(
8316                &tok,
8317                "observation",
8318                None,
8319                "revised observation superseding the old one",
8320                Some(0.9),
8321                None,
8322                vec![],
8323            )
8324            .await
8325            .unwrap();
8326
8327        let result = rt
8328            .link(
8329                &tok,
8330                new_note.id,
8331                old_note.id,
8332                EdgeRelation::Supersedes,
8333                1.0,
8334                None,
8335            )
8336            .await;
8337        assert!(
8338            result.is_ok(),
8339            "note→note Supersedes must succeed (note supersession), got {result:?}"
8340        );
8341    }
8342
8343    #[tokio::test]
8344    async fn link_supersedes_entity_to_entity_succeeds() {
8345        let rt = rt();
8346        let tok = NamespaceToken::local();
8347        let old_entity = rt
8348            .create_entity(&tok, "concept", None, "OldConcept", None, None, vec![])
8349            .await
8350            .unwrap();
8351        let new_entity = rt
8352            .create_entity(&tok, "concept", None, "NewConcept", None, None, vec![])
8353            .await
8354            .unwrap();
8355
8356        let result = rt
8357            .link(
8358                &tok,
8359                new_entity.id,
8360                old_entity.id,
8361                EdgeRelation::Supersedes,
8362                1.0,
8363                None,
8364            )
8365            .await;
8366        assert!(
8367            result.is_ok(),
8368            "entity→entity Supersedes must succeed, got {result:?}"
8369        );
8370    }
8371
8372    #[tokio::test]
8373    async fn link_supersedes_note_to_entity_returns_invalid_input() {
8374        let rt = rt();
8375        let tok = NamespaceToken::local();
8376        let note = rt
8377            .create_note(&tok, "observation", None, "a note", Some(0.5), None, vec![])
8378            .await
8379            .unwrap();
8380        let entity = rt
8381            .create_entity(&tok, "concept", None, "SomeEntity", None, None, vec![])
8382            .await
8383            .unwrap();
8384
8385        let result = rt
8386            .link(
8387                &tok,
8388                note.id,
8389                entity.id,
8390                EdgeRelation::Supersedes,
8391                1.0,
8392                None,
8393            )
8394            .await;
8395        match result {
8396            Err(RuntimeError::InvalidInput(msg)) => {
8397                assert!(
8398                    msg.contains("same substrate") || msg.contains("same-substrate"),
8399                    "error must name the same-substrate rule: {msg}"
8400                );
8401            }
8402            other => panic!(
8403                "expected InvalidInput for note→entity Supersedes (cross-substrate), got {other:?}"
8404            ),
8405        }
8406    }
8407
8408    #[tokio::test]
8409    async fn link_supersedes_entity_to_note_returns_invalid_input() {
8410        let rt = rt();
8411        let tok = NamespaceToken::local();
8412        let entity = rt
8413            .create_entity(&tok, "concept", None, "SomeEntity", None, None, vec![])
8414            .await
8415            .unwrap();
8416        let note = rt
8417            .create_note(&tok, "observation", None, "a note", Some(0.5), None, vec![])
8418            .await
8419            .unwrap();
8420
8421        let result = rt
8422            .link(
8423                &tok,
8424                entity.id,
8425                note.id,
8426                EdgeRelation::Supersedes,
8427                1.0,
8428                None,
8429            )
8430            .await;
8431        match result {
8432            Err(RuntimeError::InvalidInput(msg)) => {
8433                assert!(
8434                    msg.contains("same substrate") || msg.contains("same-substrate"),
8435                    "error must name the same-substrate rule: {msg}"
8436                );
8437            }
8438            other => panic!(
8439                "expected InvalidInput for entity→note Supersedes (cross-substrate), got {other:?}"
8440            ),
8441        }
8442    }
8443
8444    #[tokio::test]
8445    async fn link_supersedes_event_source_returns_invalid_input() {
8446        use khive_storage::Event;
8447        use khive_types::{EventKind, SubstrateKind};
8448
8449        let rt = rt();
8450        let tok = NamespaceToken::local();
8451        let ns = tok.namespace().as_str();
8452        let event = Event::new(
8453            ns,
8454            "test_verb",
8455            EventKind::Audit,
8456            SubstrateKind::Entity,
8457            "test_actor",
8458        );
8459        let event_id = event.id;
8460        rt.events(&tok).unwrap().append_event(event).await.unwrap();
8461
8462        let entity = rt
8463            .create_entity(&tok, "concept", None, "SomeEntity", None, None, vec![])
8464            .await
8465            .unwrap();
8466
8467        let result = rt
8468            .link(
8469                &tok,
8470                event_id,
8471                entity.id,
8472                EdgeRelation::Supersedes,
8473                1.0,
8474                None,
8475            )
8476            .await;
8477        match result {
8478            Err(RuntimeError::InvalidInput(msg)) => {
8479                assert!(msg.contains("event"), "error must mention 'event': {msg}");
8480            }
8481            other => {
8482                panic!("expected InvalidInput for event source with Supersedes, got {other:?}")
8483            }
8484        }
8485    }
8486
8487    #[tokio::test]
8488    async fn link_supersedes_event_target_returns_invalid_input() {
8489        use khive_storage::Event;
8490        use khive_types::{EventKind, SubstrateKind};
8491
8492        let rt = rt();
8493        let tok = NamespaceToken::local();
8494        let ns = tok.namespace().as_str();
8495        let event = Event::new(
8496            ns,
8497            "test_verb",
8498            EventKind::Audit,
8499            SubstrateKind::Entity,
8500            "test_actor",
8501        );
8502        let event_id = event.id;
8503        rt.events(&tok).unwrap().append_event(event).await.unwrap();
8504
8505        let entity = rt
8506            .create_entity(&tok, "concept", None, "SomeEntity", None, None, vec![])
8507            .await
8508            .unwrap();
8509
8510        let result = rt
8511            .link(
8512                &tok,
8513                entity.id,
8514                event_id,
8515                EdgeRelation::Supersedes,
8516                1.0,
8517                None,
8518            )
8519            .await;
8520        match result {
8521            Err(RuntimeError::InvalidInput(msg)) => {
8522                assert!(msg.contains("event"), "error must mention 'event': {msg}");
8523            }
8524            other => {
8525                panic!("expected InvalidInput for event target with Supersedes, got {other:?}")
8526            }
8527        }
8528    }
8529
8530    #[tokio::test]
8531    async fn link_supersedes_edge_source_returns_invalid_input() {
8532        let rt = rt();
8533        let tok = NamespaceToken::local();
8534        let a = rt
8535            .create_entity(&tok, "concept", None, "A", None, None, vec![])
8536            .await
8537            .unwrap();
8538        let b = rt
8539            .create_entity(&tok, "concept", None, "B", None, None, vec![])
8540            .await
8541            .unwrap();
8542        let edge = rt
8543            .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
8544            .await
8545            .unwrap();
8546        let edge_uuid: Uuid = edge.id.into();
8547
8548        let result = rt
8549            .link(&tok, edge_uuid, a.id, EdgeRelation::Supersedes, 1.0, None)
8550            .await;
8551        match result {
8552            Err(RuntimeError::InvalidInput(msg)) => {
8553                assert!(msg.contains("source"), "error must name 'source': {msg}");
8554            }
8555            other => {
8556                panic!("expected InvalidInput for edge-uuid source with Supersedes, got {other:?}")
8557            }
8558        }
8559    }
8560
8561    #[tokio::test]
8562    async fn link_supersedes_edge_target_returns_invalid_input() {
8563        let rt = rt();
8564        let tok = NamespaceToken::local();
8565        let a = rt
8566            .create_entity(&tok, "concept", None, "A", None, None, vec![])
8567            .await
8568            .unwrap();
8569        let b = rt
8570            .create_entity(&tok, "concept", None, "B", None, None, vec![])
8571            .await
8572            .unwrap();
8573        let edge = rt
8574            .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
8575            .await
8576            .unwrap();
8577        let edge_uuid: Uuid = edge.id.into();
8578
8579        let result = rt
8580            .link(&tok, a.id, edge_uuid, EdgeRelation::Supersedes, 1.0, None)
8581            .await;
8582        match result {
8583            Err(RuntimeError::InvalidInput(msg)) => {
8584                assert!(msg.contains("target"), "error must name 'target': {msg}");
8585            }
8586            other => {
8587                panic!("expected InvalidInput for edge-uuid target with Supersedes, got {other:?}")
8588            }
8589        }
8590    }
8591
8592    #[tokio::test]
8593    async fn link_supersedes_phantom_source_returns_not_found() {
8594        let rt = rt();
8595        let tok = NamespaceToken::local();
8596        let note = rt
8597            .create_note(
8598                &tok,
8599                "observation",
8600                None,
8601                "existing note",
8602                Some(0.5),
8603                None,
8604                vec![],
8605            )
8606            .await
8607            .unwrap();
8608        let phantom = Uuid::new_v4();
8609
8610        let result = rt
8611            .link(&tok, phantom, note.id, EdgeRelation::Supersedes, 1.0, None)
8612            .await;
8613        match result {
8614            Err(RuntimeError::NotFound(msg)) => {
8615                assert!(msg.contains("source"), "error must name 'source': {msg}");
8616            }
8617            other => panic!("expected NotFound for phantom source with Supersedes, got {other:?}"),
8618        }
8619    }
8620
8621    #[tokio::test]
8622    async fn link_supersedes_phantom_target_returns_not_found() {
8623        let rt = rt();
8624        let tok = NamespaceToken::local();
8625        let note = rt
8626            .create_note(
8627                &tok,
8628                "observation",
8629                None,
8630                "existing note",
8631                Some(0.5),
8632                None,
8633                vec![],
8634            )
8635            .await
8636            .unwrap();
8637        let phantom = Uuid::new_v4();
8638
8639        let result = rt
8640            .link(&tok, note.id, phantom, EdgeRelation::Supersedes, 1.0, None)
8641            .await;
8642        match result {
8643            Err(RuntimeError::NotFound(msg)) => {
8644                assert!(msg.contains("target"), "error must name 'target': {msg}");
8645            }
8646            other => panic!("expected NotFound for phantom target with Supersedes, got {other:?}"),
8647        }
8648    }
8649
8650    /// The canonical `remember | supersedes` chain: a `supersedes` source note living
8651    /// in a different namespace than the caller must still resolve as a by-ID endpoint.
8652    #[tokio::test]
8653    async fn link_supersedes_cross_namespace_source_succeeds() {
8654        let rt = rt();
8655        let ns_a = NamespaceToken::for_namespace(Namespace::parse("ns-a").unwrap());
8656        let ns_b = NamespaceToken::for_namespace(Namespace::parse("ns-b").unwrap());
8657        let note_a = rt
8658            .create_note(
8659                &ns_a,
8660                "observation",
8661                None,
8662                "note in ns-a",
8663                Some(0.5),
8664                None,
8665                vec![],
8666            )
8667            .await
8668            .unwrap();
8669        let note_b = rt
8670            .create_note(
8671                &ns_b,
8672                "observation",
8673                None,
8674                "note in ns-b",
8675                Some(0.5),
8676                None,
8677                vec![],
8678            )
8679            .await
8680            .unwrap();
8681
8682        // From ns-a perspective, note_b is in a different namespace — by-ID resolution
8683        // finds it anyway.
8684        let result = rt
8685            .link(
8686                &ns_a,
8687                note_b.id,
8688                note_a.id,
8689                EdgeRelation::Supersedes,
8690                1.0,
8691                None,
8692            )
8693            .await;
8694        assert!(
8695            result.is_ok(),
8696            "cross-namespace supersedes source must resolve (#631), got {result:?}"
8697        );
8698    }
8699
8700    // Sanity: extends (non-annotates, non-supersedes) still requires entity→entity.
8701    #[tokio::test]
8702    async fn link_extends_note_source_still_returns_invalid_input() {
8703        let rt = rt();
8704        let tok = NamespaceToken::local();
8705        let note = rt
8706            .create_note(
8707                &tok,
8708                "observation",
8709                None,
8710                "a note that cannot be an extends source",
8711                Some(0.5),
8712                None,
8713                vec![],
8714            )
8715            .await
8716            .unwrap();
8717        let entity = rt
8718            .create_entity(&tok, "concept", None, "E", None, None, vec![])
8719            .await
8720            .unwrap();
8721
8722        let result = rt
8723            .link(&tok, note.id, entity.id, EdgeRelation::Extends, 1.0, None)
8724            .await;
8725        assert!(
8726            matches!(result, Err(RuntimeError::InvalidInput(_))),
8727            "note source with Extends must still return InvalidInput after this fix, got {result:?}"
8728        );
8729    }
8730
8731    // Sanity: annotates note→edge still succeeds (unchanged path not broken by this fix).
8732    #[tokio::test]
8733    async fn link_annotates_note_to_edge_still_succeeds_after_fix() {
8734        let rt = rt();
8735        let tok = NamespaceToken::local();
8736        let a = rt
8737            .create_entity(&tok, "concept", None, "A", None, None, vec![])
8738            .await
8739            .unwrap();
8740        let b = rt
8741            .create_entity(&tok, "concept", None, "B", None, None, vec![])
8742            .await
8743            .unwrap();
8744        let edge = rt
8745            .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
8746            .await
8747            .unwrap();
8748        let edge_uuid: Uuid = edge.id.into();
8749
8750        let note = rt
8751            .create_note(
8752                &tok,
8753                "observation",
8754                None,
8755                "annotating an edge",
8756                Some(0.5),
8757                None,
8758                vec![],
8759            )
8760            .await
8761            .unwrap();
8762
8763        let result = rt
8764            .link(&tok, note.id, edge_uuid, EdgeRelation::Annotates, 1.0, None)
8765            .await;
8766        assert!(
8767            result.is_ok(),
8768            "note→edge Annotates must still succeed after supersedes fix, got {result:?}"
8769        );
8770    }
8771
8772    // ---- Compensation-path rollback (fix/annotates) ----
8773
8774    // The compensation branch in `create_note_inner` (operations.rs) rolls back
8775    // a partial write — note row + first edge + FTS + vector — when a subsequent
8776    // link call fails. The failure trigger is a storage error (e.g. I/O failure)
8777    // that cannot occur in the in-memory runtime; this test instead exercises the
8778    // exact cleanup operations that the compensation branch performs, starting from
8779    // a manually-constructed partial state, and verifies the post-cleanup invariants.
8780    //
8781    // What this covers: the cleanup sequence (delete_edge, delete_note hard, FTS
8782    // index clean) is correct and leaves the DB in a pristine state. What it does
8783    // not cover: the trigger condition (second link failure). Storage-error injection
8784    // would require a mock GraphStore, which is beyond the current test infrastructure.
8785    #[tokio::test]
8786    async fn create_note_multi_annotates_compensation_cleanup_restores_pristine_state() {
8787        let rt = rt();
8788        let tok = NamespaceToken::local();
8789        let t1 = rt
8790            .create_entity(&tok, "concept", None, "T1", None, None, vec![])
8791            .await
8792            .unwrap();
8793
8794        // Construct the partial state that the compensation branch would encounter:
8795        // note persisted + first annotates edge created.
8796        let note = rt
8797            .create_note(
8798                &tok,
8799                "observation",
8800                None,
8801                "partial note",
8802                Some(0.5),
8803                None,
8804                vec![t1.id],
8805            )
8806            .await
8807            .unwrap();
8808
8809        // Confirm the partial state exists before compensation.
8810        let before_notes = rt.list_notes(&tok, None, 1000, 0).await.unwrap();
8811        assert_eq!(before_notes.len(), 1, "note must be present before cleanup");
8812        let before_edges = rt
8813            .neighbors(
8814                &tok,
8815                note.id,
8816                Direction::Out,
8817                None,
8818                Some(vec![EdgeRelation::Annotates]),
8819            )
8820            .await
8821            .unwrap();
8822        assert_eq!(
8823            before_edges.len(),
8824            1,
8825            "one annotates edge must exist before cleanup"
8826        );
8827        let edge_id: Uuid = before_edges[0].edge_id;
8828
8829        // Execute the same cleanup sequence that `create_note_inner`'s Err branch runs.
8830        rt.delete_edge(&tok, edge_id, true).await.unwrap();
8831        rt.delete_note(&tok, note.id, true /* hard */)
8832            .await
8833            .unwrap();
8834
8835        // Post-compensation invariants:
8836        let after_notes = rt.list_notes(&tok, None, 1000, 0).await.unwrap();
8837        assert!(
8838            after_notes.is_empty(),
8839            "compensation must remove the note row; got {after_notes:?}"
8840        );
8841        let search_hits = rt
8842            .search_notes(&tok, "partial note", None, 10, None, false, &[], None)
8843            .await
8844            .unwrap();
8845        assert!(
8846            search_hits.is_empty(),
8847            "compensation must clean the FTS index; got {search_hits:?}"
8848        );
8849        let after_edges = rt
8850            .neighbors(&tok, note.id, Direction::Out, None, None)
8851            .await
8852            .unwrap();
8853        assert!(
8854            after_edges.is_empty(),
8855            "compensation must remove all partial edges; got {after_edges:?}"
8856        );
8857    }
8858
8859    // ---- Hard-delete cascade for note and edge annotation targets (fix/annotates) ----
8860
8861    // annotates is note → ANYTHING (entity, note, edge, event);
8862    // targets may be entity, edge, event, or note.
8863    // Hard-deleting any of those targets must cascade incident annotates edges.
8864    // Soft deletes leave edges (data-vs-view rule).
8865
8866    #[tokio::test]
8867    async fn annotated_entity_hard_delete_cascades_annotate_edge() {
8868        let rt = rt();
8869        let tok = NamespaceToken::local();
8870        let entity = rt
8871            .create_entity(&tok, "concept", None, "E", None, None, vec![])
8872            .await
8873            .unwrap();
8874        let note = rt
8875            .create_note(
8876                &tok,
8877                "observation",
8878                None,
8879                "note about entity",
8880                Some(0.5),
8881                None,
8882                vec![entity.id],
8883            )
8884            .await
8885            .unwrap();
8886
8887        // Confirm edge exists before delete.
8888        let before = rt
8889            .neighbors(
8890                &tok,
8891                note.id,
8892                Direction::Out,
8893                None,
8894                Some(vec![EdgeRelation::Annotates]),
8895            )
8896            .await
8897            .unwrap();
8898        assert_eq!(
8899            before.len(),
8900            1,
8901            "annotates edge must exist before entity delete"
8902        );
8903
8904        // Hard delete the entity.
8905        let deleted = rt.delete_entity(&tok, entity.id, true).await.unwrap();
8906        assert!(deleted, "entity hard delete must return true");
8907
8908        // Annotates edge must be gone.
8909        let after = rt
8910            .neighbors(
8911                &tok,
8912                note.id,
8913                Direction::Out,
8914                None,
8915                Some(vec![EdgeRelation::Annotates]),
8916            )
8917            .await
8918            .unwrap();
8919        assert!(
8920            after.is_empty(),
8921            "annotates edge must be cascaded on entity hard delete; got {after:?}"
8922        );
8923    }
8924
8925    #[tokio::test]
8926    async fn annotated_note_hard_delete_cascades_annotate_edge() {
8927        let rt = rt();
8928        let tok = NamespaceToken::local();
8929        // note_target is the thing being annotated (a note itself).
8930        let note_target = rt
8931            .create_note(
8932                &tok,
8933                "observation",
8934                None,
8935                "target note",
8936                Some(0.5),
8937                None,
8938                vec![],
8939            )
8940            .await
8941            .unwrap();
8942        // note_source annotates note_target.
8943        let note_source = rt
8944            .create_note(
8945                &tok,
8946                "insight",
8947                None,
8948                "annotation",
8949                Some(0.5),
8950                None,
8951                vec![note_target.id],
8952            )
8953            .await
8954            .unwrap();
8955
8956        let before = rt
8957            .neighbors(
8958                &tok,
8959                note_source.id,
8960                Direction::Out,
8961                None,
8962                Some(vec![EdgeRelation::Annotates]),
8963            )
8964            .await
8965            .unwrap();
8966        assert_eq!(
8967            before.len(),
8968            1,
8969            "annotates edge must exist before note delete"
8970        );
8971
8972        // Hard delete the annotation TARGET note.
8973        let deleted = rt.delete_note(&tok, note_target.id, true).await.unwrap();
8974        assert!(deleted, "note hard delete must return true");
8975
8976        // The annotates edge targeting note_target must be gone.
8977        let after = rt
8978            .neighbors(
8979                &tok,
8980                note_source.id,
8981                Direction::Out,
8982                None,
8983                Some(vec![EdgeRelation::Annotates]),
8984            )
8985            .await
8986            .unwrap();
8987        assert!(
8988            after.is_empty(),
8989            "annotates edge must be cascaded on note-target hard delete; got {after:?}"
8990        );
8991    }
8992
8993    #[tokio::test]
8994    async fn annotated_edge_delete_cascades_annotate_edge() {
8995        let rt = rt();
8996        let tok = NamespaceToken::local();
8997        let a = rt
8998            .create_entity(&tok, "concept", None, "A", None, None, vec![])
8999            .await
9000            .unwrap();
9001        let b = rt
9002            .create_entity(&tok, "concept", None, "B", None, None, vec![])
9003            .await
9004            .unwrap();
9005        // Create an edge to annotate.
9006        let base_edge = rt
9007            .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
9008            .await
9009            .unwrap();
9010        let base_edge_uuid: Uuid = base_edge.id.into();
9011
9012        // Create a note that annotates the edge.
9013        let note = rt
9014            .create_note(
9015                &tok,
9016                "observation",
9017                None,
9018                "note about edge",
9019                Some(0.5),
9020                None,
9021                vec![base_edge_uuid],
9022            )
9023            .await
9024            .unwrap();
9025
9026        let before = rt
9027            .neighbors(
9028                &tok,
9029                note.id,
9030                Direction::Out,
9031                None,
9032                Some(vec![EdgeRelation::Annotates]),
9033            )
9034            .await
9035            .unwrap();
9036        assert_eq!(
9037            before.len(),
9038            1,
9039            "annotates edge must exist before base edge delete"
9040        );
9041
9042        // Delete the base edge.
9043        let deleted = rt.delete_edge(&tok, base_edge_uuid, true).await.unwrap();
9044        assert!(deleted, "edge delete must return true");
9045
9046        // The annotates edge targeting base_edge must be gone.
9047        let after = rt
9048            .neighbors(
9049                &tok,
9050                note.id,
9051                Direction::Out,
9052                None,
9053                Some(vec![EdgeRelation::Annotates]),
9054            )
9055            .await
9056            .unwrap();
9057        assert!(
9058            after.is_empty(),
9059            "annotates edge must be cascaded on base edge delete; got {after:?}"
9060        );
9061    }
9062
9063    #[tokio::test]
9064    async fn mixed_multi_annotates_partial_target_hard_delete_leaves_remaining_edges() {
9065        let rt = rt();
9066        let tok = NamespaceToken::local();
9067        let t1 = rt
9068            .create_entity(&tok, "concept", None, "T1", None, None, vec![])
9069            .await
9070            .unwrap();
9071        let t2 = rt
9072            .create_entity(&tok, "concept", None, "T2", None, None, vec![])
9073            .await
9074            .unwrap();
9075
9076        // Note annotates both t1 and t2.
9077        let note = rt
9078            .create_note(
9079                &tok,
9080                "observation",
9081                None,
9082                "multi-target note",
9083                Some(0.5),
9084                None,
9085                vec![t1.id, t2.id],
9086            )
9087            .await
9088            .unwrap();
9089
9090        let before = rt
9091            .neighbors(
9092                &tok,
9093                note.id,
9094                Direction::Out,
9095                None,
9096                Some(vec![EdgeRelation::Annotates]),
9097            )
9098            .await
9099            .unwrap();
9100        assert_eq!(
9101            before.len(),
9102            2,
9103            "must have 2 annotates edges before any delete"
9104        );
9105
9106        // Hard delete only t1.
9107        rt.delete_entity(&tok, t1.id, true).await.unwrap();
9108
9109        // Edge to t1 must be gone, edge to t2 must remain.
9110        let after = rt
9111            .neighbors(
9112                &tok,
9113                note.id,
9114                Direction::Out,
9115                None,
9116                Some(vec![EdgeRelation::Annotates]),
9117            )
9118            .await
9119            .unwrap();
9120        assert_eq!(
9121            after.len(),
9122            1,
9123            "only the edge to t1 must be cascaded; t2 edge must remain"
9124        );
9125        assert_eq!(
9126            after[0].node_id, t2.id,
9127            "remaining annotates edge must point to t2"
9128        );
9129    }
9130
9131    #[tokio::test]
9132    async fn annotated_note_soft_delete_preserves_annotate_edge() {
9133        let rt = rt();
9134        let tok = NamespaceToken::local();
9135        let note_target = rt
9136            .create_note(&tok, "observation", None, "target", Some(0.5), None, vec![])
9137            .await
9138            .unwrap();
9139        let note_source = rt
9140            .create_note(
9141                &tok,
9142                "insight",
9143                None,
9144                "annotation",
9145                Some(0.5),
9146                None,
9147                vec![note_target.id],
9148            )
9149            .await
9150            .unwrap();
9151
9152        let before = rt
9153            .neighbors(
9154                &tok,
9155                note_source.id,
9156                Direction::Out,
9157                None,
9158                Some(vec![EdgeRelation::Annotates]),
9159            )
9160            .await
9161            .unwrap();
9162        assert_eq!(before.len(), 1);
9163        let edge_id = before[0].edge_id;
9164
9165        // Soft delete must NOT cascade edges (data-vs-view principle).
9166        let deleted = rt.delete_note(&tok, note_target.id, false).await.unwrap();
9167        assert!(deleted, "soft delete must return true");
9168
9169        // The edge itself must survive the soft delete — checked at the
9170        // storage/edge layer directly (`get_edge`), not through `neighbors()`.
9171        // `neighbors()` is a VIEW query and correctly screens
9172        // out soft-deleted note targets — so it no longer surfaces this edge
9173        // once note_target is soft-deleted, even though the edge row itself
9174        // is untouched (data-vs-view principle: the edge is data, what
9175        // `neighbors()` shows is a view decision).
9176        let edge_after = rt.get_edge(&tok, edge_id).await.unwrap();
9177        assert!(
9178            edge_after.is_some(),
9179            "soft delete must NOT cascade edges; get_edge returned None"
9180        );
9181
9182        let after = rt
9183            .neighbors(
9184                &tok,
9185                note_source.id,
9186                Direction::Out,
9187                None,
9188                Some(vec![EdgeRelation::Annotates]),
9189            )
9190            .await
9191            .unwrap();
9192        assert_eq!(
9193            after.len(),
9194            0,
9195            "#748: neighbors() must screen out the soft-deleted note target; got {after:?}"
9196        );
9197    }
9198
9199    // ---- delete_edge public-API safety ----
9200
9201    // Passing an entity/note UUID to `delete_edge` must return Ok(false) with no
9202    // side effects — it must NOT delete inbound annotates edges targeting that record.
9203    // Without the get_edge guard, the old code would cascade inbound edges before
9204    // returning false.
9205    #[tokio::test]
9206    async fn delete_edge_non_edge_uuid_has_no_side_effects() {
9207        let rt = rt();
9208        let tok = NamespaceToken::local();
9209
9210        // Create an entity that has an inbound annotates edge.
9211        let entity = rt
9212            .create_entity(&tok, "concept", None, "Target", None, None, vec![])
9213            .await
9214            .unwrap();
9215        let note = rt
9216            .create_note(
9217                &tok,
9218                "observation",
9219                None,
9220                "annotates the entity",
9221                Some(0.5),
9222                None,
9223                vec![entity.id],
9224            )
9225            .await
9226            .unwrap();
9227
9228        // Confirm the annotates edge exists.
9229        let before = rt
9230            .neighbors(
9231                &tok,
9232                note.id,
9233                Direction::Out,
9234                None,
9235                Some(vec![EdgeRelation::Annotates]),
9236            )
9237            .await
9238            .unwrap();
9239        assert_eq!(before.len(), 1, "annotates edge must exist before test");
9240        let annotates_edge_id: Uuid = before[0].edge_id;
9241
9242        // Call delete_edge with the entity UUID (NOT an edge UUID).
9243        let result = rt.delete_edge(&tok, entity.id, true).await;
9244        assert!(
9245            result.is_ok(),
9246            "delete_edge must not error on a non-edge UUID"
9247        );
9248        assert!(
9249            !result.unwrap(),
9250            "delete_edge must return false for a non-edge UUID"
9251        );
9252
9253        // The inbound annotates edge to the entity must still exist — no side effects.
9254        let after = rt
9255            .neighbors(
9256                &tok,
9257                note.id,
9258                Direction::Out,
9259                None,
9260                Some(vec![EdgeRelation::Annotates]),
9261            )
9262            .await
9263            .unwrap();
9264        assert_eq!(
9265            after.len(),
9266            1,
9267            "delete_edge with a non-edge UUID must not touch inbound annotates edges"
9268        );
9269        assert_eq!(
9270            after[0].edge_id, annotates_edge_id,
9271            "the original annotates edge must be unchanged"
9272        );
9273    }
9274
9275    // ---- create_note compensation branch ----
9276
9277    // This test injects a deterministic failure on the second `link` call inside
9278    // `create_note_inner` (the one that would create the second annotates edge).
9279    // It verifies that the compensation branch is wired — i.e. this test would
9280    // fail if the `Err(e)` rollback arm at operations.rs were deleted.
9281    //
9282    // Injection mechanism: LINK_FAIL_AFTER thread-local (ops.rs, cfg(test) only).
9283    // Setting it to 2 forces the 2nd link call to return an error.  The counter is
9284    // reset to 0 once triggered, so no other test is affected.
9285    #[tokio::test]
9286    async fn create_note_multi_annotates_second_link_failure_rolls_back_partial_write() {
9287        let rt = rt();
9288        let tok = NamespaceToken::local();
9289        let t1 = rt
9290            .create_entity(&tok, "concept", None, "T1", None, None, vec![])
9291            .await
9292            .unwrap();
9293        let t2 = rt
9294            .create_entity(&tok, "concept", None, "T2", None, None, vec![])
9295            .await
9296            .unwrap();
9297
9298        // Arm the injection: fail on the 2nd link (link_idx+1 == 2).
9299        LINK_FAIL_AFTER.with(|cell| cell.set(2));
9300
9301        let result = rt
9302            .create_note(
9303                &tok,
9304                "observation",
9305                None,
9306                "rollback target",
9307                Some(0.5),
9308                None,
9309                vec![t1.id, t2.id],
9310            )
9311            .await;
9312
9313        // The call must fail with the injected error.
9314        assert!(
9315            result.is_err(),
9316            "create_note must propagate the injected link failure"
9317        );
9318        let err_msg = result.unwrap_err().to_string();
9319        assert!(
9320            err_msg.contains("injected link failure"),
9321            "error must carry injection message; got: {err_msg}"
9322        );
9323
9324        // Compensation must have removed the note row.
9325        let notes = rt.list_notes(&tok, None, 1000, 0).await.unwrap();
9326        assert!(
9327            notes.is_empty(),
9328            "compensation must remove the note row; got {notes:?}"
9329        );
9330
9331        // FTS must have no hit for the content.
9332        let hits = rt
9333            .search_notes(&tok, "rollback target", None, 10, None, false, &[], None)
9334            .await
9335            .unwrap();
9336        assert!(
9337            hits.is_empty(),
9338            "compensation must clean FTS index; got {hits:?}"
9339        );
9340
9341        // No partial annotates edges must remain (first edge must have been deleted).
9342        let edges_from_t1 = rt
9343            .neighbors(
9344                &tok,
9345                t1.id,
9346                Direction::In,
9347                None,
9348                Some(vec![EdgeRelation::Annotates]),
9349            )
9350            .await
9351            .unwrap();
9352        let edges_from_t2 = rt
9353            .neighbors(
9354                &tok,
9355                t2.id,
9356                Direction::In,
9357                None,
9358                Some(vec![EdgeRelation::Annotates]),
9359            )
9360            .await
9361            .unwrap();
9362        assert!(
9363            edges_from_t1.is_empty(),
9364            "compensation must delete the first annotates edge; got {edges_from_t1:?}"
9365        );
9366        assert!(
9367            edges_from_t2.is_empty(),
9368            "no second annotates edge must exist; got {edges_from_t2:?}"
9369        );
9370    }
9371
9372    // Inject an FTS failure after the note row is committed and assert the note
9373    // row is removed (no stranded row).  arm_fts_fail() arms the flag before
9374    // the call and it resets automatically after one trigger.
9375    #[tokio::test]
9376    async fn create_note_fts_failure_rolls_back_note_row() {
9377        let rt = rt();
9378        // Unique namespace: FTS_FAIL_NS is a namespace-keyed set, so a
9379        // concurrently running test arming a different namespace never evicts
9380        // this test's arm. The namespace still guards against a same-test
9381        // mismatch between the armed value and the note actually being created.
9382        let ns = Namespace::parse("fault-fts-rollback").unwrap();
9383        let tok = NamespaceToken::for_namespace(ns.clone());
9384
9385        arm_fts_fail(ns.as_str());
9386
9387        let result = rt
9388            .create_note(
9389                &tok,
9390                "observation",
9391                None,
9392                "fts-fail rollback target",
9393                None,
9394                None,
9395                vec![],
9396            )
9397            .await;
9398
9399        assert!(
9400            result.is_err(),
9401            "create_note must propagate the injected FTS failure"
9402        );
9403        let err_msg = result.unwrap_err().to_string();
9404        assert!(
9405            err_msg.contains("injected FTS failure"),
9406            "error must carry injection message; got: {err_msg}"
9407        );
9408
9409        // Compensation must have removed the note row.
9410        let notes = rt.list_notes(&tok, None, 1000, 0).await.unwrap();
9411        assert!(
9412            notes.is_empty(),
9413            "compensation must remove the note row after FTS failure; got {notes:?}"
9414        );
9415    }
9416
9417    // Arming FTS_FAIL_NS on one OS thread must still fire on a `create_note`
9418    // call that runs on a genuinely different OS thread. Arms here on the
9419    // test's own (tokio current-thread) task, then hands the triggering
9420    // `create_note` call to a `std::thread::spawn` worker running its own
9421    // single-threaded tokio runtime — a stronger guarantee of thread migration
9422    // than `tokio::spawn`, which may schedule the spawned task back onto the
9423    // same worker. Proves the process-wide, namespace-keyed `FTS_FAIL_NS` set
9424    // is thread-independent.
9425    #[tokio::test]
9426    async fn create_note_fts_failure_fires_across_os_threads() {
9427        let rt = std::sync::Arc::new(rt());
9428        let ns = Namespace::parse("fault-fts-rollback-cross-thread").unwrap();
9429        let tok = NamespaceToken::for_namespace(ns.clone());
9430
9431        arm_fts_fail(ns.as_str());
9432
9433        let thread_rt = std::sync::Arc::clone(&rt);
9434        let thread_tok = tok.clone();
9435        let result = std::thread::spawn(move || {
9436            let worker = tokio::runtime::Builder::new_current_thread()
9437                .enable_all()
9438                .build()
9439                .expect("worker runtime must build");
9440            worker.block_on(thread_rt.create_note(
9441                &thread_tok,
9442                "observation",
9443                None,
9444                "fts-fail rollback target (cross-thread)",
9445                None,
9446                None,
9447                vec![],
9448            ))
9449        })
9450        .join()
9451        .expect("worker thread must not panic");
9452
9453        assert!(
9454            result.is_err(),
9455            "create_note on a different OS thread must still observe the injected FTS failure"
9456        );
9457        let err_msg = result.unwrap_err().to_string();
9458        assert!(
9459            err_msg.contains("injected FTS failure"),
9460            "error must carry injection message; got: {err_msg}"
9461        );
9462
9463        // Compensation must have removed the note row.
9464        let notes = rt.list_notes(&tok, None, 1000, 0).await.unwrap();
9465        assert!(
9466            notes.is_empty(),
9467            "compensation must remove the note row after FTS failure; got {notes:?}"
9468        );
9469    }
9470
9471    // Inject a vector insertion failure after note row + FTS commit and assert
9472    // both the note row and the FTS document are removed (no stranded rows).
9473    // Uses a unique namespace (see create_note_fts_failure_rolls_back_note_row)
9474    // so the process-global VECTOR_FAIL_NS flag is consumed only by this test.
9475    // Since the single registered provider fires embed_document before the
9476    // injection check, the injection converts the successful embedding into an
9477    // error just before the VectorStore insert, then disarms.
9478    #[tokio::test]
9479    async fn create_note_vector_failure_rolls_back_note_row_and_fts() {
9480        const MODEL: &str = "test-vec-inject";
9481        const DIMS: usize = 4;
9482
9483        let rt = KhiveRuntime::memory().unwrap();
9484        let (provider, _counter) = ConstVecProvider::new(MODEL, DIMS);
9485        rt.register_embedder(provider);
9486
9487        let ns = Namespace::parse("fault-vec-rollback").unwrap();
9488        let tok = NamespaceToken::for_namespace(ns.clone());
9489
9490        arm_vector_fail(ns.as_str());
9491
9492        let result = rt
9493            .create_note(
9494                &tok,
9495                "observation",
9496                None,
9497                "vec-fail rollback target",
9498                None,
9499                None,
9500                vec![],
9501            )
9502            .await;
9503
9504        assert!(
9505            result.is_err(),
9506            "create_note must propagate the injected vector failure"
9507        );
9508        let err_msg = result.unwrap_err().to_string();
9509        assert!(
9510            err_msg.contains("injected vector failure"),
9511            "error must carry injection message; got: {err_msg}"
9512        );
9513
9514        // Compensation must have removed the note row.
9515        let notes = rt.list_notes(&tok, None, 1000, 0).await.unwrap();
9516        assert!(
9517            notes.is_empty(),
9518            "compensation must remove note row after vector failure; got {notes:?}"
9519        );
9520    }
9521
9522    // The `embedding_content` override must not bypass the same
9523    // FTS/vector compensation the plain `create_note` path already has —
9524    // both use `create_note_inner` underneath, but these tests exercise it
9525    // through `create_note_with_embedding_content` with a real Some(head)
9526    // override to prove the override path shares the identical rollback.
9527    #[tokio::test]
9528    async fn create_note_with_embedding_content_fts_failure_rolls_back_note_row() {
9529        let rt = rt();
9530        let ns = Namespace::parse("fault-fts-rollback-embedding-content").unwrap();
9531        let tok = NamespaceToken::for_namespace(ns.clone());
9532
9533        arm_fts_fail(ns.as_str());
9534
9535        let full = "fts-fail rollback target with an embedding-content override";
9536        let head = &full[.."fts-fail rollback target".len()];
9537        let result = rt
9538            .create_note_with_embedding_content(
9539                &tok,
9540                "observation",
9541                None,
9542                full,
9543                Some(head),
9544                None,
9545                None,
9546                vec![],
9547            )
9548            .await;
9549
9550        assert!(
9551            result.is_err(),
9552            "create_note_with_embedding_content must propagate the injected FTS failure"
9553        );
9554        let err_msg = result.unwrap_err().to_string();
9555        assert!(
9556            err_msg.contains("injected FTS failure"),
9557            "error must carry injection message; got: {err_msg}"
9558        );
9559
9560        // Compensation must have removed the note row; a failed create must
9561        // never leave a stranded row behind just because it carried an
9562        // embedding_content override.
9563        let notes = rt.list_notes(&tok, None, 1000, 0).await.unwrap();
9564        assert!(
9565            notes.is_empty(),
9566            "compensation must remove the note row after FTS failure; got {notes:?}"
9567        );
9568    }
9569
9570    #[tokio::test]
9571    async fn create_note_with_embedding_content_vector_failure_rolls_back_note_row_and_fts() {
9572        const MODEL: &str = "test-vec-inject-embedding-content";
9573        const DIMS: usize = 4;
9574
9575        let rt = KhiveRuntime::memory().unwrap();
9576        let (provider, _counter) = ConstVecProvider::new(MODEL, DIMS);
9577        rt.register_embedder(provider);
9578
9579        let ns = Namespace::parse("fault-vec-rollback-embedding-content").unwrap();
9580        let tok = NamespaceToken::for_namespace(ns.clone());
9581
9582        arm_vector_fail(ns.as_str());
9583
9584        let full = "vec-fail rollback target with an embedding-content override";
9585        let head = &full[.."vec-fail rollback target".len()];
9586        let result = rt
9587            .create_note_with_embedding_content(
9588                &tok,
9589                "observation",
9590                None,
9591                full,
9592                Some(head),
9593                None,
9594                None,
9595                vec![],
9596            )
9597            .await;
9598
9599        assert!(
9600            result.is_err(),
9601            "create_note_with_embedding_content must propagate the injected vector failure"
9602        );
9603        let err_msg = result.unwrap_err().to_string();
9604        assert!(
9605            err_msg.contains("injected vector failure"),
9606            "error must carry injection message; got: {err_msg}"
9607        );
9608
9609        // Compensation must have removed the note row: the ingest-layer
9610        // truncation counter only increments in the successful-create arm,
9611        // so a failed create — with or without an embedding_content override
9612        // — can never cause a spurious truncation count on the caller side.
9613        let notes = rt.list_notes(&tok, None, 1000, 0).await.unwrap();
9614        assert!(
9615            notes.is_empty(),
9616            "compensation must remove note row after vector failure; got {notes:?}"
9617        );
9618    }
9619
9620    // ---- soft-delete index cleanup tests ----
9621
9622    #[tokio::test]
9623    async fn soft_delete_entity_removes_indexes() {
9624        let rt = rt();
9625        let tok = NamespaceToken::local();
9626        let entity = rt
9627            .create_entity(
9628                &tok,
9629                "concept",
9630                None,
9631                "QuantumEntanglement",
9632                Some("unique FTS term xzqjwv for soft delete test"),
9633                None,
9634                vec![],
9635            )
9636            .await
9637            .unwrap();
9638
9639        let ns = tok.namespace().as_str().to_string();
9640
9641        let before = rt
9642            .text(&tok)
9643            .unwrap()
9644            .search(TextSearchRequest {
9645                query: "xzqjwv".to_string(),
9646                mode: TextQueryMode::Plain,
9647                filter: Some(TextFilter {
9648                    namespaces: vec![ns.clone()],
9649                    ..Default::default()
9650                }),
9651                top_k: 10,
9652                snippet_chars: 100,
9653            })
9654            .await
9655            .unwrap();
9656        assert!(
9657            before.iter().any(|h| h.subject_id == entity.id),
9658            "entity must be in FTS before soft-delete"
9659        );
9660
9661        let deleted = rt.delete_entity(&tok, entity.id, false).await.unwrap();
9662        assert!(deleted, "soft delete must return true");
9663
9664        let after = rt
9665            .text(&tok)
9666            .unwrap()
9667            .search(TextSearchRequest {
9668                query: "xzqjwv".to_string(),
9669                mode: TextQueryMode::Plain,
9670                filter: Some(TextFilter {
9671                    namespaces: vec![ns],
9672                    ..Default::default()
9673                }),
9674                top_k: 10,
9675                snippet_chars: 100,
9676            })
9677            .await
9678            .unwrap();
9679        assert!(
9680            after.iter().all(|h| h.subject_id != entity.id),
9681            "soft-deleted entity must be removed from FTS index"
9682        );
9683    }
9684
9685    #[tokio::test]
9686    async fn soft_delete_note_removes_indexes() {
9687        let rt = rt();
9688        let tok = NamespaceToken::local();
9689        let note = rt
9690            .create_note(
9691                &tok,
9692                "observation",
9693                None,
9694                "SpectralDecomposition unique term yvwkqz for soft delete test",
9695                Some(0.7),
9696                None,
9697                vec![],
9698            )
9699            .await
9700            .unwrap();
9701
9702        let before = rt
9703            .search_notes(&tok, "yvwkqz", None, 10, None, false, &[], None)
9704            .await
9705            .unwrap();
9706        assert!(
9707            before.iter().any(|h| h.note_id == note.id),
9708            "note must be in FTS before soft-delete"
9709        );
9710
9711        let deleted = rt.delete_note(&tok, note.id, false).await.unwrap();
9712        assert!(deleted, "soft delete must return true");
9713
9714        let after = rt
9715            .search_notes(&tok, "yvwkqz", None, 10, None, false, &[], None)
9716            .await
9717            .unwrap();
9718        assert!(
9719            after.iter().all(|h| h.note_id != note.id),
9720            "soft-deleted note must be removed from FTS index"
9721        );
9722    }
9723
9724    // Base endpoint allowlist: unlisted triples must fail closed.
9725    // Document->Document Extends is not in the allowlist.
9726    #[tokio::test]
9727    async fn link_extends_document_to_document_returns_invalid_input() {
9728        let rt = rt();
9729        let tok = NamespaceToken::local();
9730        let d1 = rt
9731            .create_entity(&tok, "document", None, "DocA", None, None, vec![])
9732            .await
9733            .unwrap();
9734        let d2 = rt
9735            .create_entity(&tok, "document", None, "DocB", None, None, vec![])
9736            .await
9737            .unwrap();
9738        let result = rt
9739            .link(&tok, d1.id, d2.id, EdgeRelation::Extends, 1.0, None)
9740            .await;
9741        assert!(
9742            result.is_err(),
9743            "F010: document->document Extends must be rejected by the base allowlist; \
9744             current generic entity fallthrough incorrectly accepts it"
9745        );
9746    }
9747
9748    // Happy path: Concept->Concept Extends is in the base allowlist and must succeed.
9749    #[tokio::test]
9750    async fn link_extends_concept_to_concept_succeeds() {
9751        let rt = rt();
9752        let tok = NamespaceToken::local();
9753        let a = rt
9754            .create_entity(&tok, "concept", None, "CA", None, None, vec![])
9755            .await
9756            .unwrap();
9757        let b = rt
9758            .create_entity(&tok, "concept", None, "CB", None, None, vec![])
9759            .await
9760            .unwrap();
9761        let result = rt
9762            .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
9763            .await;
9764        assert!(
9765            result.is_ok(),
9766            "F010: concept->concept Extends must be allowed (base allowlist)"
9767        );
9768    }
9769
9770    // CompetesWith is symmetric; reversed pair must deduplicate to one canonical row.
9771    #[tokio::test]
9772    async fn link_symmetric_relation_canonicalizes_endpoint_order() {
9773        use khive_storage::EdgeFilter;
9774        let rt = rt();
9775        let tok = NamespaceToken::local();
9776        let a = rt
9777            .create_entity(&tok, "concept", None, "ConceptP", None, None, vec![])
9778            .await
9779            .unwrap();
9780        let b = rt
9781            .create_entity(&tok, "concept", None, "ConceptQ", None, None, vec![])
9782            .await
9783            .unwrap();
9784        // Link A->B then B->A with the same symmetric relation.
9785        rt.link(&tok, a.id, b.id, EdgeRelation::CompetesWith, 1.0, None)
9786            .await
9787            .unwrap();
9788        rt.link(&tok, b.id, a.id, EdgeRelation::CompetesWith, 1.0, None)
9789            .await
9790            .unwrap();
9791        let count = rt
9792            .graph(&tok)
9793            .unwrap()
9794            .count_edges(EdgeFilter::default())
9795            .await
9796            .unwrap();
9797        assert_eq!(
9798            count,
9799            1,
9800            "F012: CompetesWith is symmetric; A->B and B->A must deduplicate to one canonical row; \
9801             found {count} rows (canonicalization not yet implemented)"
9802        );
9803    }
9804
9805    // Supersedes: positive tests for all 5 allowed entity kinds.
9806    #[tokio::test]
9807    async fn f010_supersedes_document_to_document_allowed() {
9808        let rt = rt();
9809        let tok = NamespaceToken::local();
9810        let a = rt
9811            .create_entity(&tok, "document", None, "DocA", None, None, vec![])
9812            .await
9813            .unwrap();
9814        let b = rt
9815            .create_entity(&tok, "document", None, "DocB", None, None, vec![])
9816            .await
9817            .unwrap();
9818        let result = rt
9819            .link(&tok, b.id, a.id, EdgeRelation::Supersedes, 1.0, None)
9820            .await;
9821        assert!(
9822            result.is_ok(),
9823            "document->document Supersedes must be allowed (allowlist), got {result:?}"
9824        );
9825    }
9826
9827    #[tokio::test]
9828    async fn f010_supersedes_artifact_to_artifact_allowed() {
9829        let rt = rt();
9830        let tok = NamespaceToken::local();
9831        let a = rt
9832            .create_entity(&tok, "artifact", None, "ArtA", None, None, vec![])
9833            .await
9834            .unwrap();
9835        let b = rt
9836            .create_entity(&tok, "artifact", None, "ArtB", None, None, vec![])
9837            .await
9838            .unwrap();
9839        let result = rt
9840            .link(&tok, b.id, a.id, EdgeRelation::Supersedes, 1.0, None)
9841            .await;
9842        assert!(
9843            result.is_ok(),
9844            "artifact->artifact Supersedes must be allowed (allowlist), got {result:?}"
9845        );
9846    }
9847
9848    #[tokio::test]
9849    async fn f010_supersedes_service_to_service_allowed() {
9850        let rt = rt();
9851        let tok = NamespaceToken::local();
9852        let a = rt
9853            .create_entity(&tok, "service", None, "SvcA", None, None, vec![])
9854            .await
9855            .unwrap();
9856        let b = rt
9857            .create_entity(&tok, "service", None, "SvcB", None, None, vec![])
9858            .await
9859            .unwrap();
9860        let result = rt
9861            .link(&tok, b.id, a.id, EdgeRelation::Supersedes, 1.0, None)
9862            .await;
9863        assert!(
9864            result.is_ok(),
9865            "service->service Supersedes must be allowed (allowlist), got {result:?}"
9866        );
9867    }
9868
9869    #[tokio::test]
9870    async fn f010_supersedes_dataset_to_dataset_allowed() {
9871        let rt = rt();
9872        let tok = NamespaceToken::local();
9873        let a = rt
9874            .create_entity(&tok, "dataset", None, "DataA", None, None, vec![])
9875            .await
9876            .unwrap();
9877        let b = rt
9878            .create_entity(&tok, "dataset", None, "DataB", None, None, vec![])
9879            .await
9880            .unwrap();
9881        let result = rt
9882            .link(&tok, b.id, a.id, EdgeRelation::Supersedes, 1.0, None)
9883            .await;
9884        assert!(
9885            result.is_ok(),
9886            "dataset->dataset Supersedes must be allowed (allowlist), got {result:?}"
9887        );
9888    }
9889
9890    // Supersedes: negative tests for rejected entity kinds.
9891    #[tokio::test]
9892    async fn f010_supersedes_project_to_project_rejected() {
9893        let rt = rt();
9894        let tok = NamespaceToken::local();
9895        let a = rt
9896            .create_entity(&tok, "project", None, "ProjA", None, None, vec![])
9897            .await
9898            .unwrap();
9899        let b = rt
9900            .create_entity(&tok, "project", None, "ProjB", None, None, vec![])
9901            .await
9902            .unwrap();
9903        let result = rt
9904            .link(&tok, b.id, a.id, EdgeRelation::Supersedes, 1.0, None)
9905            .await;
9906        assert!(
9907            matches!(result, Err(RuntimeError::InvalidInput(_))),
9908            "project->project Supersedes must be rejected (not in allowlist), got {result:?}"
9909        );
9910    }
9911
9912    #[tokio::test]
9913    async fn f010_supersedes_person_to_person_rejected() {
9914        let rt = rt();
9915        let tok = NamespaceToken::local();
9916        let a = rt
9917            .create_entity(&tok, "person", None, "Alice", None, None, vec![])
9918            .await
9919            .unwrap();
9920        let b = rt
9921            .create_entity(&tok, "person", None, "Bob", None, None, vec![])
9922            .await
9923            .unwrap();
9924        let result = rt
9925            .link(&tok, b.id, a.id, EdgeRelation::Supersedes, 1.0, None)
9926            .await;
9927        assert!(
9928            matches!(result, Err(RuntimeError::InvalidInput(_))),
9929            "person->person Supersedes must be rejected (not in allowlist), got {result:?}"
9930        );
9931    }
9932
9933    #[tokio::test]
9934    async fn f010_supersedes_org_to_org_rejected() {
9935        let rt = rt();
9936        let tok = NamespaceToken::local();
9937        let a = rt
9938            .create_entity(&tok, "org", None, "OrgA", None, None, vec![])
9939            .await
9940            .unwrap();
9941        let b = rt
9942            .create_entity(&tok, "org", None, "OrgB", None, None, vec![])
9943            .await
9944            .unwrap();
9945        let result = rt
9946            .link(&tok, b.id, a.id, EdgeRelation::Supersedes, 1.0, None)
9947            .await;
9948        assert!(
9949            matches!(result, Err(RuntimeError::InvalidInput(_))),
9950            "org->org Supersedes must be rejected (not in allowlist), got {result:?}"
9951        );
9952    }
9953
9954    // Supersedes entity→entity: same kind (concept→concept) must be allowed.
9955    #[tokio::test]
9956    async fn f010_supersedes_same_kind_entity_allowed() {
9957        let rt = rt();
9958        let tok = NamespaceToken::local();
9959        let a = rt
9960            .create_entity(&tok, "concept", None, "OldV", None, None, vec![])
9961            .await
9962            .unwrap();
9963        let b = rt
9964            .create_entity(&tok, "concept", None, "NewV", None, None, vec![])
9965            .await
9966            .unwrap();
9967        let result = rt
9968            .link(&tok, b.id, a.id, EdgeRelation::Supersedes, 1.0, None)
9969            .await;
9970        assert!(
9971            result.is_ok(),
9972            "concept->concept Supersedes must be allowed by the base allowlist, got {result:?}"
9973        );
9974    }
9975
9976    // target_backend invariant: all edges written through link() must have
9977    // target_backend = None because validate_edge_relation_endpoints already ensured the
9978    // target exists locally.
9979    #[tokio::test]
9980    async fn f161_link_always_writes_null_target_backend() {
9981        let rt = rt();
9982        let tok = NamespaceToken::local();
9983        let a = rt
9984            .create_entity(&tok, "concept", None, "A", None, None, vec![])
9985            .await
9986            .unwrap();
9987        let b = rt
9988            .create_entity(&tok, "concept", None, "B", None, None, vec![])
9989            .await
9990            .unwrap();
9991        let edge = rt
9992            .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
9993            .await
9994            .unwrap();
9995        assert!(
9996            edge.target_backend.is_none(),
9997            "F161: target_backend must be None for locally-routed edges; got {:?}",
9998            edge.target_backend
9999        );
10000    }
10001
10002    // link_many must also write null target_backend for all local edges.
10003    #[tokio::test]
10004    async fn f161_link_many_always_writes_null_target_backend() {
10005        let rt = rt();
10006        let tok = NamespaceToken::local();
10007        let a = rt
10008            .create_entity(&tok, "concept", None, "A", None, None, vec![])
10009            .await
10010            .unwrap();
10011        let b = rt
10012            .create_entity(&tok, "concept", None, "B", None, None, vec![])
10013            .await
10014            .unwrap();
10015        let c = rt
10016            .create_entity(&tok, "concept", None, "C", None, None, vec![])
10017            .await
10018            .unwrap();
10019        let specs = vec![
10020            LinkSpec {
10021                namespace: None,
10022                source_id: a.id,
10023                target_id: b.id,
10024                relation: EdgeRelation::Extends,
10025                weight: 1.0,
10026                metadata: None,
10027            },
10028            LinkSpec {
10029                namespace: None,
10030                source_id: a.id,
10031                target_id: c.id,
10032                relation: EdgeRelation::Enables,
10033                weight: 1.0,
10034                metadata: None,
10035            },
10036        ];
10037        let edges = rt.link_many(&tok, specs).await.unwrap();
10038        for edge in &edges {
10039            assert!(
10040                edge.target_backend.is_none(),
10041                "F161: target_backend must be None for locally-routed edges in link_many; got {:?}",
10042                edge.target_backend
10043            );
10044        }
10045    }
10046
10047    // Symmetric relation neighbors: competes_with queried from the non-canonical
10048    // endpoint must still return results when direction=Out is requested.
10049    #[tokio::test]
10050    async fn f012_symmetric_neighbors_visible_from_both_endpoints() {
10051        let rt = rt();
10052        let tok = NamespaceToken::local();
10053        let a = rt
10054            .create_entity(&tok, "concept", None, "A", None, None, vec![])
10055            .await
10056            .unwrap();
10057        let b = rt
10058            .create_entity(&tok, "concept", None, "B", None, None, vec![])
10059            .await
10060            .unwrap();
10061        // Link A→B competes_with; if A.id > B.id the edge is stored as B→A (canonical).
10062        rt.link(&tok, a.id, b.id, EdgeRelation::CompetesWith, 1.0, None)
10063            .await
10064            .unwrap();
10065        // Both endpoints should see the edge regardless of direction=Out.
10066        let from_a = rt
10067            .neighbors(
10068                &tok,
10069                a.id,
10070                Direction::Out,
10071                None,
10072                Some(vec![EdgeRelation::CompetesWith]),
10073            )
10074            .await
10075            .unwrap();
10076        let from_b = rt
10077            .neighbors(
10078                &tok,
10079                b.id,
10080                Direction::Out,
10081                None,
10082                Some(vec![EdgeRelation::CompetesWith]),
10083            )
10084            .await
10085            .unwrap();
10086        assert_eq!(
10087            from_a.len(),
10088            1,
10089            "node A must see competes_with neighbor from Direction::Out (F012); got {from_a:?}"
10090        );
10091        assert_eq!(
10092            from_b.len(),
10093            1,
10094            "node B must see competes_with neighbor from Direction::Out (F012); got {from_b:?}"
10095        );
10096    }
10097
10098    // Fix 1: Supersedes entity→entity — cross-kind (concept→document) must be rejected.
10099    #[tokio::test]
10100    async fn f010_supersedes_cross_kind_entity_rejected() {
10101        let rt = rt();
10102        let tok = NamespaceToken::local();
10103        let concept = rt
10104            .create_entity(&tok, "concept", None, "MyConcept", None, None, vec![])
10105            .await
10106            .unwrap();
10107        let doc = rt
10108            .create_entity(&tok, "document", None, "MyDoc", None, None, vec![])
10109            .await
10110            .unwrap();
10111        let result = rt
10112            .link(
10113                &tok,
10114                concept.id,
10115                doc.id,
10116                EdgeRelation::Supersedes,
10117                1.0,
10118                None,
10119            )
10120            .await;
10121        assert!(
10122            matches!(result, Err(RuntimeError::InvalidInput(_))),
10123            "concept->document Supersedes must be rejected by the base allowlist, got {result:?}"
10124        );
10125    }
10126
10127    // Cross-namespace delete_note now succeeds (UUID v4 is globally unique,
10128    // no namespace isolation on by-ID ops).
10129    #[tokio::test]
10130    async fn delete_note_cross_namespace_succeeds() {
10131        let rt = rt();
10132        let ns_a = NamespaceToken::for_namespace(Namespace::parse("ns-a").unwrap());
10133        let ns_b = NamespaceToken::for_namespace(Namespace::parse("ns-b").unwrap());
10134        let note = rt
10135            .create_note(
10136                &ns_a,
10137                "observation",
10138                None,
10139                "note in ns-a",
10140                Some(0.8),
10141                None,
10142                vec![],
10143            )
10144            .await
10145            .unwrap();
10146
10147        // Delete from a different namespace must now SUCCEED.
10148        let result = rt.delete_note(&ns_b, note.id, false).await;
10149        assert!(
10150            result.unwrap(),
10151            "cross-namespace delete_note (soft) must return Ok(true)"
10152        );
10153
10154        // Note must be gone from ns-a storage after the cross-ns soft delete.
10155        let note_store = rt.notes(&ns_a).unwrap();
10156        let gone = note_store.get_note(note.id).await.unwrap();
10157        assert!(
10158            gone.is_none(),
10159            "note must be soft-deleted in its home namespace after cross-ns delete"
10160        );
10161
10162        // Hard-delete path: create a fresh note and hard-delete from foreign token.
10163        let note2 = rt
10164            .create_note(
10165                &ns_a,
10166                "observation",
10167                None,
10168                "note2 in ns-a",
10169                Some(0.5),
10170                None,
10171                vec![],
10172            )
10173            .await
10174            .unwrap();
10175        let hard_result = rt.delete_note(&ns_b, note2.id, true).await;
10176        assert!(
10177            hard_result.unwrap(),
10178            "cross-namespace hard delete_note must return Ok(true)"
10179        );
10180        let gone2 = rt
10181            .get_note_including_deleted(&ns_a, note2.id)
10182            .await
10183            .unwrap();
10184        assert!(
10185            gone2.is_none(),
10186            "hard-deleted note must not appear even in including_deleted query"
10187        );
10188    }
10189
10190    // Regression: parallel link_many calls with overlapping triples must
10191    // return the identical persisted edge ID, not locally-generated phantom IDs.
10192    //
10193    // Sequence:
10194    //   1. First link_many creates the A→B Extends edge (persisted with ID₁).
10195    //   2. Second link_many upserts the same triple (ON CONFLICT DO UPDATE keeps ID₁).
10196    //   3. Both callers must see ID₁ in their returned Vec<Edge>.
10197    #[tokio::test]
10198    async fn link_many_overlapping_triple_returns_persisted_ids() {
10199        let rt = rt();
10200        let tok = NamespaceToken::local();
10201        let a = rt
10202            .create_entity(&tok, "concept", None, "A", None, None, vec![])
10203            .await
10204            .unwrap();
10205        let b = rt
10206            .create_entity(&tok, "concept", None, "B", None, None, vec![])
10207            .await
10208            .unwrap();
10209
10210        let spec = || LinkSpec {
10211            namespace: None,
10212            source_id: a.id,
10213            target_id: b.id,
10214            relation: EdgeRelation::Extends,
10215            weight: 1.0,
10216            metadata: None,
10217        };
10218
10219        // First call — creates the edge.
10220        let first = rt.link_many(&tok, vec![spec()]).await.unwrap();
10221        assert_eq!(first.len(), 1);
10222        let persisted_id: Uuid = first[0].id.into();
10223
10224        // Second call — same natural-key triple; ON CONFLICT updates, preserving the
10225        // existing row ID. link_many must read back the row and return that same ID.
10226        let second = rt.link_many(&tok, vec![spec()]).await.unwrap();
10227        assert_eq!(second.len(), 1);
10228        let second_id: Uuid = second[0].id.into();
10229
10230        assert_eq!(
10231            persisted_id, second_id,
10232            "link_many with an existing triple must return the persisted row ID ({persisted_id}), \
10233             not a new phantom ID ({second_id})"
10234        );
10235
10236        // Confirm only one edge row exists in the graph store.
10237        let count = rt
10238            .count_edges(&tok, crate::curation::EdgeListFilter::default())
10239            .await
10240            .unwrap();
10241        assert_eq!(count, 1, "upsert must not duplicate the edge row");
10242    }
10243
10244    // ── create_many: batch entity creation ───────────────────────────────────
10245
10246    #[tokio::test]
10247    async fn create_many_persists_all_entities() {
10248        let rt = rt();
10249        let tok = NamespaceToken::local();
10250
10251        let specs: Vec<EntityCreateSpec> = (0..5)
10252            .map(|i| EntityCreateSpec {
10253                kind: "concept".into(),
10254                entity_type: None,
10255                name: format!("BulkConcept-{i}"),
10256                description: Some(format!("desc {i}")),
10257                properties: None,
10258                tags: vec!["bulk-test".into()],
10259            })
10260            .collect();
10261
10262        let entities = rt.create_many(&tok, specs).await.unwrap();
10263        assert_eq!(entities.len(), 5, "all 5 entities must be returned");
10264
10265        // Verify each one is retrievable from storage.
10266        for entity in &entities {
10267            let fetched = rt.get_entity(&tok, entity.id).await.unwrap();
10268            assert_eq!(fetched.id, entity.id);
10269        }
10270    }
10271
10272    #[tokio::test]
10273    async fn create_many_empty_name_rejects_atomically() {
10274        let rt = rt();
10275        let tok = NamespaceToken::local();
10276
10277        let specs = vec![
10278            EntityCreateSpec {
10279                kind: "concept".into(),
10280                entity_type: None,
10281                name: "ValidEntity".into(),
10282                description: None,
10283                properties: None,
10284                tags: vec![],
10285            },
10286            EntityCreateSpec {
10287                kind: "concept".into(),
10288                entity_type: None,
10289                name: "".into(), // invalid — triggers atomic rejection
10290                description: None,
10291                properties: None,
10292                tags: vec![],
10293            },
10294        ];
10295
10296        let result = rt.create_many(&tok, specs).await;
10297        assert!(
10298            matches!(result, Err(RuntimeError::InvalidInput(_))),
10299            "empty name must produce InvalidInput error"
10300        );
10301
10302        // Nothing must have been written — list_entities returns 0 items.
10303        let rows = rt.list_entities(&tok, None, None, 100, 0).await.unwrap();
10304        assert_eq!(
10305            rows.len(),
10306            0,
10307            "atomic rejection must leave storage unchanged"
10308        );
10309    }
10310
10311    // entity_type validated at runtime layer when validator is installed.
10312    #[tokio::test]
10313    async fn create_many_rejects_unknown_entity_type_when_validator_installed() {
10314        let rt = rt();
10315        let tok = NamespaceToken::local();
10316
10317        // Install a mock validator that only accepts "algorithm" for "concept".
10318        rt.install_entity_type_validator(Arc::new(|kind, entity_type| {
10319            let Some(raw) = entity_type else {
10320                return Ok(None);
10321            };
10322            if kind == "concept" && raw == "algorithm" {
10323                return Ok(Some("algorithm".to_string()));
10324            }
10325            Err(RuntimeError::InvalidInput(format!(
10326                "unknown entity_type {raw:?} for {kind:?}; valid: algorithm"
10327            )))
10328        }));
10329
10330        let bad_spec = vec![EntityCreateSpec {
10331            kind: "concept".into(),
10332            entity_type: Some("not_a_registered_type".into()),
10333            name: "ShouldNotLand".into(),
10334            description: None,
10335            properties: None,
10336            tags: vec![],
10337        }];
10338
10339        let result = rt.create_many(&tok, bad_spec).await;
10340        assert!(
10341            matches!(result, Err(RuntimeError::InvalidInput(_))),
10342            "unknown entity_type must be rejected by the runtime-layer validator; got {result:?}"
10343        );
10344
10345        // Zero rows written — validator fires before any storage call.
10346        let rows = rt.list_entities(&tok, None, None, 100, 0).await.unwrap();
10347        assert_eq!(
10348            rows.len(),
10349            0,
10350            "validator rejection must leave storage empty"
10351        );
10352    }
10353
10354    // Valid entity_type passes through and is normalised by the validator.
10355    #[tokio::test]
10356    async fn create_many_accepts_valid_entity_type_via_validator() {
10357        let rt = rt();
10358        let tok = NamespaceToken::local();
10359
10360        rt.install_entity_type_validator(Arc::new(|kind, entity_type| {
10361            let Some(raw) = entity_type else {
10362                return Ok(None);
10363            };
10364            if kind == "concept" && raw == "algorithm" {
10365                return Ok(Some("algorithm".to_string()));
10366            }
10367            Err(RuntimeError::InvalidInput(format!(
10368                "unknown entity_type {raw:?} for {kind:?}"
10369            )))
10370        }));
10371
10372        let specs = vec![EntityCreateSpec {
10373            kind: "concept".into(),
10374            entity_type: Some("algorithm".into()),
10375            name: "BubbleSort".into(),
10376            description: None,
10377            properties: None,
10378            tags: vec![],
10379        }];
10380
10381        let entities = rt.create_many(&tok, specs).await.unwrap();
10382        assert_eq!(entities.len(), 1, "valid entity_type must be accepted");
10383        assert_eq!(
10384            entities[0].entity_type.as_deref(),
10385            Some("algorithm"),
10386            "entity_type must be stored as returned by the validator"
10387        );
10388    }
10389
10390    // FTS failure in create_many rolls back both substrates.
10391    //
10392    // Arm `arm_fts_fail_many` before the call; the FTS phase returns an injected
10393    // error; the test asserts zero rows in both `entities` and `fts_entities`.
10394    #[tokio::test]
10395    async fn create_many_fts_failure_rolls_back_both_substrates() {
10396        // Use a unique namespace so the process-global one-shot is unaffected by
10397        // other concurrent tests.
10398        let ns = format!("fts-fail-many-{}", uuid::Uuid::new_v4().as_simple());
10399        let rt = rt();
10400        let tok = NamespaceToken::for_namespace(Namespace::parse(&ns).unwrap());
10401
10402        let specs = vec![
10403            EntityCreateSpec {
10404                kind: "concept".into(),
10405                entity_type: None,
10406                name: "FtsRollbackA".into(),
10407                description: None,
10408                properties: None,
10409                tags: vec![],
10410            },
10411            EntityCreateSpec {
10412                kind: "concept".into(),
10413                entity_type: None,
10414                name: "FtsRollbackB".into(),
10415                description: None,
10416                properties: None,
10417                tags: vec![],
10418            },
10419        ];
10420
10421        arm_fts_fail_many(&ns);
10422        let result = rt.create_many(&tok, specs).await;
10423
10424        assert!(
10425            result.is_err(),
10426            "create_many must return Err when FTS write fails"
10427        );
10428
10429        // Entity substrate must be empty — entity rows must have been rolled back.
10430        let entity_rows = rt.list_entities(&tok, None, None, 100, 0).await.unwrap();
10431        assert_eq!(
10432            entity_rows.len(),
10433            0,
10434            "entity rows must be rolled back on FTS failure; found {entity_rows:?}"
10435        );
10436
10437        // FTS substrate must be empty — no stale fts_entities rows.
10438        let fts = rt.text(&tok).unwrap();
10439        let fts_count = fts
10440            .count(TextFilter {
10441                ids: vec![],
10442                kinds: vec![],
10443                namespaces: vec![ns.clone()],
10444            })
10445            .await
10446            .unwrap();
10447        assert_eq!(
10448            fts_count, 0,
10449            "fts_entities must be empty after FTS-failure rollback; found {fts_count}"
10450        );
10451    }
10452
10453    // FTS partial-failure (Ok(summary) with summary.failed > 0) rolls back
10454    // both substrates.
10455    //
10456    // The production code has a distinct arm:
10457    //   Ok(summary) if summary.failed > 0 => return Err(...)
10458    // This test exercises that arm by arming `arm_fts_fail_many_partial`, which
10459    // returns Ok(BatchWriteSummary { failed: 1, ... }) instead of a hard Err.
10460    // Both entity rows and FTS rows must be empty after rollback.
10461    #[tokio::test]
10462    async fn create_many_fts_partial_failure_rolls_back_both_substrates() {
10463        let ns = format!("fts-fail-partial-{}", uuid::Uuid::new_v4().as_simple());
10464        let rt = rt();
10465        let tok = NamespaceToken::for_namespace(Namespace::parse(&ns).unwrap());
10466
10467        let specs = vec![
10468            EntityCreateSpec {
10469                kind: "concept".into(),
10470                entity_type: None,
10471                name: "PartialRollbackA".into(),
10472                description: None,
10473                properties: None,
10474                tags: vec![],
10475            },
10476            EntityCreateSpec {
10477                kind: "concept".into(),
10478                entity_type: None,
10479                name: "PartialRollbackB".into(),
10480                description: None,
10481                properties: None,
10482                tags: vec![],
10483            },
10484        ];
10485
10486        arm_fts_fail_many_partial(&ns);
10487        let result = rt.create_many(&tok, specs).await;
10488
10489        assert!(
10490            result.is_err(),
10491            "create_many must return Err when FTS summary.failed > 0"
10492        );
10493
10494        // Entity substrate must be empty — entity rows must have been rolled back.
10495        let entity_rows = rt.list_entities(&tok, None, None, 100, 0).await.unwrap();
10496        assert_eq!(
10497            entity_rows.len(),
10498            0,
10499            "entity rows must be rolled back when FTS summary.failed > 0; found {entity_rows:?}"
10500        );
10501
10502        // FTS substrate must be empty — no stale fts_entities rows.
10503        let fts = rt.text(&tok).unwrap();
10504        let fts_count = fts
10505            .count(TextFilter {
10506                ids: vec![],
10507                kinds: vec![],
10508                namespaces: vec![ns.clone()],
10509            })
10510            .await
10511            .unwrap();
10512        assert_eq!(
10513            fts_count, 0,
10514            "fts_entities must be empty after partial-FTS-failure rollback; found {fts_count}"
10515        );
10516    }
10517
10518    // ── Cross-namespace get_edge now succeeds (UUID v4 is globally unique) ──
10519
10520    #[tokio::test]
10521    async fn get_edge_cross_namespace_succeeds() {
10522        let rt = rt();
10523        let ns_a = NamespaceToken::for_namespace(Namespace::parse("ns-a").unwrap());
10524        let ns_b = NamespaceToken::for_namespace(Namespace::parse("ns-b").unwrap());
10525
10526        let src = rt
10527            .create_entity(&ns_a, "concept", None, "Src", None, None, vec![])
10528            .await
10529            .unwrap();
10530        let tgt = rt
10531            .create_entity(&ns_a, "concept", None, "Tgt", None, None, vec![])
10532            .await
10533            .unwrap();
10534        let edge = rt
10535            .link(&ns_a, src.id, tgt.id, EdgeRelation::Extends, 1.0, None)
10536            .await
10537            .unwrap();
10538
10539        // Visible from own namespace.
10540        let own_ns = rt.get_edge(&ns_a, Uuid::from(edge.id)).await;
10541        assert!(
10542            own_ns.is_ok() && own_ns.unwrap().is_some(),
10543            "edge must be visible in its own namespace"
10544        );
10545
10546        // Foreign namespace must now SUCCEED: by-ID get is namespace-agnostic.
10547        let cross_ns = rt.get_edge(&ns_b, Uuid::from(edge.id)).await;
10548        assert!(
10549            matches!(cross_ns, Ok(Some(_))),
10550            "cross-namespace get_edge must return Ok(Some(_)) after PR-A1, got {cross_ns:?}"
10551        );
10552
10553        // Absent edge UUID still returns None regardless of token namespace.
10554        let absent = rt.get_edge(&ns_b, Uuid::new_v4()).await;
10555        assert!(
10556            matches!(absent, Ok(None)),
10557            "absent edge must return Ok(None), got {absent:?}"
10558        );
10559    }
10560
10561    // ── Traversal across namespace labels now succeeds ────────────────────────
10562    //
10563    // Previously, traverse with ns_b token + ns_a root was silently empty
10564    // because substrate_exists_in_ns → get_entity rejected cross-namespace lookups.
10565    // Now: get_entity finds any entity by UUID; traverse finds the root and
10566    // returns paths scoped to the graph store's namespace filter for ns_b.
10567    #[tokio::test]
10568    async fn traverse_cross_namespace_root_is_accepted() {
10569        use khive_storage::types::TraversalOptions;
10570
10571        let rt = rt();
10572        let ns_a = NamespaceToken::for_namespace(Namespace::parse("ns-a").unwrap());
10573        let ns_b = NamespaceToken::for_namespace(Namespace::parse("ns-b").unwrap());
10574
10575        let a = rt
10576            .create_entity(&ns_a, "concept", None, "A", None, None, vec![])
10577            .await
10578            .unwrap();
10579        rt.create_entity(&ns_a, "concept", None, "B", None, None, vec![])
10580            .await
10581            .unwrap();
10582        rt.link(&ns_a, a.id, a.id, EdgeRelation::Extends, 1.0, None)
10583            .await
10584            .ok(); // may conflict with self-loop check; we just need an entity
10585
10586        // substrate_exists_in_ns finds the ns_a root via get_entity
10587        // (UUID-global lookup). The traverse proceeds; no panic.
10588        let result = rt
10589            .traverse(
10590                &ns_b,
10591                TraversalRequest {
10592                    roots: vec![a.id],
10593                    options: TraversalOptions {
10594                        max_depth: 1,
10595                        direction: Direction::Out,
10596                        ..Default::default()
10597                    },
10598                    include_roots: true,
10599                    include_properties: false,
10600                },
10601            )
10602            .await;
10603        assert!(result.is_ok(), "traverse must not error; got {:?}", result);
10604    }
10605
10606    // ---- purge cascade must include already-soft-deleted edges ----
10607    //
10608    // Hard delete must cascade ALL incident edges synchronously. A cascade driven
10609    // through `neighbors()`, which filters `deleted_at IS NULL`, would let incident
10610    // edges that were already soft-deleted survive endpoint purge as dangling rows.
10611    // `purge_incident_edges` issues a single DELETE without a `deleted_at` guard.
10612
10613    /// Count ALL `graph_edges` rows for a given UUID (source OR target), including soft-deleted.
10614    async fn count_all_incident_edges(rt: &KhiveRuntime, node_id: Uuid, ns: &str) -> u64 {
10615        let mut reader = rt.sql().reader().await.expect("sql reader must open");
10616        let row = reader
10617            .query_scalar(SqlStatement {
10618                sql: "SELECT COUNT(*) FROM graph_edges \
10619                      WHERE namespace = ?1 AND (source_id = ?2 OR target_id = ?2)"
10620                    .into(),
10621                params: vec![
10622                    SqlValue::Text(ns.to_string()),
10623                    SqlValue::Text(node_id.to_string()),
10624                ],
10625                label: Some("count_all_incident_edges".into()),
10626            })
10627            .await
10628            .expect("count query must succeed");
10629        match row {
10630            Some(SqlValue::Integer(n)) => n as u64,
10631            _ => panic!("count must return an integer"),
10632        }
10633    }
10634
10635    #[tokio::test]
10636    async fn hard_delete_entity_purges_already_soft_deleted_incident_edge() {
10637        let rt = rt();
10638        let tok = NamespaceToken::local();
10639        let ns = tok.namespace().to_string();
10640
10641        let a = rt
10642            .create_entity(&tok, "concept", None, "SrcA", None, None, vec![])
10643            .await
10644            .unwrap();
10645        let b = rt
10646            .create_entity(&tok, "concept", None, "TgtB", None, None, vec![])
10647            .await
10648            .unwrap();
10649
10650        rt.link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
10651            .await
10652            .unwrap();
10653
10654        // Soft-delete the edge — it is now invisible to `neighbors` but still in storage.
10655        let edge_hit = rt
10656            .neighbors(&tok, a.id, Direction::Out, None, None)
10657            .await
10658            .unwrap();
10659        assert_eq!(edge_hit.len(), 1, "edge must exist before soft-delete");
10660        let edge_uuid = edge_hit[0].edge_id;
10661        rt.delete_edge(&tok, edge_uuid, false).await.unwrap();
10662
10663        // Confirm the edge is invisible to normal read paths but present in raw storage.
10664        let visible = rt
10665            .neighbors(&tok, a.id, Direction::Out, None, None)
10666            .await
10667            .unwrap();
10668        assert!(visible.is_empty(), "soft-deleted edge must be invisible");
10669        let raw_before = count_all_incident_edges(&rt, a.id, &ns).await;
10670        assert_eq!(
10671            raw_before, 1,
10672            "soft-deleted edge must still be a physical row"
10673        );
10674
10675        // Hard-delete (purge) the source entity — cascade must also remove the soft-deleted edge.
10676        rt.delete_entity(&tok, a.id, true).await.unwrap();
10677
10678        let raw_after = count_all_incident_edges(&rt, a.id, &ns).await;
10679        assert_eq!(
10680            raw_after, 0,
10681            "purge_incident_edges must physically remove soft-deleted edge rows (ADR-002)"
10682        );
10683    }
10684
10685    #[tokio::test]
10686    async fn hard_delete_note_purges_already_soft_deleted_incident_edge() {
10687        let rt = rt();
10688        let tok = NamespaceToken::local();
10689        let ns = tok.namespace().to_string();
10690
10691        let target = rt
10692            .create_note(
10693                &tok,
10694                "observation",
10695                None,
10696                "purge-cascade target note",
10697                Some(0.5),
10698                None,
10699                vec![],
10700            )
10701            .await
10702            .unwrap();
10703        let annotating = rt
10704            .create_note(
10705                &tok,
10706                "insight",
10707                None,
10708                "annotator note",
10709                Some(0.5),
10710                None,
10711                vec![target.id],
10712            )
10713            .await
10714            .unwrap();
10715
10716        // Soft-delete the annotates edge.
10717        let edge_hit = rt
10718            .neighbors(
10719                &tok,
10720                annotating.id,
10721                Direction::Out,
10722                None,
10723                Some(vec![EdgeRelation::Annotates]),
10724            )
10725            .await
10726            .unwrap();
10727        assert_eq!(edge_hit.len(), 1, "annotates edge must exist");
10728        let edge_uuid = edge_hit[0].edge_id;
10729        rt.delete_edge(&tok, edge_uuid, false).await.unwrap();
10730
10731        let raw_before = count_all_incident_edges(&rt, target.id, &ns).await;
10732        assert_eq!(
10733            raw_before, 1,
10734            "soft-deleted edge must still be a physical row before note purge"
10735        );
10736
10737        // Hard-delete the target note — cascade must remove the soft-deleted edge row.
10738        rt.delete_note(&tok, target.id, true).await.unwrap();
10739
10740        let raw_after = count_all_incident_edges(&rt, target.id, &ns).await;
10741        assert_eq!(
10742            raw_after, 0,
10743            "purge_incident_edges must physically remove soft-deleted edge rows on note purge (ADR-002)"
10744        );
10745    }
10746
10747    // ---- cross-namespace entity hard-delete purges ALL incident edges ----
10748    //
10749    // `purge_incident_edges` must not scope its DELETE by `WHERE namespace = caller_ns`,
10750    // or a foreign-namespace entity's incident edges in ITS namespace would survive
10751    // the cascade as dangling rows.
10752
10753    /// Count ALL `graph_edges` rows for a given node UUID, across every namespace.
10754    async fn count_all_incident_edges_global(rt: &KhiveRuntime, node_id: Uuid) -> u64 {
10755        let mut reader = rt.sql().reader().await.expect("sql reader must open");
10756        let row = reader
10757            .query_scalar(SqlStatement {
10758                sql: "SELECT COUNT(*) FROM graph_edges WHERE source_id = ?1 OR target_id = ?1"
10759                    .into(),
10760                params: vec![SqlValue::Text(node_id.to_string())],
10761                label: Some("count_all_incident_edges_global".into()),
10762            })
10763            .await
10764            .expect("count query must succeed");
10765        match row {
10766            Some(SqlValue::Integer(n)) => n as u64,
10767            _ => panic!("count must return an integer"),
10768        }
10769    }
10770
10771    #[tokio::test]
10772    async fn cross_namespace_hard_delete_entity_purges_all_incident_edges() {
10773        // Entity lives in ns-owner. Edges live in ns-owner.
10774        // Delete is driven from ns-caller (a different namespace).
10775        // Assertion: after hard delete, no incident edges remain in ANY namespace.
10776        let rt = rt();
10777        let ns_owner = NamespaceToken::for_namespace(Namespace::parse("ns-owner").unwrap());
10778        let ns_caller = NamespaceToken::for_namespace(Namespace::parse("ns-caller").unwrap());
10779
10780        let entity = rt
10781            .create_entity(
10782                &ns_owner,
10783                "concept",
10784                None,
10785                "ForeignEntity",
10786                None,
10787                None,
10788                vec![],
10789            )
10790            .await
10791            .unwrap();
10792        let peer = rt
10793            .create_entity(&ns_owner, "concept", None, "Peer", None, None, vec![])
10794            .await
10795            .unwrap();
10796        // Create two incident edges in ns_owner. concept->Extends->concept is in the allowlist.
10797        rt.link(
10798            &ns_owner,
10799            entity.id,
10800            peer.id,
10801            EdgeRelation::Extends,
10802            1.0,
10803            None,
10804        )
10805        .await
10806        .unwrap();
10807        rt.link(
10808            &ns_owner,
10809            peer.id,
10810            entity.id,
10811            EdgeRelation::Extends,
10812            1.0,
10813            None,
10814        )
10815        .await
10816        .unwrap();
10817
10818        let before = count_all_incident_edges_global(&rt, entity.id).await;
10819        assert_eq!(before, 2, "two incident edges must exist before delete");
10820
10821        // Hard-delete entity from a DIFFERENT namespace token.
10822        let deleted = rt.delete_entity(&ns_caller, entity.id, true).await.unwrap();
10823        assert!(deleted, "cross-ns hard delete must return true");
10824
10825        // All incident edges must be gone regardless of namespace.
10826        let after = count_all_incident_edges_global(&rt, entity.id).await;
10827        assert_eq!(
10828            after, 0,
10829            "purge_incident_edges must remove all incident edges across namespaces (ADR-002, ADR-007)"
10830        );
10831    }
10832
10833    // ---- edge-ID hard-delete path ----
10834    //
10835    // Bug class: delete_edge drove the primary-edge guard through get_edge()
10836    // (live-only) and the cascade through neighbors() (live-only). Two reachable holes:
10837    // (a) soft-deleted primary edge cannot be hard-purged via its own ID;
10838    // (b) an already-soft-deleted annotates edge targeting a base edge survives that
10839    //     edge's hard delete as a dangling row with target_id = physically-gone edge id.
10840
10841    /// Count graph_edges rows matching the given edge ID, including soft-deleted rows.
10842    async fn count_edge_rows_by_id(rt: &KhiveRuntime, edge_id: Uuid, ns: &str) -> u64 {
10843        let mut reader = rt.sql().reader().await.expect("sql reader must open");
10844        let row = reader
10845            .query_scalar(SqlStatement {
10846                sql: "SELECT COUNT(*) FROM graph_edges WHERE namespace = ?1 AND id = ?2".into(),
10847                params: vec![
10848                    SqlValue::Text(ns.to_string()),
10849                    SqlValue::Text(edge_id.to_string()),
10850                ],
10851                label: Some("count_edge_rows_by_id".into()),
10852            })
10853            .await
10854            .expect("count query must succeed");
10855        match row {
10856            Some(SqlValue::Integer(n)) => n as u64,
10857            _ => panic!("count must return an integer"),
10858        }
10859    }
10860
10861    #[tokio::test]
10862    async fn hard_delete_edge_purges_already_soft_deleted_primary_edge() {
10863        let rt = rt();
10864        let tok = NamespaceToken::local();
10865        let ns = tok.namespace().to_string();
10866
10867        let a = rt
10868            .create_entity(&tok, "concept", None, "EA", None, None, vec![])
10869            .await
10870            .unwrap();
10871        let b = rt
10872            .create_entity(&tok, "concept", None, "EB", None, None, vec![])
10873            .await
10874            .unwrap();
10875
10876        let edge = rt
10877            .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
10878            .await
10879            .unwrap();
10880        let edge_uuid: Uuid = edge.id.into();
10881
10882        // Soft-delete the edge first.
10883        let soft = rt.delete_edge(&tok, edge_uuid, false).await.unwrap();
10884        assert!(soft, "soft delete must succeed");
10885
10886        // Edge is now invisible to normal reads but still a physical row.
10887        assert!(
10888            rt.get_edge(&tok, edge_uuid).await.unwrap().is_none(),
10889            "soft-deleted edge must be invisible to get_edge"
10890        );
10891        assert_eq!(
10892            count_edge_rows_by_id(&rt, edge_uuid, &ns).await,
10893            1,
10894            "soft-deleted edge must still be a physical row"
10895        );
10896
10897        // Hard-delete (purge) via the edge ID — must succeed and remove the row.
10898        let purged = rt.delete_edge(&tok, edge_uuid, true).await.unwrap();
10899        assert!(
10900            purged,
10901            "hard delete of a soft-deleted edge must return true"
10902        );
10903
10904        assert_eq!(
10905            count_edge_rows_by_id(&rt, edge_uuid, &ns).await,
10906            0,
10907            "hard-delete must physically remove the soft-deleted edge row (ADR-002)"
10908        );
10909    }
10910
10911    #[tokio::test]
10912    async fn hard_delete_base_edge_purges_already_soft_deleted_annotates_edge() {
10913        let rt = rt();
10914        let tok = NamespaceToken::local();
10915        let ns = tok.namespace().to_string();
10916
10917        let a = rt
10918            .create_entity(&tok, "concept", None, "CA", None, None, vec![])
10919            .await
10920            .unwrap();
10921        let b = rt
10922            .create_entity(&tok, "concept", None, "CB", None, None, vec![])
10923            .await
10924            .unwrap();
10925
10926        // Create the base edge to be annotated.
10927        let base_edge = rt
10928            .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
10929            .await
10930            .unwrap();
10931        let base_edge_uuid: Uuid = base_edge.id.into();
10932
10933        // Create a note that annotates the base edge.
10934        let note = rt
10935            .create_note(
10936                &tok,
10937                "observation",
10938                None,
10939                "note about base edge",
10940                Some(0.5),
10941                None,
10942                vec![base_edge_uuid],
10943            )
10944            .await
10945            .unwrap();
10946
10947        // Find the annotates edge.
10948        let ann_hits = rt
10949            .neighbors(
10950                &tok,
10951                note.id,
10952                Direction::Out,
10953                None,
10954                Some(vec![EdgeRelation::Annotates]),
10955            )
10956            .await
10957            .unwrap();
10958        assert_eq!(ann_hits.len(), 1, "annotates edge must exist");
10959        let ann_edge_uuid = ann_hits[0].edge_id;
10960
10961        // Soft-delete the annotates edge — now invisible but still a physical row.
10962        rt.delete_edge(&tok, ann_edge_uuid, false).await.unwrap();
10963        assert_eq!(
10964            count_edge_rows_by_id(&rt, ann_edge_uuid, &ns).await,
10965            1,
10966            "soft-deleted annotates edge must still be a physical row"
10967        );
10968
10969        // Hard-delete the base edge — cascade must also remove the soft-deleted annotates row.
10970        let purged = rt.delete_edge(&tok, base_edge_uuid, true).await.unwrap();
10971        assert!(purged, "hard delete of base edge must return true");
10972
10973        assert_eq!(
10974            count_edge_rows_by_id(&rt, ann_edge_uuid, &ns).await,
10975            0,
10976            "hard-delete of base edge must purge already-soft-deleted annotates edge row (ADR-002)"
10977        );
10978        assert_eq!(
10979            count_edge_rows_by_id(&rt, base_edge_uuid, &ns).await,
10980            0,
10981            "hard-delete must physically remove the base edge row"
10982        );
10983    }
10984
10985    // ---- entity create/update multi-model embed fan-out tests ----
10986
10987    // FTS failure after entity row commit rolls back the entity row.
10988    // Mirrors create_note_fts_failure_rolls_back_note_row but for entities.
10989    // Uses a unique namespace so this test's arm never fires for the wrong
10990    // write path, even under full-suite parallelism.
10991    #[tokio::test]
10992    async fn create_entity_fts_failure_rolls_back_entity_row() {
10993        let rt = KhiveRuntime::memory().unwrap();
10994        let ns = Namespace::parse("fault-entity-fts").unwrap();
10995        let tok = NamespaceToken::for_namespace(ns.clone());
10996
10997        arm_fts_fail(ns.as_str());
10998
10999        let result = rt
11000            .create_entity(
11001                &tok,
11002                "concept",
11003                None,
11004                "fts-fail rollback target",
11005                None,
11006                None,
11007                vec![],
11008            )
11009            .await;
11010
11011        assert!(
11012            result.is_err(),
11013            "create_entity must propagate the injected FTS failure"
11014        );
11015        let err_msg = result.unwrap_err().to_string();
11016        assert!(
11017            err_msg.contains("injected FTS failure"),
11018            "error must carry injection message; got: {err_msg}"
11019        );
11020
11021        let entities = rt.list_entities(&tok, None, None, 1000, 0).await.unwrap();
11022        assert!(
11023            entities.is_empty(),
11024            "compensation must remove the entity row after FTS failure; got {entities:?}"
11025        );
11026    }
11027
11028    // Vector insert failure after entity row + FTS commit rolls back both.
11029    // Uses a unique namespace to avoid consuming the VECTOR_FAIL_NS flag from
11030    // a concurrent test's create_entity or create_note.
11031    #[tokio::test]
11032    async fn create_entity_vector_failure_rolls_back_entity_row_and_fts() {
11033        const MODEL: &str = "test-entity-vec-inject";
11034        const DIMS: usize = 4;
11035
11036        let rt = KhiveRuntime::memory().unwrap();
11037        let (provider, _counter) = ConstVecProvider::new(MODEL, DIMS);
11038        rt.register_embedder(provider);
11039
11040        let ns = Namespace::parse("fault-entity-vec").unwrap();
11041        let tok = NamespaceToken::for_namespace(ns.clone());
11042
11043        arm_vector_fail(ns.as_str());
11044
11045        let result = rt
11046            .create_entity(
11047                &tok,
11048                "concept",
11049                None,
11050                "vec-fail rollback target",
11051                Some("description so embed body is non-empty"),
11052                None,
11053                vec![],
11054            )
11055            .await;
11056
11057        assert!(
11058            result.is_err(),
11059            "create_entity must propagate the injected vector failure"
11060        );
11061        let err_msg = result.unwrap_err().to_string();
11062        assert!(
11063            err_msg.contains("injected vector failure"),
11064            "error must carry injection message; got: {err_msg}"
11065        );
11066
11067        let entities = rt.list_entities(&tok, None, None, 1000, 0).await.unwrap();
11068        assert!(
11069            entities.is_empty(),
11070            "compensation must remove entity row after vector failure; got {entities:?}"
11071        );
11072
11073        // FTS document must also be removed.
11074        use khive_storage::types::{TextFilter, TextQueryMode, TextSearchRequest};
11075        let fts_hits = rt
11076            .text(&tok)
11077            .unwrap()
11078            .search(TextSearchRequest {
11079                query: "vec-fail rollback target".to_string(),
11080                mode: TextQueryMode::Plain,
11081                filter: Some(TextFilter {
11082                    namespaces: vec![ns.as_str().to_string()],
11083                    ..Default::default()
11084                }),
11085                top_k: 10,
11086                snippet_chars: 100,
11087            })
11088            .await
11089            .unwrap();
11090        assert!(
11091            fts_hits.is_empty(),
11092            "compensation must remove FTS document after vector failure; got {fts_hits:?}"
11093        );
11094    }
11095
11096    // Multi-model create_entity: second model's vector INSERT fails after the
11097    // first model's insert succeeds, triggering inserted_models rollback.
11098    // Uses arm_vector_fail_after(1) so the first insert passes and the second fails,
11099    // exercising the inserted_models compensation path in create_entity.
11100    // Thread-local VECTOR_FAIL_AFTER is per-thread isolated (current-thread tokio runtime),
11101    // so this test does not race with namespace-targeted VECTOR_FAIL_NS tests.
11102    #[tokio::test]
11103    async fn create_entity_multi_model_second_vector_failure_rolls_back_all() {
11104        const DIMS: usize = 4;
11105
11106        let rt = KhiveRuntime::memory().unwrap();
11107        let (provider_a, _ca) = ConstVecProvider::new("model-a", DIMS);
11108        let (provider_b, _cb) = ConstVecProvider::new("model-b", DIMS);
11109        rt.register_embedder(provider_a);
11110        rt.register_embedder(provider_b);
11111
11112        let ns = Namespace::parse("fault-entity-multi").unwrap();
11113        let tok = NamespaceToken::for_namespace(ns.clone());
11114
11115        // Let the first vector insert succeed, fail on the second.
11116        arm_vector_fail_after(1);
11117
11118        let result = rt
11119            .create_entity(
11120                &tok,
11121                "concept",
11122                None,
11123                "multi-model rollback target",
11124                Some("description for embedding"),
11125                None,
11126                vec![],
11127            )
11128            .await;
11129
11130        assert!(
11131            result.is_err(),
11132            "create_entity must propagate the injected multi-model vector failure"
11133        );
11134
11135        let entities = rt.list_entities(&tok, None, None, 1000, 0).await.unwrap();
11136        assert!(
11137            entities.is_empty(),
11138            "compensation must remove entity row; got {entities:?}"
11139        );
11140
11141        // Both model-a and model-b vector stores must be empty for the entity id.
11142        // (The entity was never returned so we can't get its id from the result;
11143        // list_entities returning empty is the primary assertion. Additionally confirm
11144        // both stores have zero rows via a broad vector search.)
11145        use khive_storage::types::VectorSearchRequest;
11146        let query_vec = vec![1.0_f32; DIMS];
11147        let hits_a = rt
11148            .vectors_for_model(&tok, "model-a")
11149            .unwrap()
11150            .search(VectorSearchRequest {
11151                query_vectors: vec![query_vec.clone()],
11152                top_k: 100,
11153                namespace: Some(ns.as_str().to_string()),
11154                kind: Some(khive_types::SubstrateKind::Entity),
11155                embedding_model: Some("model-a".to_string()),
11156                filter: None,
11157                backend_hints: None,
11158            })
11159            .await
11160            .unwrap();
11161        assert!(
11162            hits_a.is_empty(),
11163            "model-a vector store must be empty after rollback; got {hits_a:?}"
11164        );
11165        let hits_b = rt
11166            .vectors_for_model(&tok, "model-b")
11167            .unwrap()
11168            .search(VectorSearchRequest {
11169                query_vectors: vec![query_vec],
11170                top_k: 100,
11171                namespace: Some(ns.as_str().to_string()),
11172                kind: Some(khive_types::SubstrateKind::Entity),
11173                embedding_model: Some("model-b".to_string()),
11174                filter: None,
11175                backend_hints: None,
11176            })
11177            .await
11178            .unwrap();
11179        assert!(
11180            hits_b.is_empty(),
11181            "model-b vector store must be empty after rollback; got {hits_b:?}"
11182        );
11183    }
11184
11185    // update_entity fans out to ALL registered models.
11186    // After create + update with a changed description, both model-a and model-b
11187    // vector stores hold a row for the entity id.
11188    #[tokio::test]
11189    async fn update_entity_fans_out_to_all_registered_models() {
11190        const DIMS: usize = 4;
11191
11192        let rt = KhiveRuntime::memory().unwrap();
11193        let (provider_a, _ca) = ConstVecProvider::new("embed-a", DIMS);
11194        let (provider_b, _cb) = ConstVecProvider::new("embed-b", DIMS);
11195        rt.register_embedder(provider_a);
11196        rt.register_embedder(provider_b);
11197
11198        let ns = Namespace::parse("update-entity-fanout").unwrap();
11199        let tok = NamespaceToken::for_namespace(ns.clone());
11200
11201        let entity = rt
11202            .create_entity(
11203                &tok,
11204                "concept",
11205                None,
11206                "FanOutEntity",
11207                Some("initial description"),
11208                None,
11209                vec![],
11210            )
11211            .await
11212            .expect("create_entity must succeed");
11213
11214        use crate::curation::EntityPatch;
11215        let patch = EntityPatch {
11216            description: Some(Some("updated description after fan-out fix".to_string())),
11217            ..Default::default()
11218        };
11219        rt.update_entity(&tok, entity.id, patch)
11220            .await
11221            .expect("update_entity must succeed");
11222
11223        use khive_storage::types::VectorSearchRequest;
11224        let query_vec = vec![1.0_f32; DIMS];
11225
11226        let hits_a = rt
11227            .vectors_for_model(&tok, "embed-a")
11228            .unwrap()
11229            .search(VectorSearchRequest {
11230                query_vectors: vec![query_vec.clone()],
11231                top_k: 10,
11232                namespace: Some(ns.as_str().to_string()),
11233                kind: Some(khive_types::SubstrateKind::Entity),
11234                embedding_model: Some("embed-a".to_string()),
11235                filter: None,
11236                backend_hints: None,
11237            })
11238            .await
11239            .unwrap();
11240        assert!(
11241            hits_a.iter().any(|h| h.subject_id == entity.id),
11242            "embed-a must hold a vector for the entity after update; got {hits_a:?}"
11243        );
11244
11245        let hits_b = rt
11246            .vectors_for_model(&tok, "embed-b")
11247            .unwrap()
11248            .search(VectorSearchRequest {
11249                query_vectors: vec![query_vec],
11250                top_k: 10,
11251                namespace: Some(ns.as_str().to_string()),
11252                kind: Some(khive_types::SubstrateKind::Entity),
11253                embedding_model: Some("embed-b".to_string()),
11254                filter: None,
11255                backend_hints: None,
11256            })
11257            .await
11258            .unwrap();
11259        assert!(
11260            hits_b.iter().any(|h| h.subject_id == entity.id),
11261            "embed-b must hold a vector for the entity after update; got {hits_b:?}"
11262        );
11263    }
11264
11265    // update_note fans out to ALL registered models.
11266    // After create + update with changed content, both embed-a and embed-b
11267    // vector stores hold a row for the note id.
11268    #[tokio::test]
11269    async fn update_note_fans_out_to_all_registered_models() {
11270        const DIMS: usize = 4;
11271
11272        let rt = KhiveRuntime::memory().unwrap();
11273        let (provider_a, _ca) = ConstVecProvider::new("embed-a", DIMS);
11274        let (provider_b, _cb) = ConstVecProvider::new("embed-b", DIMS);
11275        rt.register_embedder(provider_a);
11276        rt.register_embedder(provider_b);
11277
11278        let ns = Namespace::parse("update-note-fanout").unwrap();
11279        let tok = NamespaceToken::for_namespace(ns.clone());
11280
11281        let note = rt
11282            .create_note(
11283                &tok,
11284                "observation",
11285                None,
11286                "initial note content for fan-out test",
11287                None,
11288                None,
11289                vec![],
11290            )
11291            .await
11292            .expect("create_note must succeed");
11293
11294        use crate::curation::NotePatch;
11295        let patch = NotePatch {
11296            content: Some("updated content after fan-out fix".to_string()),
11297            ..Default::default()
11298        };
11299        rt.update_note(&tok, note.id, patch)
11300            .await
11301            .expect("update_note must succeed");
11302
11303        use khive_storage::types::VectorSearchRequest;
11304        let query_vec = vec![1.0_f32; DIMS];
11305
11306        let hits_a = rt
11307            .vectors_for_model(&tok, "embed-a")
11308            .unwrap()
11309            .search(VectorSearchRequest {
11310                query_vectors: vec![query_vec.clone()],
11311                top_k: 10,
11312                namespace: Some(ns.as_str().to_string()),
11313                kind: Some(khive_types::SubstrateKind::Note),
11314                embedding_model: Some("embed-a".to_string()),
11315                filter: None,
11316                backend_hints: None,
11317            })
11318            .await
11319            .unwrap();
11320        assert!(
11321            hits_a.iter().any(|h| h.subject_id == note.id),
11322            "embed-a must hold a vector for the note after update; got {hits_a:?}"
11323        );
11324
11325        let hits_b = rt
11326            .vectors_for_model(&tok, "embed-b")
11327            .unwrap()
11328            .search(VectorSearchRequest {
11329                query_vectors: vec![query_vec],
11330                top_k: 10,
11331                namespace: Some(ns.as_str().to_string()),
11332                kind: Some(khive_types::SubstrateKind::Note),
11333                embedding_model: Some("embed-b".to_string()),
11334                filter: None,
11335                backend_hints: None,
11336            })
11337            .await
11338            .unwrap();
11339        assert!(
11340            hits_b.iter().any(|h| h.subject_id == note.id),
11341            "embed-b must hold a vector for the note after update; got {hits_b:?}"
11342        );
11343    }
11344
11345    // ── By-ID ops must not filter by namespace ──────────────────────────────
11346    //
11347    // A namespace-gated by-ID op on an entity stamped "foreign" from a "local"
11348    // token would return NotFound, causing gtd.complete / update blindness.
11349    // UUID is globally unique; by-ID ops find the record regardless of
11350    // which namespace the caller's token carries.
11351
11352    #[tokio::test]
11353    async fn get_entity_cross_namespace_succeeds() {
11354        let rt = rt();
11355        // Create under "lambda:leo".
11356        let leo_tok = NamespaceToken::for_namespace(Namespace::parse("lambda:leo").unwrap());
11357        let entity = rt
11358            .create_entity(&leo_tok, "concept", None, "Peer-Entity", None, None, vec![])
11359            .await
11360            .unwrap();
11361        assert_eq!(entity.namespace, "lambda:leo");
11362
11363        // Read from "local" — must succeed (no namespace gate on by-ID get).
11364        let local_tok = NamespaceToken::local();
11365        let fetched = rt.get_entity(&local_tok, entity.id).await;
11366        assert!(
11367            fetched.is_ok(),
11368            "get_entity from local token must find lambda:leo entity; got {:?}",
11369            fetched
11370        );
11371        assert_eq!(fetched.unwrap().id, entity.id);
11372    }
11373
11374    #[tokio::test]
11375    async fn update_entity_cross_namespace_succeeds() {
11376        let rt = rt();
11377        let leo_tok = NamespaceToken::for_namespace(Namespace::parse("lambda:leo").unwrap());
11378        let entity = rt
11379            .create_entity(
11380                &leo_tok,
11381                "concept",
11382                None,
11383                "Peer-Entity-Update",
11384                None,
11385                None,
11386                vec![],
11387            )
11388            .await
11389            .unwrap();
11390
11391        // Update from "local" token — must not error with NotFound.
11392        let local_tok = NamespaceToken::local();
11393        let patch = crate::curation::EntityPatch {
11394            name: Some("Peer-Entity-Updated".to_string()),
11395            ..Default::default()
11396        };
11397        let result = rt.update_entity(&local_tok, entity.id, patch).await;
11398        assert!(
11399            result.is_ok(),
11400            "update_entity from local token must succeed on lambda:leo entity; got {:?}",
11401            result
11402        );
11403        assert_eq!(result.unwrap().name, "Peer-Entity-Updated");
11404    }
11405
11406    #[tokio::test]
11407    async fn delete_entity_cross_namespace_succeeds() {
11408        let rt = rt();
11409        let leo_tok = NamespaceToken::for_namespace(Namespace::parse("lambda:leo").unwrap());
11410        let entity = rt
11411            .create_entity(
11412                &leo_tok,
11413                "concept",
11414                None,
11415                "Peer-Entity-Delete",
11416                None,
11417                None,
11418                vec![],
11419            )
11420            .await
11421            .unwrap();
11422
11423        // Delete from "local" token — must succeed.
11424        let local_tok = NamespaceToken::local();
11425        let deleted = rt.delete_entity(&local_tok, entity.id, false).await;
11426        assert!(
11427            deleted.is_ok(),
11428            "delete_entity from local token must succeed on lambda:leo entity; got {:?}",
11429            deleted
11430        );
11431        assert!(
11432            deleted.unwrap(),
11433            "delete must return true when entity existed"
11434        );
11435    }
11436
11437    #[tokio::test]
11438    async fn namespace_preserved_on_entity_after_cross_namespace_get() {
11439        let rt = rt();
11440        let leo_tok = NamespaceToken::for_namespace(Namespace::parse("lambda:leo").unwrap());
11441        let entity = rt
11442            .create_entity(
11443                &leo_tok,
11444                "concept",
11445                None,
11446                "NS-Preserved",
11447                None,
11448                None,
11449                vec![],
11450            )
11451            .await
11452            .unwrap();
11453
11454        // The namespace column on the fetched record must still say "lambda:leo".
11455        let local_tok = NamespaceToken::local();
11456        let fetched = rt.get_entity(&local_tok, entity.id).await.unwrap();
11457        assert_eq!(
11458            fetched.namespace, "lambda:leo",
11459            "namespace column must be preserved; not overwritten with caller's namespace"
11460        );
11461    }
11462
11463    // ── PackByIdResolver unit tests ──────────────────────────────────────────
11464
11465    use crate::pack::PackByIdResolver;
11466    use tokio::sync::Mutex as TokioMutex;
11467
11468    #[derive(Debug, Default)]
11469    struct MockResolverState {
11470        owned: Vec<Uuid>,
11471        deleted: Vec<Uuid>,
11472        delete_calls: Vec<(Uuid, bool)>,
11473    }
11474
11475    struct MockPackResolver(TokioMutex<MockResolverState>);
11476
11477    impl MockPackResolver {
11478        fn new() -> Self {
11479            Self(TokioMutex::new(MockResolverState::default()))
11480        }
11481    }
11482
11483    #[async_trait::async_trait]
11484    impl crate::pack::PackByIdResolver for MockPackResolver {
11485        async fn resolve_by_id(&self, id: Uuid) -> Result<Option<Resolved>, RuntimeError> {
11486            let state = self.0.lock().await;
11487            if state.owned.contains(&id) && !state.deleted.contains(&id) {
11488                Ok(Some(Resolved::PackRecord {
11489                    pack: "mock".into(),
11490                    kind: "widget".into(),
11491                    data: serde_json::json!({ "id": id.to_string(), "name": "test-widget" }),
11492                }))
11493            } else {
11494                Ok(None)
11495            }
11496        }
11497
11498        async fn resolve_by_id_including_deleted(
11499            &self,
11500            id: Uuid,
11501        ) -> Result<Option<Resolved>, RuntimeError> {
11502            let state = self.0.lock().await;
11503            if state.owned.contains(&id) {
11504                Ok(Some(Resolved::PackRecord {
11505                    pack: "mock".into(),
11506                    kind: "widget".into(),
11507                    data: serde_json::json!({ "id": id.to_string(), "name": "test-widget" }),
11508                }))
11509            } else {
11510                Ok(None)
11511            }
11512        }
11513
11514        async fn delete_by_id(
11515            &self,
11516            id: Uuid,
11517            hard: bool,
11518        ) -> Result<serde_json::Value, RuntimeError> {
11519            let mut state = self.0.lock().await;
11520            if !state.owned.contains(&id) {
11521                return Err(RuntimeError::NotFound(format!(
11522                    "mock widget not found: {id}"
11523                )));
11524            }
11525            state.delete_calls.push((id, hard));
11526            if hard {
11527                state.owned.retain(|&x| x != id);
11528                state.deleted.retain(|&x| x != id);
11529            } else {
11530                state.deleted.push(id);
11531            }
11532            Ok(
11533                serde_json::json!({ "deleted": true, "id": id.to_string(), "kind": "widget", "hard": hard }),
11534            )
11535        }
11536    }
11537
11538    fn registry_with_mock_resolver(
11539        rt: KhiveRuntime,
11540        resolver: Box<dyn crate::pack::PackByIdResolver>,
11541    ) -> crate::VerbRegistry {
11542        use crate::pack::{PackRuntime, VerbRegistryBuilder};
11543        use khive_types::{HandlerDef, VerbCategory, Visibility};
11544
11545        static MINIMAL_HANDLERS: &[HandlerDef] = &[HandlerDef {
11546            name: "minimal.noop",
11547            description: "noop",
11548            visibility: Visibility::Verb,
11549            category: VerbCategory::Commissive,
11550            params: &[],
11551        }];
11552
11553        struct MinimalPack;
11554        impl khive_types::Pack for MinimalPack {
11555            const NAME: &'static str = "minimal";
11556            const NOTE_KINDS: &'static [&'static str] = &[];
11557            const ENTITY_KINDS: &'static [&'static str] = &[];
11558            const HANDLERS: &'static [HandlerDef] = MINIMAL_HANDLERS;
11559        }
11560        #[async_trait::async_trait]
11561        impl PackRuntime for MinimalPack {
11562            fn name(&self) -> &str {
11563                "minimal"
11564            }
11565            fn note_kinds(&self) -> &'static [&'static str] {
11566                &[]
11567            }
11568            fn entity_kinds(&self) -> &'static [&'static str] {
11569                &[]
11570            }
11571            fn handlers(&self) -> &'static [HandlerDef] {
11572                MINIMAL_HANDLERS
11573            }
11574            async fn dispatch(
11575                &self,
11576                _verb: &str,
11577                _params: serde_json::Value,
11578                _registry: &crate::VerbRegistry,
11579                _token: &NamespaceToken,
11580            ) -> Result<serde_json::Value, RuntimeError> {
11581                Err(RuntimeError::InvalidInput("stub".into()))
11582            }
11583        }
11584
11585        let _ = rt;
11586        let mut builder = VerbRegistryBuilder::new();
11587        builder.register(MinimalPack);
11588        builder.register_resolver("mock", resolver);
11589        builder.build().expect("registry build failed")
11590    }
11591
11592    #[tokio::test]
11593    async fn pack_record_resolved_pair_returns_none() {
11594        let pr = Resolved::PackRecord {
11595            pack: "knowledge".into(),
11596            kind: "atom".into(),
11597            data: serde_json::json!({}),
11598        };
11599        assert!(
11600            resolved_pair(Some(&pr)).is_none(),
11601            "PackRecord must not be a valid edge endpoint"
11602        );
11603    }
11604
11605    #[test]
11606    fn resolved_pair_surfaces_entity_type() {
11607        let e = Resolved::Entity(
11608            Entity::new("mathlib", "concept", "Nat.add_comm").with_entity_type(Some("theorem")),
11609        );
11610        assert_eq!(
11611            resolved_pair(Some(&e)),
11612            Some(("entity", "concept", Some("theorem"))),
11613            "entity_type subtype must be surfaced alongside base kind"
11614        );
11615    }
11616
11617    #[test]
11618    fn endpoint_of_type_matches_subtype_not_base_kind() {
11619        // An entity whose base kind is "concept" and subtype is "theorem".
11620        let kind = "concept";
11621        let et = Some("theorem");
11622
11623        // EntityOfType matches only when BOTH base kind and subtype match.
11624        assert!(endpoint_matches(
11625            &EndpointKind::EntityOfType {
11626                kind: "concept",
11627                entity_type: "theorem",
11628            },
11629            "entity",
11630            kind,
11631            et
11632        ));
11633        assert!(!endpoint_matches(
11634            &EndpointKind::EntityOfType {
11635                kind: "concept",
11636                entity_type: "definition",
11637            },
11638            "entity",
11639            kind,
11640            et
11641        ));
11642
11643        // The silently-inert trap: EntityOfKind sees only the BASE
11644        // kind, so EntityOfKind("theorem") never matches a concept/theorem.
11645        assert!(!endpoint_matches(
11646            &EndpointKind::EntityOfKind("theorem"),
11647            "entity",
11648            kind,
11649            et
11650        ));
11651        // EntityOfKind still matches the base kind.
11652        assert!(endpoint_matches(
11653            &EndpointKind::EntityOfKind("concept"),
11654            "entity",
11655            kind,
11656            et
11657        ));
11658
11659        // EntityOfType rejects non-entity substrates and entities with no subtype.
11660        assert!(!endpoint_matches(
11661            &EndpointKind::EntityOfType {
11662                kind: "concept",
11663                entity_type: "theorem",
11664            },
11665            "note",
11666            "task",
11667            None
11668        ));
11669        assert!(!endpoint_matches(
11670            &EndpointKind::EntityOfType {
11671                kind: "concept",
11672                entity_type: "theorem",
11673            },
11674            "entity",
11675            kind,
11676            None
11677        ));
11678    }
11679
11680    #[test]
11681    fn endpoint_of_type_requires_base_kind_match() {
11682        // Regression: an entity with entity_type="theorem" but base kind != "concept"
11683        // must NOT match a formal concept rule. This was the exact bypass:
11684        // before the fix, EntityOfType("theorem") ignored the base kind entirely.
11685        let wrong_base_kind = "project"; // not "concept"
11686        let et = Some("theorem");
11687
11688        // The formal rule requires kind="concept". A "project" entity with
11689        // entity_type="theorem" must not match — even though the subtype string
11690        // matches — because the base kind differs.
11691        assert!(
11692            !endpoint_matches(
11693                &EndpointKind::EntityOfType {
11694                    kind: "concept",
11695                    entity_type: "theorem",
11696                },
11697                "entity",
11698                wrong_base_kind,
11699                et
11700            ),
11701            "EntityOfType must reject an entity whose base kind != rule.kind \
11702             even when entity_type matches — the pre-fix bug admitted this"
11703        );
11704
11705        // The correct concept entity with the same subtype still matches.
11706        assert!(endpoint_matches(
11707            &EndpointKind::EntityOfType {
11708                kind: "concept",
11709                entity_type: "theorem",
11710            },
11711            "entity",
11712            "concept",
11713            et
11714        ));
11715    }
11716
11717    #[tokio::test]
11718    async fn registry_resolvers_accessor_returns_registered() {
11719        let resolver = Box::new(MockPackResolver::new());
11720        let registry = registry_with_mock_resolver(rt(), resolver);
11721        assert_eq!(registry.resolvers().len(), 1);
11722        assert_eq!(registry.resolvers()[0].0, "mock");
11723    }
11724
11725    #[tokio::test]
11726    async fn mock_resolver_resolve_by_id_returns_pack_record() {
11727        let id = Uuid::new_v4();
11728        let resolver: Box<dyn PackByIdResolver> = Box::new(MockPackResolver::new());
11729        // We need interior access — downcast first, then use via trait.
11730        let inner = MockPackResolver::new();
11731        inner.0.lock().await.owned.push(id);
11732        let result: Result<Option<Resolved>, RuntimeError> = inner.resolve_by_id(id).await;
11733        match result.unwrap() {
11734            Some(Resolved::PackRecord { pack, kind, data }) => {
11735                assert_eq!(pack, "mock");
11736                assert_eq!(kind, "widget");
11737                assert_eq!(data["id"].as_str().unwrap(), id.to_string());
11738            }
11739            other => panic!("expected PackRecord, got {:?}", other),
11740        }
11741        let _ = resolver;
11742    }
11743
11744    #[tokio::test]
11745    async fn mock_resolver_resolve_unknown_uuid_returns_none() {
11746        let inner = MockPackResolver::new();
11747        let id = Uuid::new_v4();
11748        let result: Result<Option<Resolved>, RuntimeError> = inner.resolve_by_id(id).await;
11749        assert!(result.unwrap().is_none());
11750    }
11751
11752    #[tokio::test]
11753    async fn mock_resolver_delete_soft_records_call() {
11754        let id = Uuid::new_v4();
11755        let inner = MockPackResolver::new();
11756        inner.0.lock().await.owned.push(id);
11757
11758        let result: Result<serde_json::Value, RuntimeError> = inner.delete_by_id(id, false).await;
11759        let result = result.unwrap();
11760        assert_eq!(result["deleted"], serde_json::json!(true));
11761        assert_eq!(result["hard"], serde_json::json!(false));
11762
11763        // After soft-delete: resolve_by_id returns None, but including_deleted returns Some.
11764        let live: Result<Option<Resolved>, RuntimeError> = inner.resolve_by_id(id).await;
11765        assert!(live.unwrap().is_none());
11766        let incl: Result<Option<Resolved>, RuntimeError> =
11767            inner.resolve_by_id_including_deleted(id).await;
11768        assert!(incl.unwrap().is_some());
11769    }
11770
11771    #[tokio::test]
11772    async fn mock_resolver_delete_hard_removes_record() {
11773        let id = Uuid::new_v4();
11774        let inner = MockPackResolver::new();
11775        inner.0.lock().await.owned.push(id);
11776
11777        let result: Result<serde_json::Value, RuntimeError> = inner.delete_by_id(id, true).await;
11778        assert_eq!(result.unwrap()["hard"], serde_json::json!(true));
11779
11780        // After hard-delete: neither probe finds the record.
11781        let incl: Result<Option<Resolved>, RuntimeError> =
11782            inner.resolve_by_id_including_deleted(id).await;
11783        assert!(incl.unwrap().is_none());
11784    }
11785
11786    #[tokio::test]
11787    async fn pack_record_not_valid_context_entity() {
11788        // Validates the GTD handler arm compiles and returns InvalidInput.
11789        // We exercise the match logic directly by constructing a PackRecord Resolved.
11790        let pr = Resolved::PackRecord {
11791            pack: "knowledge".into(),
11792            kind: "atom".into(),
11793            data: serde_json::json!({}),
11794        };
11795        // The match in GTD handlers.rs now handles PackRecord → InvalidInput.
11796        // We can verify the enum variant is reachable.
11797        assert!(matches!(pr, Resolved::PackRecord { .. }));
11798    }
11799
11800    // ── Batched enrich_neighbor_hits / enrich_path_nodes ────────────────────
11801
11802    fn neighbor_hit(node_id: Uuid) -> NeighborHit {
11803        NeighborHit {
11804            node_id,
11805            edge_id: Uuid::new_v4(),
11806            relation: EdgeRelation::Extends,
11807            weight: 1.0,
11808            name: None,
11809            kind: None,
11810            entity_type: None,
11811        }
11812    }
11813
11814    fn path_node(node_id: Uuid, depth: usize) -> PathNode {
11815        PathNode {
11816            node_id,
11817            via_edge: None,
11818            depth,
11819            name: None,
11820            kind: None,
11821            properties: None,
11822        }
11823    }
11824
11825    /// enrich_neighbor_hits: entity hit resolved, note hit resolved with
11826    /// name-fallback to "[kind]", bogus UUID left as None.  Order preserved.
11827    #[tokio::test]
11828    async fn enrich_neighbor_hits_batch_entity_note_and_bogus() {
11829        let rt = rt();
11830        let tok = NamespaceToken::local();
11831
11832        // Create an entity neighbor.
11833        let entity = rt
11834            .create_entity(&tok, "concept", None, "MyEntity", None, None, vec![])
11835            .await
11836            .unwrap();
11837
11838        // Nameless note — name falls back to "[observation]".
11839        let note = rt
11840            .create_note(&tok, "observation", None, "body", Some(0.5), None, vec![])
11841            .await
11842            .unwrap();
11843
11844        let bogus_id = Uuid::new_v4();
11845
11846        let mut hits = vec![
11847            neighbor_hit(entity.id),
11848            neighbor_hit(note.id),
11849            neighbor_hit(bogus_id),
11850        ];
11851
11852        rt.enrich_neighbor_hits(&tok, &mut hits).await;
11853
11854        assert_eq!(hits[0].name.as_deref(), Some("MyEntity"));
11855        assert_eq!(hits[0].kind.as_deref(), Some("concept"));
11856
11857        assert_eq!(hits[1].name.as_deref(), Some("[observation]"));
11858        assert_eq!(hits[1].kind.as_deref(), Some("observation"));
11859
11860        assert!(hits[2].name.is_none());
11861        assert!(hits[2].kind.is_none());
11862    }
11863
11864    /// enrich_neighbor_hits: note with a non-empty name uses the actual name.
11865    #[tokio::test]
11866    async fn enrich_neighbor_hits_note_with_name_uses_name() {
11867        let rt = rt();
11868        let tok = NamespaceToken::local();
11869
11870        let note = rt
11871            .create_note(
11872                &tok,
11873                "insight",
11874                Some("NoteTitle"),
11875                "body",
11876                Some(0.5),
11877                None,
11878                vec![],
11879            )
11880            .await
11881            .unwrap();
11882
11883        let mut hits = vec![neighbor_hit(note.id)];
11884        rt.enrich_neighbor_hits(&tok, &mut hits).await;
11885
11886        assert_eq!(hits[0].name.as_deref(), Some("NoteTitle"));
11887        assert_eq!(hits[0].kind.as_deref(), Some("insight"));
11888    }
11889
11890    /// enrich_path_nodes: two paths sharing a repeated node_id; each node
11891    /// enriched from a single batch; unresolved node stays None.
11892    #[tokio::test]
11893    async fn enrich_path_nodes_batch_dedup_and_unresolved() {
11894        let rt = rt();
11895        let tok = NamespaceToken::local();
11896
11897        let ea = rt
11898            .create_entity(&tok, "concept", None, "Alpha", None, None, vec![])
11899            .await
11900            .unwrap();
11901        let eb = rt
11902            .create_entity(&tok, "document", None, "Beta", None, None, vec![])
11903            .await
11904            .unwrap();
11905        let bogus_id = Uuid::new_v4();
11906
11907        // Path 1: ea → eb → bogus  |  Path 2: eb → ea  (shared nodes, reversed)
11908        let mut paths = vec![
11909            GraphPath {
11910                root_id: ea.id,
11911                nodes: vec![
11912                    path_node(ea.id, 0),
11913                    path_node(eb.id, 1),
11914                    path_node(bogus_id, 2),
11915                ],
11916                total_weight: 1.0,
11917            },
11918            GraphPath {
11919                root_id: eb.id,
11920                nodes: vec![path_node(eb.id, 0), path_node(ea.id, 1)],
11921                total_weight: 1.0,
11922            },
11923        ];
11924
11925        rt.enrich_path_nodes(&tok, &mut paths, false).await;
11926
11927        assert_eq!(paths[0].nodes[0].name.as_deref(), Some("Alpha"));
11928        assert_eq!(paths[0].nodes[0].kind.as_deref(), Some("concept"));
11929        assert_eq!(paths[0].nodes[1].name.as_deref(), Some("Beta"));
11930        assert_eq!(paths[0].nodes[1].kind.as_deref(), Some("document"));
11931        assert!(paths[0].nodes[2].name.is_none());
11932        assert!(paths[0].nodes[2].kind.is_none());
11933
11934        // Shared nodes resolve from the same HashMap — order within each path is preserved.
11935        assert_eq!(paths[1].nodes[0].name.as_deref(), Some("Beta"));
11936        assert_eq!(paths[1].nodes[0].kind.as_deref(), Some("document"));
11937        assert_eq!(paths[1].nodes[1].name.as_deref(), Some("Alpha"));
11938        assert_eq!(paths[1].nodes[1].kind.as_deref(), Some("concept"));
11939    }
11940
11941    /// enrich_neighbor_hits and enrich_path_nodes must resolve entities whose
11942    /// namespace is in the token's extra-visible set (not only the primary).
11943    ///
11944    /// Regression: the old `get_entities_by_ids`
11945    /// call left `filter.namespaces` unset, which collapses to
11946    /// `namespace = primary` in `build_entity_where`.  Graph expansion already
11947    /// crosses visible namespaces, so enrichment must match that scope.
11948    #[tokio::test]
11949    async fn enrich_resolves_entities_in_extra_visible_namespace() {
11950        let rt = KhiveRuntime::memory().unwrap();
11951
11952        let ns_a = Namespace::parse("enrich-ns-a").unwrap();
11953        let ns_b = Namespace::parse("enrich-ns-b").unwrap();
11954
11955        let tok_b = rt.authorize(ns_b.clone()).unwrap();
11956
11957        // Entity lives in ns-b.
11958        let entity_b = rt
11959            .create_entity(&tok_b, "concept", None, "EntityInB", None, None, vec![])
11960            .await
11961            .unwrap();
11962        assert_eq!(entity_b.namespace, "enrich-ns-b");
11963
11964        // Token whose primary is ns-a but ns-b is in the visible set.
11965        let vis_tok = rt
11966            .authorize_with_visibility(ns_a.clone(), vec![ns_b.clone()])
11967            .unwrap();
11968
11969        // ── neighbor hits ──────────────────────────────────────────────────
11970        let mut hits = vec![neighbor_hit(entity_b.id)];
11971        rt.enrich_neighbor_hits(&vis_tok, &mut hits).await;
11972
11973        assert_eq!(
11974            hits[0].name.as_deref(),
11975            Some("EntityInB"),
11976            "entity in extra-visible ns must be enriched by enrich_neighbor_hits"
11977        );
11978        assert_eq!(hits[0].kind.as_deref(), Some("concept"));
11979
11980        // ── path nodes ─────────────────────────────────────────────────────
11981        let mut paths = vec![GraphPath {
11982            root_id: entity_b.id,
11983            nodes: vec![path_node(entity_b.id, 0)],
11984            total_weight: 1.0,
11985        }];
11986        rt.enrich_path_nodes(&vis_tok, &mut paths, false).await;
11987
11988        assert_eq!(
11989            paths[0].nodes[0].name.as_deref(),
11990            Some("EntityInB"),
11991            "entity in extra-visible ns must be enriched by enrich_path_nodes"
11992        );
11993        assert_eq!(paths[0].nodes[0].kind.as_deref(), Some("concept"));
11994    }
11995
11996    /// enrich_neighbor_hits populates entity_type from the already-fetched entity
11997    /// batch when the entity has a non-null entity_type.  Entities without one and
11998    /// note nodes leave entity_type as None.
11999    #[tokio::test]
12000    async fn enrich_neighbor_hits_populates_entity_type() {
12001        let rt = rt();
12002        let tok = NamespaceToken::local();
12003
12004        let props = serde_json::json!({"domain": "attention"});
12005        let entity = rt
12006            .create_entity(
12007                &tok,
12008                "concept",
12009                Some("algorithm"),
12010                "FlashAttn",
12011                None,
12012                Some(props),
12013                vec![],
12014            )
12015            .await
12016            .unwrap();
12017
12018        let entity_no_type = rt
12019            .create_entity(&tok, "concept", None, "PlainConcept", None, None, vec![])
12020            .await
12021            .unwrap();
12022
12023        let mut hits = vec![neighbor_hit(entity.id), neighbor_hit(entity_no_type.id)];
12024        rt.enrich_neighbor_hits(&tok, &mut hits).await;
12025
12026        assert_eq!(hits[0].entity_type.as_deref(), Some("algorithm"));
12027        assert!(
12028            hits[1].entity_type.is_none(),
12029            "entity without entity_type must leave the field as None"
12030        );
12031    }
12032
12033    /// enrich_path_nodes populates properties from the already-fetched entity
12034    /// batch when the entity has a non-null properties blob.  Entities without
12035    /// properties leave the field as None.
12036    #[tokio::test]
12037    async fn enrich_path_nodes_populates_properties() {
12038        let rt = rt();
12039        let tok = NamespaceToken::local();
12040
12041        let props = serde_json::json!({"year": 2024, "venue": "NeurIPS"});
12042        let entity_with_props = rt
12043            .create_entity(
12044                &tok,
12045                "document",
12046                None,
12047                "AttentionPaper",
12048                None,
12049                Some(props.clone()),
12050                vec![],
12051            )
12052            .await
12053            .unwrap();
12054
12055        let entity_no_props = rt
12056            .create_entity(&tok, "concept", None, "BareConceptNode", None, None, vec![])
12057            .await
12058            .unwrap();
12059
12060        let mut paths = vec![GraphPath {
12061            root_id: entity_with_props.id,
12062            nodes: vec![
12063                path_node(entity_with_props.id, 0),
12064                path_node(entity_no_props.id, 1),
12065            ],
12066            total_weight: 1.0,
12067        }];
12068
12069        rt.enrich_path_nodes(&tok, &mut paths, true).await;
12070
12071        assert_eq!(
12072            paths[0].nodes[0].properties.as_ref(),
12073            Some(&props),
12074            "properties must be filled when entity has a non-null properties blob"
12075        );
12076        assert!(
12077            paths[0].nodes[1].properties.is_none(),
12078            "entity without properties must leave the field as None"
12079        );
12080    }
12081
12082    /// Regression: GraphStore::traverse must not fail with "too many SQL variables"
12083    /// or "too many terms in compound SELECT" when the root set exceeds the chunk
12084    /// boundary (400 roots per CTE VALUES clause after the fix).
12085    ///
12086    /// Graph: 1 000 roots, each with one distinct outgoing edge to a unique child.
12087    /// The graph store's `traverse` is exercised directly (bypassing the runtime-level
12088    /// entity-existence filter) to keep the test fast and targeted.
12089    ///
12090    /// Correctness: every root must appear in the result with exactly one reachable node.
12091    #[tokio::test]
12092    async fn traverse_chunks_root_binds_over_host_param_limit() {
12093        use khive_storage::types::TraversalOptions;
12094
12095        let rt = rt();
12096        let tok = NamespaceToken::local();
12097        let graph = rt.graph(&tok).unwrap();
12098
12099        const N: usize = 1_000;
12100        let now = chrono::Utc::now();
12101
12102        let mut roots: Vec<uuid::Uuid> = Vec::with_capacity(N);
12103        let mut expected_children: std::collections::HashMap<uuid::Uuid, uuid::Uuid> =
12104            std::collections::HashMap::with_capacity(N);
12105
12106        for _ in 0..N {
12107            let root = uuid::Uuid::new_v4();
12108            let child = uuid::Uuid::new_v4();
12109            graph
12110                .upsert_edge(Edge {
12111                    id: LinkId::from(uuid::Uuid::new_v4()),
12112                    namespace: "local".to_string(),
12113                    source_id: root,
12114                    target_id: child,
12115                    relation: EdgeRelation::Extends,
12116                    weight: 1.0,
12117                    created_at: now,
12118                    updated_at: now,
12119                    deleted_at: None,
12120                    metadata: None,
12121                    target_backend: None,
12122                })
12123                .await
12124                .unwrap();
12125            roots.push(root);
12126            expected_children.insert(root, child);
12127        }
12128
12129        // Must return Ok: no "too many SQL variables" or "too many terms in compound SELECT".
12130        let paths = graph
12131            .traverse(TraversalRequest {
12132                roots: roots.clone(),
12133                options: TraversalOptions {
12134                    max_depth: 1,
12135                    direction: Direction::Out,
12136                    relations: None,
12137                    min_weight: None,
12138                    limit: None,
12139                },
12140                include_roots: false,
12141                include_properties: false,
12142            })
12143            .await
12144            .unwrap();
12145
12146        assert_eq!(
12147            paths.len(),
12148            N,
12149            "traverse over {N} roots must return one GraphPath per root"
12150        );
12151
12152        for path in &paths {
12153            let expected_child = expected_children[&path.root_id];
12154            assert_eq!(
12155                path.nodes.len(),
12156                1,
12157                "root {:?} must reach exactly 1 node",
12158                path.root_id
12159            );
12160            assert_eq!(
12161                path.nodes[0].node_id, expected_child,
12162                "root {:?} must reach its direct child",
12163                path.root_id
12164            );
12165        }
12166    }
12167
12168    // ── Additive EDGE_RULES composition: pack EntityOfType rules must not shadow
12169    // the base EntityOfKind contract for the same relation. ──────────────────────
12170    //
12171    // When a pack contributes EntityOfType rules for a relation (e.g. variant_of:
12172    // goal -> theorem and goal -> definition), the base contract's EntityOfKind
12173    // rule for the same relation (concept -> concept) must still fire for entities
12174    // whose base kind is "concept" but whose EntityOfType pair is not in any pack rule.
12175    //
12176    // Specifically: a goal entity resolves to base kind "concept". A goal -> goal
12177    // variant_of edge has no matching pack rule (goal -> goal is not declared), so
12178    // pack_rule_allows returns false. The validator then extracts the base kind
12179    // ("concept") and checks base_entity_rule_allows, which returns true.
12180    // The edge is therefore allowed: additive composition holds.
12181
12182    #[test]
12183    fn pack_entity_of_type_rules_do_not_shadow_base_entity_of_kind_rule() {
12184        // Formal-style EntityOfType rules for variant_of: goal -> theorem, goal -> definition.
12185        // goal -> goal is deliberately absent — that case must fall through to the base rule.
12186        let pack_rules: Vec<EdgeEndpointRule> = vec![
12187            EdgeEndpointRule {
12188                relation: EdgeRelation::VariantOf,
12189                source: EndpointKind::EntityOfType {
12190                    kind: "concept",
12191                    entity_type: "goal",
12192                },
12193                target: EndpointKind::EntityOfType {
12194                    kind: "concept",
12195                    entity_type: "theorem",
12196                },
12197            },
12198            EdgeEndpointRule {
12199                relation: EdgeRelation::VariantOf,
12200                source: EndpointKind::EntityOfType {
12201                    kind: "concept",
12202                    entity_type: "goal",
12203                },
12204                target: EndpointKind::EntityOfType {
12205                    kind: "concept",
12206                    entity_type: "definition",
12207                },
12208            },
12209        ];
12210
12211        let goal_a =
12212            Resolved::Entity(Entity::new("local", "concept", "G-a").with_entity_type(Some("goal")));
12213        let goal_b =
12214            Resolved::Entity(Entity::new("local", "concept", "G-b").with_entity_type(Some("goal")));
12215
12216        // Pack rules do not cover goal -> goal, so pack_rule_allows must return false.
12217        assert!(
12218            !pack_rule_allows(
12219                &pack_rules,
12220                EdgeRelation::VariantOf,
12221                Some(&goal_a),
12222                Some(&goal_b)
12223            ),
12224            "pack rules must not cover goal->goal variant_of (no such rule declared)"
12225        );
12226
12227        // The base contract allows concept -> concept for variant_of.
12228        // A goal entity's base kind is "concept", so this must return true.
12229        assert!(
12230            base_entity_rule_allows("concept", EdgeRelation::VariantOf, "concept"),
12231            "base contract must allow concept->concept variant_of regardless of pack EntityOfType rules"
12232        );
12233    }
12234
12235    // Integration path: pack EntityOfType rules installed on the runtime must not
12236    // block a goal->goal variant_of link that the base contract already permits.
12237    // Exercises validate_edge_relation_endpoints lines 1173-1223:
12238    //   pack miss -> extract e.kind ("concept") -> base_entity_rule_allows -> Ok.
12239    #[tokio::test]
12240    async fn link_variant_of_goal_to_goal_allowed_when_pack_has_entity_of_type_rules() {
12241        let rt = rt();
12242        let tok = NamespaceToken::local();
12243
12244        // Install formal-style EntityOfType rules for variant_of (goal -> theorem/definition).
12245        // goal -> goal is absent so the base concept->concept rule must carry this case.
12246        rt.install_edge_rules(vec![
12247            EdgeEndpointRule {
12248                relation: EdgeRelation::VariantOf,
12249                source: EndpointKind::EntityOfType {
12250                    kind: "concept",
12251                    entity_type: "goal",
12252                },
12253                target: EndpointKind::EntityOfType {
12254                    kind: "concept",
12255                    entity_type: "theorem",
12256                },
12257            },
12258            EdgeEndpointRule {
12259                relation: EdgeRelation::VariantOf,
12260                source: EndpointKind::EntityOfType {
12261                    kind: "concept",
12262                    entity_type: "goal",
12263                },
12264                target: EndpointKind::EntityOfType {
12265                    kind: "concept",
12266                    entity_type: "definition",
12267                },
12268            },
12269        ]);
12270
12271        let a = rt
12272            .create_entity(
12273                &tok,
12274                "concept",
12275                Some("goal"),
12276                "Goal Alpha",
12277                None,
12278                None,
12279                vec![],
12280            )
12281            .await
12282            .unwrap();
12283        let b = rt
12284            .create_entity(
12285                &tok,
12286                "concept",
12287                Some("goal"),
12288                "Goal Beta",
12289                None,
12290                None,
12291                vec![],
12292            )
12293            .await
12294            .unwrap();
12295
12296        // Neither endpoint matches any pack rule (no goal->goal rule).
12297        // The base concept->concept rule must fire and allow the edge.
12298        let result = rt
12299            .link(&tok, a.id, b.id, EdgeRelation::VariantOf, 1.0, None)
12300            .await;
12301        assert!(
12302            result.is_ok(),
12303            "goal->goal variant_of must be allowed via the base concept->concept rule \
12304             even when EntityOfType rules for variant_of are installed; got {result:?}"
12305        );
12306
12307        // Fail-closed check: additive rules must not make validation fail-open.
12308        // A goal(concept) -> project variant_of edge has no matching pack rule
12309        // (project is not in the installed variant_of rules) and no matching base
12310        // rule (no (concept, VariantOf, project) row). It must be rejected.
12311        let p = rt
12312            .create_entity(&tok, "project", None, "Proj", None, None, vec![])
12313            .await
12314            .unwrap();
12315        let bad = rt
12316            .link(&tok, a.id, p.id, EdgeRelation::VariantOf, 1.0, None)
12317            .await;
12318        assert!(
12319            bad.is_err(),
12320            "additive pack rules must not make validation fail-open; \
12321             goal(concept)->project variant_of must be rejected (pack miss + base miss); \
12322             got {bad:?}"
12323        );
12324    }
12325
12326    // Load-bearing positive: a pack EntityOfType rule adds an endpoint the base contract
12327    // does not cover. The base contract has no (concept, DependsOn, concept) row (the
12328    // DependsOn rows are project/service/artifact only). A theorem->definition DependsOn
12329    // edge can therefore ONLY pass through the pack rule, proving the union is load-bearing.
12330    #[tokio::test]
12331    async fn link_depends_on_theorem_to_definition_allowed_only_via_pack_rule() {
12332        let rt = rt();
12333        let tok = NamespaceToken::local();
12334
12335        // Confirm the base contract does NOT allow concept->concept DependsOn.
12336        // (Documented here so the assertion below is not a tautology.)
12337        assert!(
12338            !base_entity_rule_allows("concept", EdgeRelation::DependsOn, "concept"),
12339            "base contract must not allow concept->concept DependsOn; \
12340             test would be vacuous if this precondition fails"
12341        );
12342
12343        // Install a single EntityOfType rule: theorem depends_on definition.
12344        // With no rules, the link would be rejected by the base contract.
12345        // With this rule, it must be accepted via the pack path (lines 1173-1179).
12346        rt.install_edge_rules(vec![EdgeEndpointRule {
12347            relation: EdgeRelation::DependsOn,
12348            source: EndpointKind::EntityOfType {
12349                kind: "concept",
12350                entity_type: "theorem",
12351            },
12352            target: EndpointKind::EntityOfType {
12353                kind: "concept",
12354                entity_type: "definition",
12355            },
12356        }]);
12357
12358        let thm = rt
12359            .create_entity(&tok, "concept", Some("theorem"), "T1", None, None, vec![])
12360            .await
12361            .unwrap();
12362        let def = rt
12363            .create_entity(
12364                &tok,
12365                "concept",
12366                Some("definition"),
12367                "D1",
12368                None,
12369                None,
12370                vec![],
12371            )
12372            .await
12373            .unwrap();
12374
12375        // This can only pass through the pack rule — the base contract rejects it.
12376        let result = rt
12377            .link(&tok, thm.id, def.id, EdgeRelation::DependsOn, 1.0, None)
12378            .await;
12379        assert!(
12380            result.is_ok(),
12381            "theorem->definition DependsOn must be allowed by the installed pack rule; \
12382             the base contract has no concept->concept DependsOn row; got {result:?}"
12383        );
12384    }
12385
12386    // ── Provenance endpoint pairs ────────────────────────────────────────────
12387    // Four base endpoint pairs: document->person and document->org (document
12388    // authorship), concept->org (concept origination by an org), and
12389    // document->document (normative document dependency). Positive links for
12390    // each pair, plus a direction-matters negative guard.
12391
12392    #[tokio::test]
12393    async fn link_document_introduced_by_person_allowed() {
12394        let rt = rt();
12395        let tok = NamespaceToken::local();
12396
12397        let doc = rt
12398            .create_entity(&tok, "document", None, "Paper", None, None, vec![])
12399            .await
12400            .unwrap();
12401        let author = rt
12402            .create_entity(&tok, "person", None, "Author", None, None, vec![])
12403            .await
12404            .unwrap();
12405
12406        let result = rt
12407            .link(
12408                &tok,
12409                doc.id,
12410                author.id,
12411                EdgeRelation::IntroducedBy,
12412                1.0,
12413                None,
12414            )
12415            .await;
12416        assert!(
12417            result.is_ok(),
12418            "document->person introduced_by must be allowed by the ADR-002 \
12419             endpoint amendment; got {result:?}"
12420        );
12421    }
12422
12423    #[tokio::test]
12424    async fn link_document_introduced_by_org_allowed() {
12425        let rt = rt();
12426        let tok = NamespaceToken::local();
12427
12428        let doc = rt
12429            .create_entity(&tok, "document", None, "Whitepaper", None, None, vec![])
12430            .await
12431            .unwrap();
12432        let org = rt
12433            .create_entity(&tok, "org", None, "Publisher", None, None, vec![])
12434            .await
12435            .unwrap();
12436
12437        let result = rt
12438            .link(&tok, doc.id, org.id, EdgeRelation::IntroducedBy, 1.0, None)
12439            .await;
12440        assert!(
12441            result.is_ok(),
12442            "document->org introduced_by must be allowed by the ADR-002 \
12443             endpoint amendment; got {result:?}"
12444        );
12445    }
12446
12447    #[tokio::test]
12448    async fn link_concept_introduced_by_org_allowed() {
12449        let rt = rt();
12450        let tok = NamespaceToken::local();
12451
12452        let concept = rt
12453            .create_entity(&tok, "concept", None, "Architecture", None, None, vec![])
12454            .await
12455            .unwrap();
12456        let org = rt
12457            .create_entity(&tok, "org", None, "Originator", None, None, vec![])
12458            .await
12459            .unwrap();
12460
12461        let result = rt
12462            .link(
12463                &tok,
12464                concept.id,
12465                org.id,
12466                EdgeRelation::IntroducedBy,
12467                1.0,
12468                None,
12469            )
12470            .await;
12471        assert!(
12472            result.is_ok(),
12473            "concept->org introduced_by must be allowed by the ADR-002 \
12474             endpoint amendment; got {result:?}"
12475        );
12476    }
12477
12478    #[tokio::test]
12479    async fn link_document_depends_on_document_allowed() {
12480        let rt = rt();
12481        let tok = NamespaceToken::local();
12482
12483        let doc_a = rt
12484            .create_entity(&tok, "document", None, "Spec A", None, None, vec![])
12485            .await
12486            .unwrap();
12487        let doc_b = rt
12488            .create_entity(&tok, "document", None, "Spec B", None, None, vec![])
12489            .await
12490            .unwrap();
12491
12492        let result = rt
12493            .link(&tok, doc_a.id, doc_b.id, EdgeRelation::DependsOn, 1.0, None)
12494            .await;
12495        assert!(
12496            result.is_ok(),
12497            "document->document depends_on must be allowed by the ADR-002 \
12498             endpoint amendment; got {result:?}"
12499        );
12500        let edge = result.unwrap();
12501        let dk = edge
12502            .metadata
12503            .as_ref()
12504            .and_then(|m| m.get("dependency_kind"))
12505            .and_then(|v| v.as_str());
12506        assert_eq!(
12507            dk,
12508            Some("normative"),
12509            "document->document depends_on must infer dependency_kind=normative"
12510        );
12511    }
12512
12513    #[tokio::test]
12514    async fn link_org_introduced_by_document_rejected_direction_matters() {
12515        let rt = rt();
12516        let tok = NamespaceToken::local();
12517
12518        let org = rt
12519            .create_entity(&tok, "org", None, "Publisher", None, None, vec![])
12520            .await
12521            .unwrap();
12522        let doc = rt
12523            .create_entity(&tok, "document", None, "Paper", None, None, vec![])
12524            .await
12525            .unwrap();
12526
12527        // The amendment adds document->org, not org->document. Direction matters:
12528        // an org is not "introduced by" a document it published.
12529        let result = rt
12530            .link(&tok, org.id, doc.id, EdgeRelation::IntroducedBy, 1.0, None)
12531            .await;
12532        assert!(
12533            result.is_err(),
12534            "org->document introduced_by must remain rejected; only \
12535             document->org is permitted, not the reverse; got {result:?}"
12536        );
12537    }
12538
12539    // ── create_note_with_embedding_content ──────────────────────────────────
12540
12541    /// Like `ConstVecService`/`ConstVecProvider` above, but records every text
12542    /// it is asked to embed so a test can assert exactly what reached the
12543    /// "provider" — used to verify the effective embed text is the capped
12544    /// override, not the full note content.
12545    struct CapturingVecService {
12546        dims: usize,
12547        captured: Arc<std::sync::Mutex<Vec<String>>>,
12548    }
12549
12550    #[async_trait]
12551    impl EmbeddingService for CapturingVecService {
12552        async fn embed(
12553            &self,
12554            texts: &[String],
12555            _model: EmbeddingModel,
12556        ) -> std::result::Result<Vec<Vec<f32>>, EmbedError> {
12557            self.captured.lock().unwrap().extend(texts.iter().cloned());
12558            Ok(texts.iter().map(|_| vec![1.0_f32; self.dims]).collect())
12559        }
12560
12561        fn supports_model(&self, _model: EmbeddingModel) -> bool {
12562            true
12563        }
12564
12565        fn name(&self) -> &'static str {
12566            "capturing-vec"
12567        }
12568    }
12569
12570    struct CapturingVecProvider {
12571        provider_name: String,
12572        dims: usize,
12573        captured: Arc<std::sync::Mutex<Vec<String>>>,
12574    }
12575
12576    #[async_trait]
12577    impl EmbedderProvider for CapturingVecProvider {
12578        fn name(&self) -> &str {
12579            &self.provider_name
12580        }
12581
12582        fn dimensions(&self) -> usize {
12583            self.dims
12584        }
12585
12586        async fn build(&self) -> crate::error::RuntimeResult<Arc<dyn EmbeddingService>> {
12587            Ok(Arc::new(CapturingVecService {
12588                dims: self.dims,
12589                captured: Arc::clone(&self.captured),
12590            }))
12591        }
12592    }
12593
12594    #[tokio::test]
12595    async fn create_note_with_embedding_content_none_matches_create_note() {
12596        let rt = rt();
12597        let tok = NamespaceToken::local();
12598        let captured = Arc::new(std::sync::Mutex::new(Vec::new()));
12599        rt.register_embedder(CapturingVecProvider {
12600            provider_name: "capturing-vec".into(),
12601            dims: 4,
12602            captured: Arc::clone(&captured),
12603        });
12604
12605        let note = rt
12606            .create_note_with_embedding_content(
12607                &tok,
12608                "observation",
12609                None,
12610                "full content, no override",
12611                None,
12612                None,
12613                None,
12614                vec![],
12615            )
12616            .await
12617            .expect("create with None override must behave like create_note");
12618        assert_eq!(note.content, "full content, no override");
12619        let seen = captured.lock().unwrap().clone();
12620        assert_eq!(
12621            seen,
12622            vec!["full content, no override".to_string()],
12623            "with no override the embedder must see the full content"
12624        );
12625    }
12626
12627    #[tokio::test]
12628    async fn create_note_with_embedding_content_embeds_capped_override_and_stores_full_content() {
12629        let rt = rt();
12630        let tok = NamespaceToken::local();
12631        let captured = Arc::new(std::sync::Mutex::new(Vec::new()));
12632        rt.register_embedder(CapturingVecProvider {
12633            provider_name: "capturing-vec".into(),
12634            dims: 4,
12635            captured: Arc::clone(&captured),
12636        });
12637
12638        let full = "head-term and then a very long tail-term that exceeds any cap";
12639        let head = &full[.."head-term and then a very long".len()];
12640
12641        let note = rt
12642            .create_note_with_embedding_content(
12643                &tok,
12644                "observation",
12645                None,
12646                full,
12647                Some(head),
12648                None,
12649                None,
12650                vec![],
12651            )
12652            .await
12653            .expect("proper-prefix override must be accepted");
12654        assert_eq!(note.content, full, "stored content must be the full text");
12655
12656        let seen = captured.lock().unwrap().clone();
12657        assert_eq!(
12658            seen,
12659            vec![head.to_string()],
12660            "embedder must see only the capped override"
12661        );
12662    }
12663
12664    #[tokio::test]
12665    async fn create_note_with_embedding_content_fans_out_identical_override_to_multiple_models() {
12666        let rt = rt();
12667        let tok = NamespaceToken::local();
12668        let captured_a = Arc::new(std::sync::Mutex::new(Vec::new()));
12669        let captured_b = Arc::new(std::sync::Mutex::new(Vec::new()));
12670        rt.register_embedder(CapturingVecProvider {
12671            provider_name: "capturing-vec-a".into(),
12672            dims: 4,
12673            captured: Arc::clone(&captured_a),
12674        });
12675        rt.register_embedder(CapturingVecProvider {
12676            provider_name: "capturing-vec-b".into(),
12677            dims: 4,
12678            captured: Arc::clone(&captured_b),
12679        });
12680
12681        let full = "head-only-embedded plus a long discarded tail";
12682        let head = &full[.."head-only-embedded".len()];
12683
12684        rt.create_note_with_embedding_content(
12685            &tok,
12686            "observation",
12687            None,
12688            full,
12689            Some(head),
12690            None,
12691            None,
12692            vec![],
12693        )
12694        .await
12695        .expect("create ok");
12696
12697        assert_eq!(
12698            captured_a.lock().unwrap().clone(),
12699            vec![head.to_string()],
12700            "model A must receive the identical capped override"
12701        );
12702        assert_eq!(
12703            captured_b.lock().unwrap().clone(),
12704            vec![head.to_string()],
12705            "model B must receive the identical capped override"
12706        );
12707
12708        // Both models actually persisted a vector row for the note (not just
12709        // an embed call that was discarded before insertion).
12710        let vs_a = rt
12711            .vectors_for_model(&tok, "capturing-vec-a")
12712            .expect("vector store for model A");
12713        assert_eq!(
12714            vs_a.count().await.expect("vector count A"),
12715            1,
12716            "model A must have exactly one vector row for the note"
12717        );
12718        let vs_b = rt
12719            .vectors_for_model(&tok, "capturing-vec-b")
12720            .expect("vector store for model B");
12721        assert_eq!(
12722            vs_b.count().await.expect("vector count B"),
12723            1,
12724            "model B must have exactly one vector row for the note"
12725        );
12726    }
12727
12728    #[tokio::test]
12729    async fn create_note_with_embedding_content_rejects_empty_override() {
12730        let rt = rt();
12731        let tok = NamespaceToken::local();
12732
12733        let err = rt
12734            .create_note_with_embedding_content(
12735                &tok,
12736                "observation",
12737                None,
12738                "some content",
12739                Some(""),
12740                None,
12741                None,
12742                vec![],
12743            )
12744            .await
12745            .expect_err("empty override must be rejected");
12746        assert!(matches!(err, RuntimeError::InvalidInput(_)));
12747    }
12748
12749    #[tokio::test]
12750    async fn create_note_with_embedding_content_rejects_non_prefix_override() {
12751        let rt = rt();
12752        let tok = NamespaceToken::local();
12753
12754        let err = rt
12755            .create_note_with_embedding_content(
12756                &tok,
12757                "observation",
12758                None,
12759                "the actual content",
12760                Some("an unrelated string"),
12761                None,
12762                None,
12763                vec![],
12764            )
12765            .await
12766            .expect_err("non-prefix override must be rejected");
12767        assert!(matches!(err, RuntimeError::InvalidInput(ref m) if m.contains("prefix")));
12768    }
12769
12770    #[tokio::test]
12771    async fn create_note_with_embedding_content_rejects_equal_length_override() {
12772        let rt = rt();
12773        let tok = NamespaceToken::local();
12774
12775        // Same length and same text as `content` is not a *proper* prefix.
12776        let err = rt
12777            .create_note_with_embedding_content(
12778                &tok,
12779                "observation",
12780                None,
12781                "identical text",
12782                Some("identical text"),
12783                None,
12784                None,
12785                vec![],
12786            )
12787            .await
12788            .expect_err("an equal-length override must be rejected as not a proper prefix");
12789        assert!(matches!(err, RuntimeError::InvalidInput(ref m) if m.contains("prefix")));
12790    }
12791
12792    #[tokio::test]
12793    async fn create_note_with_embedding_content_rejects_secret_bearing_override() {
12794        let rt = rt();
12795        let tok = NamespaceToken::local();
12796
12797        let token_span = "ghp_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
12798        let content = format!("{token_span} plus extra trailing content beyond the override");
12799        let embedding_content = format!("{token_span} plus extra");
12800
12801        let err = rt
12802            .create_note_with_embedding_content(
12803                &tok,
12804                "observation",
12805                None,
12806                &content,
12807                Some(&embedding_content),
12808                None,
12809                None,
12810                vec![],
12811            )
12812            .await
12813            .expect_err("a credential-shaped override must fail the secret gate");
12814        assert!(
12815            matches!(err, RuntimeError::SecretDetected(_)),
12816            "expected SecretDetected, got {err:?}"
12817        );
12818
12819        // Fail-closed: no note survives the rejected create.
12820        let count = rt
12821            .notes(&tok)
12822            .unwrap()
12823            .count_notes(tok.namespace().as_str(), None)
12824            .await
12825            .unwrap();
12826        assert_eq!(count, 0, "a rejected create must leave no note behind");
12827    }
12828}