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#[cfg(any(test, feature = "fault-injection"))]
76static FTS_FAIL_NS: std::sync::Mutex<Option<String>> = std::sync::Mutex::new(None);
77#[cfg(any(test, feature = "fault-injection"))]
78static VECTOR_FAIL_NS: std::sync::Mutex<Option<String>> = std::sync::Mutex::new(None);
79/// FTS failure injection for `create_many` — separate from `FTS_FAIL_NS` so that
80/// create_note_inner and create_many tests cannot disarm each other.
81#[cfg(any(test, feature = "fault-injection"))]
82static FTS_FAIL_MANY_NS: std::sync::Mutex<Option<String>> = std::sync::Mutex::new(None);
83/// FTS partial-failure injection for `create_many` — returns `Ok(BatchWriteSummary)`
84/// with `failed > 0` so that the `summary.failed > 0` rollback branch is exercised.
85/// Distinct from `FTS_FAIL_MANY_NS` which injects a hard `Err`.
86#[cfg(any(test, feature = "fault-injection"))]
87static FTS_FAIL_MANY_PARTIAL_NS: std::sync::Mutex<Option<String>> = std::sync::Mutex::new(None);
88/// Non-parser FTS *search*-leg failure injection for `search_notes`: distinct
89/// from `FTS_FAIL_NS` (which injects at the FTS *upsert*/write step of
90/// `create_note_inner`). Injects a `StorageError::Timeout` at the `search()`
91/// call the FTS fail-open arm guards, so the arm's `is_fts5_syntax_error()`
92/// gate can be exercised against a genuine non-parser failure and asserted to
93/// propagate rather than degrade.
94#[cfg(any(test, feature = "fault-injection"))]
95static FTS_SEARCH_FAIL_NS: std::sync::Mutex<Option<String>> = std::sync::Mutex::new(None);
96
97/// Arm the FTS failure injection for `create_note_inner` targeting namespace `ns`.
98///
99/// The next `create_note` call whose note namespace equals `ns` returns an injected
100/// error at the FTS upsert step (after the note row is committed), then disarms.
101/// Calls on other namespaces are unaffected.
102/// Available when compiled with `cfg(test)` or `feature = "fault-injection"`.
103#[cfg(any(test, feature = "fault-injection"))]
104pub fn arm_fts_fail(ns: &str) {
105    *FTS_FAIL_NS.lock().unwrap() = Some(ns.to_string());
106}
107
108/// Arm the FTS failure injection for `create_many` targeting namespace `ns`.
109///
110/// The next `create_many` call whose namespace equals `ns` returns an injected
111/// error at the FTS upsert step (after entity rows are committed), then disarms.
112/// Calls on other namespaces are unaffected.
113/// Available when compiled with `cfg(test)` or `feature = "fault-injection"`.
114#[cfg(any(test, feature = "fault-injection"))]
115pub fn arm_fts_fail_many(ns: &str) {
116    *FTS_FAIL_MANY_NS.lock().unwrap() = Some(ns.to_string());
117}
118
119/// Arm the FTS *partial*-failure injection for `create_many` targeting namespace `ns`.
120///
121/// The next `create_many` call whose namespace equals `ns` returns
122/// `Ok(BatchWriteSummary { attempted: 2, affected: 1, failed: 1, ... })` from the
123/// FTS upsert step, exercising the `summary.failed > 0` rollback branch (as opposed
124/// to the hard-`Err` branch exercised by `arm_fts_fail_many`).  Then disarms.
125/// Available when compiled with `cfg(test)` or `feature = "fault-injection"`.
126#[cfg(any(test, feature = "fault-injection"))]
127pub fn arm_fts_fail_many_partial(ns: &str) {
128    *FTS_FAIL_MANY_PARTIAL_NS.lock().unwrap() = Some(ns.to_string());
129}
130
131/// Arm a non-parser FTS *search*-leg failure injection for `search_notes` targeting
132/// any call whose visible namespaces include `ns`.
133///
134/// The next `search_notes` call touching `ns` returns `StorageError::Timeout` from
135/// the FTS leg instead of calling the real `TextSearch::search`, then disarms.
136/// Used to prove the fail-open arm in `search_notes` propagates non-parser
137/// `StorageError`s instead of silently degrading them the way a genuine FTS5
138/// parser syntax error is degraded.
139/// Available when compiled with `cfg(test)` or `feature = "fault-injection"`.
140#[cfg(any(test, feature = "fault-injection"))]
141pub fn arm_fts_search_fail(ns: &str) {
142    *FTS_SEARCH_FAIL_NS.lock().unwrap() = Some(ns.to_string());
143}
144
145/// Arm the vector insertion failure injection for `create_note_inner` targeting `ns`.
146///
147/// The next `create_note` call whose note namespace equals `ns` returns an injected
148/// error at the first vector insert step, then disarms.  Calls on other namespaces
149/// are unaffected.
150/// Available when compiled with `cfg(test)` or `feature = "fault-injection"`.
151#[cfg(any(test, feature = "fault-injection"))]
152pub fn arm_vector_fail(ns: &str) {
153    *VECTOR_FAIL_NS.lock().unwrap() = Some(ns.to_string());
154}
155
156/// Failure injection for `delete_note_row_first_for_compensation`'s post-row-removal
157/// cleanup step: distinct from `FTS_FAIL_NS`/`VECTOR_FAIL_NS`, which target
158/// `create_note_inner`. Lets tests prove that a rollback compensation's cleanup
159/// failure still leaves the note row (and thus the live message) gone.
160#[cfg(any(test, feature = "fault-injection"))]
161static ROLLBACK_CLEANUP_FAIL_NS: std::sync::Mutex<Option<String>> = std::sync::Mutex::new(None);
162
163/// Arm the rollback-compensation cleanup failure injection targeting `ns`.
164///
165/// The next `delete_note_row_first_for_compensation` call whose note namespace
166/// equals `ns` removes the row as usual, then returns an injected cleanup error
167/// instead of running the real graph/FTS/vector cleanup, then disarms.
168/// Available when compiled with `cfg(test)` or `feature = "fault-injection"`.
169#[cfg(any(test, feature = "fault-injection"))]
170pub fn arm_rollback_cleanup_fail(ns: &str) {
171    *ROLLBACK_CLEANUP_FAIL_NS.lock().unwrap() = Some(ns.to_string());
172}
173
174/// A note search result with UUID, salience-weighted RRF score, and display text.
175#[derive(Clone, Debug)]
176pub struct NoteSearchHit {
177    pub note_id: Uuid,
178    pub score: DeterministicScore,
179    pub title: Option<String>,
180    pub snippet: Option<String>,
181}
182
183/// Re-insert hyphens at canonical UUID positions (8-4-4-4-12) into a
184/// hyphen-free hex prefix, so a `LIKE '<pattern>%'` scan against the
185/// hyphenated `id` column matches correctly. Prefixes that already
186/// contain a hyphen are passed through unchanged. No-op for len <= 8
187/// (already correct). Input longer than 32 hex chars is NOT truncated: the
188/// extra hex chars are appended past the canonical 12-char final segment
189/// with no further hyphen, so the resulting `LIKE` pattern requires literal
190/// characters beyond position 36 that no real (36-char) UUID string can
191/// ever have — the scan naturally fails closed instead of silently
192/// resolving `<valid-32-hex><extra-hex>` to the valid UUID.
193pub fn hex_prefix_to_uuid_pattern(prefix: &str) -> String {
194    if prefix.contains('-') {
195        return prefix.to_string();
196    }
197    const BOUNDARIES: [usize; 4] = [8, 13, 18, 23]; // post-hyphen-insertion offsets
198    let mut out = String::with_capacity(36);
199    for c in prefix.chars() {
200        if BOUNDARIES.contains(&out.len()) {
201            out.push('-');
202        }
203        out.push(c);
204    }
205    out
206}
207
208fn text_preview(text: &str, max_chars: usize) -> Option<String> {
209    let trimmed = text.trim();
210    if trimmed.is_empty() {
211        None
212    } else {
213        Some(trimmed.chars().take(max_chars).collect())
214    }
215}
216
217/// Symmetric relations (`competes_with`, `composed_with`) are stored with a
218/// canonical source (lower UUID wins), so a directed `Out` or `In` query may
219/// miss results. When the relations filter is non-empty and contains **only**
220/// symmetric relations, override direction to `Both` so callers always see all
221/// edges for these relations regardless of storage canonicalization.
222fn normalize_symmetric_direction(
223    direction: Direction,
224    relations: Option<&[EdgeRelation]>,
225) -> Direction {
226    let Some(rels) = relations else {
227        return direction;
228    };
229    if rels.is_empty() {
230        return direction;
231    }
232    let all_symmetric = rels
233        .iter()
234        .all(|r| matches!(r, EdgeRelation::CompetesWith | EdgeRelation::ComposedWith));
235    if all_symmetric {
236        Direction::Both
237    } else {
238        direction
239    }
240}
241
242/// Stable tie-break rank for [`Direction`] — `Out` before `In` — used to make
243/// the both-direction sort/dedup key total over self-loop edges. A self-loop
244/// (`source_id == target_id == node_id`) produces two `UNION ALL` rows with
245/// the same `(node_id, edge_id)` but opposite directions; without direction in
246/// the key, sort-then-dedup collapses them to one and drops the direction
247/// parity a separate `Out` call plus a separate `In` call would preserve.
248fn direction_sort_rank(direction: &Direction) -> u8 {
249    match direction {
250        Direction::Out => 0,
251        Direction::In => 1,
252        Direction::Both => 2,
253    }
254}
255
256fn note_title(note: &Note) -> Option<String> {
257    note.name
258        .clone()
259        .filter(|s| !s.trim().is_empty())
260        .or_else(|| Some(format!("[{}]", note.kind.as_str())))
261}
262
263fn note_snippet(note: &Note) -> Option<String> {
264    text_preview(&note.content, 200)
265}
266
267/// Result of resolving a UUID to its substrate kind.
268#[derive(Clone, Debug)]
269pub enum Resolved {
270    Entity(Entity),
271    Note(Note),
272    Event(Event),
273    /// A record owned by a pack's private tables.
274    ///
275    /// `pack` identifies the owning pack by name, `kind` is the pack-local
276    /// record type (e.g. "domain", "atom"), and `data` is the full record as
277    /// a JSON Value. Pack-private records are not valid edge endpoints,
278    /// annotates sources, or task context entities.
279    PackRecord {
280        pack: String,
281        kind: String,
282        data: serde_json::Value,
283    },
284}
285
286/// A by-ID edge-endpoint substrate kind, including `Edge` itself.
287///
288/// Unlike [`Resolved`], this carries no record data — it is used where only
289/// the substrate classification is needed (coordinator locate/link parity
290/// with `get`, ADR-002 rule 1: `annotates` target may be entity, note, edge,
291/// or event).
292#[derive(Clone, Copy, Debug, Eq, PartialEq)]
293pub enum EdgeEndpointKind {
294    Entity,
295    Note,
296    Event,
297    Edge,
298}
299
300/// Map a resolved endpoint to its `(substrate, kind, entity_type)` triple, or
301/// `None` if the substrate is not a valid edge endpoint (events, edges).
302///
303/// `entity_type` carries the pack-owned granular subtype (`Entity::entity_type`,
304/// e.g. `"theorem"`); it is `None` for notes and for entities with no subtype.
305fn resolved_pair(r: Option<&Resolved>) -> Option<(&'static str, &str, Option<&str>)> {
306    match r? {
307        Resolved::Entity(e) => Some(("entity", e.kind.as_str(), e.entity_type.as_deref())),
308        Resolved::Note(n) => Some(("note", n.kind.as_str(), None)),
309        Resolved::Event(_) => None,
310        Resolved::PackRecord { .. } => None,
311    }
312}
313
314/// `true` if `spec` matches the given substrate + kind + entity_type triple.
315///
316/// Pure and DB-free — exposed so offline consumers (e.g. `kkernel kg
317/// validate`, which parses `(substrate, kind, entity_type)` straight out of
318/// NDJSON with no live record to resolve) can apply the exact same
319/// `EdgeEndpointRule` matching semantics `pack_rule_allows` uses internally,
320/// instead of re-deriving a parallel matcher that could drift out of sync.
321pub fn endpoint_matches(
322    spec: &EndpointKind,
323    substrate: &str,
324    kind: &str,
325    entity_type: Option<&str>,
326) -> bool {
327    match spec {
328        EndpointKind::EntityOfKind(k) => substrate == "entity" && *k == kind,
329        EndpointKind::NoteOfKind(k) => substrate == "note" && *k == kind,
330        EndpointKind::EntityOfType {
331            kind: k,
332            entity_type: t,
333        } => substrate == "entity" && *k == kind && entity_type == Some(*t),
334    }
335}
336
337/// Relations that a composed pack `EDGE_RULES` set accepts for a given
338/// `(entity_kind, entity_type)` endpoint pair, using the EXACT SAME
339/// `endpoint_matches` semantics `pack_rule_allows` applies internally
340/// (`EntityOfKind`, `EntityOfType`, `NoteOfKind`) — never a re-filtered copy.
341///
342/// Both endpoints are treated as entities (substrate `"entity"`), matching
343/// the only case pack-layer error-hint code needs (issue #543): a rejected
344/// `link` between two already-resolved entities. `entity_type` is the
345/// pack-owned granular subtype (e.g. `"theorem"`); pass `None` for
346/// untyped entities. Exposed so `khive-pack-kg`'s hint derivation cannot
347/// silently diverge from the validator by only matching `EntityOfKind` and
348/// missing pack rules declared via `EntityOfType` (e.g. `khive-pack-formal`'s
349/// typed `theorem -> definition` `depends_on` rules).
350pub fn accepted_pack_relations_for_entities(
351    rules: &[EdgeEndpointRule],
352    src_kind: &str,
353    src_entity_type: Option<&str>,
354    tgt_kind: &str,
355    tgt_entity_type: Option<&str>,
356) -> Vec<EdgeRelation> {
357    let mut relations: Vec<EdgeRelation> = rules
358        .iter()
359        .filter(|r| {
360            endpoint_matches(&r.source, "entity", src_kind, src_entity_type)
361                && endpoint_matches(&r.target, "entity", tgt_kind, tgt_entity_type)
362        })
363        .map(|r| r.relation)
364        .collect();
365    relations.sort_by_key(|r| r.as_str());
366    relations.dedup();
367    relations
368}
369
370/// `true` if any pack-declared edge endpoint rule allows the
371/// `(source, relation, target)` triple. Pack rules are additive only.
372fn pack_rule_allows(
373    rules: &[EdgeEndpointRule],
374    relation: EdgeRelation,
375    src: Option<&Resolved>,
376    tgt: Option<&Resolved>,
377) -> bool {
378    let Some((src_sub, src_kind, src_type)) = resolved_pair(src) else {
379        return false;
380    };
381    let Some((tgt_sub, tgt_kind, tgt_type)) = resolved_pair(tgt) else {
382        return false;
383    };
384    rules.iter().any(|r| {
385        r.relation == relation
386            && endpoint_matches(&r.source, src_sub, src_kind, src_type)
387            && endpoint_matches(&r.target, tgt_sub, tgt_kind, tgt_type)
388    })
389}
390
391/// Base entity endpoint allowlist — the closed set of permitted entity→entity
392/// relation triples.
393///
394/// Each entry `(src_kind, relation, tgt_kind)` explicitly allows that combination.
395/// `"*"` as `src_kind` means "any entity kind" (used by `instance_of` whose source
396/// is unrestricted).
397///
398/// Pack rules (via `EDGE_RULES`) are additive — they cannot remove rows here.
399/// Exposed via `base_entity_endpoint_rules()` for the ADR-076 certificate tests.
400pub const BASE_ENTITY_ENDPOINT_RULES: &[(&str, EdgeRelation, &str)] = &[
401    // Structure
402    ("concept", EdgeRelation::Contains, "concept"),
403    ("project", EdgeRelation::Contains, "project"),
404    ("project", EdgeRelation::Contains, "artifact"),
405    ("org", EdgeRelation::Contains, "project"),
406    ("org", EdgeRelation::Contains, "service"),
407    ("concept", EdgeRelation::PartOf, "concept"),
408    ("project", EdgeRelation::PartOf, "project"),
409    ("project", EdgeRelation::PartOf, "org"),
410    ("*", EdgeRelation::InstanceOf, "concept"),
411    ("service", EdgeRelation::InstanceOf, "project"),
412    // Derivation
413    ("concept", EdgeRelation::Extends, "concept"),
414    ("concept", EdgeRelation::VariantOf, "concept"),
415    ("artifact", EdgeRelation::VariantOf, "artifact"),
416    ("concept", EdgeRelation::IntroducedBy, "document"),
417    ("concept", EdgeRelation::IntroducedBy, "person"),
418    ("artifact", EdgeRelation::IntroducedBy, "document"),
419    ("document", EdgeRelation::IntroducedBy, "person"),
420    ("document", EdgeRelation::IntroducedBy, "org"),
421    ("concept", EdgeRelation::IntroducedBy, "org"),
422    // Provenance
423    ("artifact", EdgeRelation::DerivedFrom, "dataset"),
424    ("artifact", EdgeRelation::DerivedFrom, "document"),
425    ("artifact", EdgeRelation::DerivedFrom, "project"),
426    ("artifact", EdgeRelation::DerivedFrom, "artifact"),
427    // Temporal
428    ("document", EdgeRelation::Precedes, "document"),
429    ("dataset", EdgeRelation::Precedes, "dataset"),
430    ("artifact", EdgeRelation::Precedes, "artifact"),
431    ("service", EdgeRelation::Precedes, "service"),
432    ("project", EdgeRelation::Precedes, "project"),
433    // Dependency
434    ("project", EdgeRelation::DependsOn, "project"),
435    ("service", EdgeRelation::DependsOn, "project"),
436    ("service", EdgeRelation::DependsOn, "service"),
437    ("service", EdgeRelation::DependsOn, "artifact"),
438    ("service", EdgeRelation::DependsOn, "dataset"),
439    ("artifact", EdgeRelation::DependsOn, "project"),
440    ("artifact", EdgeRelation::DependsOn, "service"),
441    ("document", EdgeRelation::DependsOn, "document"),
442    ("concept", EdgeRelation::Enables, "concept"),
443    ("service", EdgeRelation::Enables, "concept"),
444    ("dataset", EdgeRelation::Enables, "concept"),
445    // Implementation
446    ("project", EdgeRelation::Implements, "concept"),
447    ("service", EdgeRelation::Implements, "concept"),
448    // Lateral
449    ("concept", EdgeRelation::CompetesWith, "concept"),
450    ("project", EdgeRelation::CompetesWith, "project"),
451    ("service", EdgeRelation::CompetesWith, "service"),
452    ("concept", EdgeRelation::ComposedWith, "concept"),
453    ("project", EdgeRelation::ComposedWith, "project"),
454    // Versioning (Supersedes — Concept/Document/Artifact/Service/Dataset only)
455    ("concept", EdgeRelation::Supersedes, "concept"),
456    ("document", EdgeRelation::Supersedes, "document"),
457    ("artifact", EdgeRelation::Supersedes, "artifact"),
458    ("service", EdgeRelation::Supersedes, "service"),
459    ("dataset", EdgeRelation::Supersedes, "dataset"),
460    // Epistemic (Supports/Refutes — evidence sources → Concept claim only)
461    ("concept", EdgeRelation::Supports, "concept"),
462    ("document", EdgeRelation::Supports, "concept"),
463    ("dataset", EdgeRelation::Supports, "concept"),
464    ("artifact", EdgeRelation::Supports, "concept"),
465    ("concept", EdgeRelation::Refutes, "concept"),
466    ("document", EdgeRelation::Refutes, "concept"),
467    ("dataset", EdgeRelation::Refutes, "concept"),
468    ("artifact", EdgeRelation::Refutes, "concept"),
469];
470
471/// Returns the base entity endpoint allowlist.
472///
473/// The returned slice is the same data that `base_entity_rule_allows` consults at
474/// runtime. Exposed for the ADR-076 certificate tests in `khive-pack-kg`, which
475/// must audit live rules rather than hand-copied snapshots.
476pub fn base_entity_endpoint_rules() -> &'static [(&'static str, EdgeRelation, &'static str)] {
477    BASE_ENTITY_ENDPOINT_RULES
478}
479
480/// `true` if `(src_kind, relation, tgt_kind)` is in the base entity endpoint
481/// allowlist. Pure and DB-free — exposed alongside [`base_entity_endpoint_rules`]
482/// so offline consumers (e.g. `kkernel kg validate`) can apply the exact same
483/// base-table membership test the live validator uses, instead of re-deriving
484/// a parallel `.any()` predicate over a hand-copied allowlist.
485pub fn base_entity_rule_allows(src_kind: &str, relation: EdgeRelation, tgt_kind: &str) -> bool {
486    BASE_ENTITY_ENDPOINT_RULES.iter().any(|(src, rel, tgt)| {
487        *rel == relation && (*src == "*" || *src == src_kind) && *tgt == tgt_kind
488    })
489}
490
491/// Canonical endpoint order for symmetric relations (F012).
492///
493/// For `competes_with` and `composed_with`, normalises direction so that
494/// `source_uuid < target_uuid` (lexicographic on the UUID bytes). This
495/// collapses A→B and B→A into a single canonical row, preventing duplicates.
496pub(crate) fn canonical_edge_endpoints(
497    relation: EdgeRelation,
498    source_id: Uuid,
499    target_id: Uuid,
500) -> (Uuid, Uuid) {
501    if relation.is_symmetric() && target_id < source_id {
502        (target_id, source_id)
503    } else {
504        (source_id, target_id)
505    }
506}
507
508/// Infer the default `dependency_kind` from endpoint entity kinds.
509///
510/// `pub(crate)` so `crate::atomic_prepare::prepare_link` can reuse this exact
511/// inference table, keeping `--atomic link` byte-for-byte consistent with the
512/// non-atomic `link()` rather than re-deriving the table.
513pub(crate) fn infer_dependency_kind(src_kind: &str, tgt_kind: &str) -> Option<&'static str> {
514    match (src_kind, tgt_kind) {
515        ("project", "project") => Some("build"),
516        ("service", "service") => Some("runtime"),
517        ("service", "dataset") => Some("data"),
518        ("service", "artifact") => Some("artifact"),
519        ("artifact", "project") | ("artifact", "service") => Some("tooling"),
520        ("document", "document") => Some("normative"),
521        _ => None,
522    }
523}
524
525/// Merge an inferred `dependency_kind` into `depends_on` edge metadata.
526///
527/// If `metadata` already carries a `dependency_kind` key the existing value is
528/// preserved. If the key is absent and the endpoint pair has a known default,
529/// the inferred value is added. Returns `metadata` unchanged for all other
530/// cases (no matching default, or metadata already has the key).
531///
532/// `pub(crate)` so `crate::atomic_prepare::prepare_link` can reuse it for
533/// atomic/non-atomic parity.
534pub(crate) fn merge_dependency_kind(
535    src_kind: &str,
536    tgt_kind: &str,
537    metadata: Option<serde_json::Value>,
538) -> Option<serde_json::Value> {
539    if let Some(ref m) = metadata {
540        if m.get("dependency_kind").is_some() {
541            return metadata;
542        }
543    }
544    let inferred = infer_dependency_kind(src_kind, tgt_kind)?;
545    let mut obj = metadata.unwrap_or_else(|| serde_json::json!({}));
546    if let Some(o) = obj.as_object_mut() {
547        o.insert("dependency_kind".to_string(), serde_json::json!(inferred));
548    }
549    Some(obj)
550}
551
552/// Merge a caller-supplied top-level `dependency_kind` param into an edge's
553/// `metadata` object, filling the key only if `metadata` doesn't already
554/// carry one. This is distinct from `merge_dependency_kind` above (which
555/// infers a default from endpoint entity kinds when no explicit value was
556/// given at all) — this one folds in an EXPLICIT `dependency_kind` argument
557/// the caller passed alongside `metadata`.
558///
559/// `pub`: the single source both `khive-pack-kg::handlers::link::handle_link`
560/// (via `khive_runtime::merge_entry_metadata`) and
561/// `crate::atomic_prepare::prepare_link` call. Lives in `khive-runtime` (not
562/// pack-kg) because packs depend on `khive-runtime`, never the reverse: the
563/// only direction that lets both call sites share one copy instead of a
564/// hand-duplicated block.
565pub fn merge_entry_metadata(
566    metadata: Option<serde_json::Value>,
567    dependency_kind: Option<String>,
568) -> RuntimeResult<Option<serde_json::Value>> {
569    let Some(dk) = dependency_kind else {
570        return Ok(metadata);
571    };
572    let mut obj = metadata.unwrap_or_else(|| serde_json::json!({}));
573    let map = obj
574        .as_object_mut()
575        .ok_or_else(|| RuntimeError::InvalidInput("metadata must be a JSON object".into()))?;
576    map.entry("dependency_kind".to_string())
577        .or_insert_with(|| serde_json::json!(dk));
578    Ok(Some(obj))
579}
580
581/// Valid `dependency_kind` values for `depends_on` edges.
582const VALID_DEPENDENCY_KINDS: &[&str] = &[
583    "build",
584    "runtime",
585    "data",
586    "artifact",
587    "tooling",
588    "normative",
589];
590
591/// Validate that an edge weight is finite and within `[0.0, 1.0]`.
592///
593/// Rejects NaN, infinities, negative values, and values exceeding 1.0.
594/// Used by `link` and `import_kg` to enforce the weight invariant consistently
595/// across all edge creation paths.
596pub(crate) fn validate_edge_weight(weight: f64) -> RuntimeResult<()> {
597    if !weight.is_finite() || !(0.0..=1.0).contains(&weight) {
598        return Err(RuntimeError::InvalidInput(format!(
599            "edge weight must be finite and in [0.0, 1.0], got {weight}"
600        )));
601    }
602    Ok(())
603}
604
605/// Validate governed edge metadata keys.
606///
607/// Currently enforces:
608/// - `dependency_kind` is only valid on `depends_on` edges.
609/// - `dependency_kind`, when present, must be one of the governed values.
610pub(crate) fn validate_edge_metadata(
611    relation: EdgeRelation,
612    metadata: Option<&serde_json::Value>,
613) -> RuntimeResult<()> {
614    let Some(meta) = metadata else {
615        return Ok(());
616    };
617    if let Some(dk) = meta.get("dependency_kind") {
618        if relation != EdgeRelation::DependsOn {
619            return Err(RuntimeError::InvalidInput(format!(
620                "dependency_kind is only valid on depends_on edges (got {})",
621                relation.as_str()
622            )));
623        }
624        let dk_str = dk
625            .as_str()
626            .ok_or_else(|| RuntimeError::InvalidInput("dependency_kind must be a string".into()))?;
627        if !VALID_DEPENDENCY_KINDS.contains(&dk_str) {
628            return Err(RuntimeError::InvalidInput(format!(
629                "unknown dependency_kind {dk_str:?}; valid: {}",
630                VALID_DEPENDENCY_KINDS.join(" | ")
631            )));
632        }
633    }
634    Ok(())
635}
636
637/// Returns `true` when `note_props` is a superset of all key-value pairs in `filter`.
638///
639/// Mirrors the semantics of `khive_pack_kg::handlers::common::props_match` so that the
640/// storage-leg predicate in `search_notes` is identical to the handler-side post-filter.
641fn note_props_match(note_props: Option<&serde_json::Value>, filter: &serde_json::Value) -> bool {
642    let required = match filter.as_object() {
643        Some(obj) if !obj.is_empty() => obj,
644        _ => return true,
645    };
646    let actual = match note_props.and_then(serde_json::Value::as_object) {
647        Some(obj) => obj,
648        None => return false,
649    };
650    required
651        .iter()
652        .all(|(k, v)| actual.get(k).is_some_and(|av| av == v))
653}
654
655impl KhiveRuntime {
656    // ---- Entity operations ----
657
658    /// Create and persist a new entity.
659    // REASON: entity creation requires kind, type, name, description, properties, tags, and
660    // namespace token — refactoring into a builder would add indirection without reducing
661    // caller complexity; this signature mirrors the MCP verb surface directly.
662    #[allow(clippy::too_many_arguments)]
663    pub async fn create_entity(
664        &self,
665        token: &NamespaceToken,
666        kind: &str,
667        entity_type: Option<&str>,
668        name: &str,
669        description: Option<&str>,
670        properties: Option<serde_json::Value>,
671        tags: Vec<String>,
672    ) -> RuntimeResult<Entity> {
673        self.validate_entity_kind(kind)?;
674        // Secret gate: scan name, description, structured properties, and tags.
675        crate::secret_gate::check(name)?;
676        if let Some(d) = description {
677            crate::secret_gate::check(d)?;
678        }
679        if let Some(ref p) = properties {
680            crate::secret_gate::check_json(p)?;
681        }
682        crate::secret_gate::check_tags(&tags)?;
683        let ns = token.namespace().as_str();
684        let mut entity = Entity::new(ns, kind, name).with_entity_type(entity_type);
685        if let Some(d) = description {
686            entity = entity.with_description(d);
687        }
688        if let Some(p) = properties {
689            entity = entity.with_properties(p);
690        }
691        if !tags.is_empty() {
692            entity = entity.with_tags(tags);
693        }
694        self.entities(token)?.upsert_entity(entity.clone()).await?;
695
696        let doc = entity_fts_document(&entity);
697        let embed_body = doc.body.clone();
698
699        // FTS step — compensate entity row on failure (mirrors create_note_inner).
700        {
701            #[cfg(any(test, feature = "fault-injection"))]
702            let fts_inject = {
703                let mut g = FTS_FAIL_NS.lock().unwrap();
704                if g.as_deref() == Some(ns) {
705                    *g = None;
706                    true
707                } else {
708                    false
709                }
710            };
711            #[cfg(not(any(test, feature = "fault-injection")))]
712            let fts_inject = false;
713            let fts_result: RuntimeResult<()> = if fts_inject {
714                Err(RuntimeError::Internal("injected FTS failure".to_string()))
715            } else {
716                match self.text(token) {
717                    Ok(fts) => fts.upsert_document(doc).await.map_err(RuntimeError::from),
718                    Err(e) => Err(e),
719                }
720            };
721            if let Err(e) = fts_result {
722                if let Ok(store) = self.entities(token) {
723                    if let Err(ce) = store.delete_entity(entity.id, DeleteMode::Hard).await {
724                        tracing::error!(
725                            error = %ce,
726                            id = %entity.id,
727                            "create_entity: failed to roll back entity row after FTS failure"
728                        );
729                    }
730                }
731                return Err(e);
732            }
733        }
734
735        // Vector embedding + insert step — compensate entity row + FTS doc on failure.
736        // Fan out to ALL registered models (mirrors create_note_inner multi-model path).
737        let embed_model_names = {
738            let names = self.registered_embedding_model_names();
739            if names.is_empty() {
740                vec![]
741            } else {
742                names
743            }
744        };
745
746        if embed_model_names.len() == 1 {
747            let model_name = &embed_model_names[0];
748            let vec_result = self
749                .embed_document_with_model(model_name, &embed_body)
750                .await;
751
752            #[cfg(any(test, feature = "fault-injection"))]
753            let vec_inject = {
754                let mut g = VECTOR_FAIL_NS.lock().unwrap();
755                if g.as_deref() == Some(ns) {
756                    *g = None;
757                    true
758                } else {
759                    false
760                }
761            };
762            #[cfg(not(any(test, feature = "fault-injection")))]
763            let vec_inject = false;
764            let vec_result: RuntimeResult<Vec<f32>> = if vec_inject {
765                Err(RuntimeError::Internal(
766                    "injected vector failure".to_string(),
767                ))
768            } else {
769                vec_result
770            };
771
772            let single_result: RuntimeResult<()> = match vec_result {
773                Ok(vector) => match self.vectors_for_model(token, model_name) {
774                    Ok(vs) => vs
775                        .insert(
776                            entity.id,
777                            SubstrateKind::Entity,
778                            ns,
779                            "entity.body",
780                            vec![vector],
781                        )
782                        .await
783                        .map_err(RuntimeError::from),
784                    Err(e) => Err(e),
785                },
786                Err(e) => Err(e),
787            };
788            if let Err(e) = single_result {
789                if let Ok(store) = self.entities(token) {
790                    if let Err(ce) = store.delete_entity(entity.id, DeleteMode::Hard).await {
791                        tracing::error!(
792                            error = %ce,
793                            id = %entity.id,
794                            "create_entity: failed to roll back entity row after vector failure"
795                        );
796                    }
797                }
798                if let Ok(fts) = self.text(token) {
799                    if let Err(ce) = fts.delete_document(ns, entity.id).await {
800                        tracing::error!(
801                            error = %ce,
802                            id = %entity.id,
803                            "create_entity: failed to roll back FTS document after vector failure"
804                        );
805                    }
806                }
807                return Err(e);
808            }
809        } else if !embed_model_names.is_empty() {
810            // Multi-model path: embed with each model in parallel, then insert sequentially
811            // with inserted_models tracking for rollback on partial failure.
812            let rt_clone = self.clone();
813            let body_owned = embed_body.clone();
814            let mut handles = Vec::with_capacity(embed_model_names.len());
815            for model_name in &embed_model_names {
816                let rt = rt_clone.clone();
817                let text = body_owned.clone();
818                let name = model_name.clone();
819                handles.push(tokio::spawn(async move {
820                    rt.embed_document_with_model(&name, &text).await
821                }));
822            }
823            let mut vectors: Vec<Vec<f32>> = Vec::with_capacity(embed_model_names.len());
824            for handle in handles {
825                let join_result = handle
826                    .await
827                    .map_err(|e| RuntimeError::Internal(format!("embed task panicked: {e}")));
828                match join_result {
829                    Err(e) => {
830                        if let Ok(store) = self.entities(token) {
831                            if let Err(ce) = store.delete_entity(entity.id, DeleteMode::Hard).await
832                            {
833                                tracing::error!(
834                                    error = %ce,
835                                    id = %entity.id,
836                                    "create_entity: failed to roll back entity row after embed task panic"
837                                );
838                            }
839                        }
840                        if let Ok(fts) = self.text(token) {
841                            if let Err(ce) = fts.delete_document(ns, entity.id).await {
842                                tracing::error!(
843                                    error = %ce,
844                                    id = %entity.id,
845                                    "create_entity: failed to roll back FTS document after embed task panic"
846                                );
847                            }
848                        }
849                        return Err(e);
850                    }
851                    Ok(Err(e)) => {
852                        if let Ok(store) = self.entities(token) {
853                            if let Err(ce) = store.delete_entity(entity.id, DeleteMode::Hard).await
854                            {
855                                tracing::error!(
856                                    error = %ce,
857                                    id = %entity.id,
858                                    "create_entity: failed to roll back entity row after embed failure"
859                                );
860                            }
861                        }
862                        if let Ok(fts) = self.text(token) {
863                            if let Err(ce) = fts.delete_document(ns, entity.id).await {
864                                tracing::error!(
865                                    error = %ce,
866                                    id = %entity.id,
867                                    "create_entity: failed to roll back FTS document after embed failure"
868                                );
869                            }
870                        }
871                        return Err(e);
872                    }
873                    Ok(Ok(vec)) => vectors.push(vec),
874                }
875            }
876            // TODO(P2): parallelize vector inserts
877            let mut inserted_models: Vec<String> = Vec::with_capacity(embed_model_names.len());
878            for (model_name, vector) in embed_model_names.iter().zip(vectors) {
879                // Count-targetable fault injection for multi-model insert path.
880                #[cfg(any(test, feature = "fault-injection"))]
881                let count_inject = VECTOR_FAIL_AFTER.with(|cell| match cell.get() {
882                    Some(0) => {
883                        cell.set(None);
884                        true
885                    }
886                    Some(n) => {
887                        cell.set(Some(n - 1));
888                        false
889                    }
890                    None => false,
891                });
892                #[cfg(not(any(test, feature = "fault-injection")))]
893                let count_inject = false;
894
895                let insert_result = if count_inject {
896                    Err(RuntimeError::Internal(
897                        "injected vector insert failure".to_string(),
898                    ))
899                } else {
900                    match self.vectors_for_model(token, model_name) {
901                        Ok(vs) => vs
902                            .insert(
903                                entity.id,
904                                SubstrateKind::Entity,
905                                ns,
906                                "entity.body",
907                                vec![vector],
908                            )
909                            .await
910                            .map_err(RuntimeError::from),
911                        Err(e) => Err(e),
912                    }
913                };
914                if let Err(e) = insert_result {
915                    // Compensate entity row + FTS + already-inserted vectors.
916                    if let Ok(store) = self.entities(token) {
917                        if let Err(ce) = store.delete_entity(entity.id, DeleteMode::Hard).await {
918                            tracing::error!(
919                                error = %ce,
920                                id = %entity.id,
921                                "create_entity: failed to roll back entity row after vector insert failure"
922                            );
923                        }
924                    }
925                    if let Ok(fts) = self.text(token) {
926                        if let Err(ce) = fts.delete_document(ns, entity.id).await {
927                            tracing::error!(
928                                error = %ce,
929                                id = %entity.id,
930                                "create_entity: failed to roll back FTS document after vector insert failure"
931                            );
932                        }
933                    }
934                    for m in &inserted_models {
935                        if let Ok(vs) = self.vectors_for_model(token, m) {
936                            if let Err(ce) = vs.delete(entity.id).await {
937                                tracing::error!(
938                                    error = %ce,
939                                    model = m,
940                                    id = %entity.id,
941                                    "create_entity: failed to roll back vector for model after insert failure"
942                                );
943                            }
944                        }
945                    }
946                    return Err(e);
947                }
948                inserted_models.push(model_name.clone());
949            }
950        }
951
952        Ok(entity)
953    }
954
955    /// Retrieve an entity by ID.
956    ///
957    /// UUID v4 is globally unique: no namespace filter on by-ID ops.
958    pub async fn get_entity(&self, token: &NamespaceToken, id: Uuid) -> RuntimeResult<Entity> {
959        self.entities(token)?
960            .get_entity(id)
961            .await?
962            .ok_or_else(|| RuntimeError::NotFound(format!("entity {id}")))
963    }
964
965    /// Retrieve an entity by ID including soft-deleted rows.
966    ///
967    /// UUID v4 is globally unique: no namespace filter on by-ID ops.
968    pub async fn get_entity_including_deleted(
969        &self,
970        token: &NamespaceToken,
971        id: Uuid,
972    ) -> RuntimeResult<Option<Entity>> {
973        self.entities(token)?
974            .get_entity_including_deleted(id)
975            .await
976            .map_err(Into::into)
977    }
978
979    /// Retrieve a note by ID including soft-deleted rows.
980    ///
981    /// UUID v4 is globally unique: no namespace filter on by-ID ops.
982    pub async fn get_note_including_deleted(
983        &self,
984        token: &NamespaceToken,
985        id: Uuid,
986    ) -> RuntimeResult<Option<khive_storage::note::Note>> {
987        self.notes(token)?
988            .get_note_including_deleted(id)
989            .await
990            .map_err(Into::into)
991    }
992
993    /// Fetch multiple entities by ID, returning only those that exist in the
994    /// caller's namespace.  Missing or namespace-mismatched IDs are silently
995    /// omitted so that batch lookups don't abort on a single stale reference.
996    pub async fn get_entities_by_ids(
997        &self,
998        token: &NamespaceToken,
999        ids: &[Uuid],
1000    ) -> RuntimeResult<Vec<Entity>> {
1001        if ids.is_empty() {
1002            return Ok(vec![]);
1003        }
1004        let filter = EntityFilter {
1005            ids: ids.to_vec(),
1006            ..Default::default()
1007        };
1008        let page = self
1009            .entities(token)?
1010            .query_entities(
1011                token.namespace().as_str(),
1012                filter,
1013                PageRequest {
1014                    offset: 0,
1015                    limit: ids.len() as u32,
1016                },
1017            )
1018            .await?;
1019        Ok(page.items)
1020    }
1021
1022    /// Like `get_entities_by_ids` but scoped to the token's full visible-namespace
1023    /// set (`primary ∪ extra_visible`) instead of primary only.
1024    ///
1025    /// Graph expansion (`neighbors`, `traverse`) iterates over all visible
1026    /// namespaces, so enrichment must use the same scope — otherwise neighbors
1027    /// or path nodes whose entities live in an extra-visible namespace are left
1028    /// with `name = None`, `kind = None`.  Missing or out-of-scope IDs are
1029    /// silently omitted (best-effort, same as `get_entities_by_ids`).
1030    async fn get_entities_by_ids_visible(
1031        &self,
1032        token: &NamespaceToken,
1033        ids: &[Uuid],
1034    ) -> RuntimeResult<Vec<Entity>> {
1035        if ids.is_empty() {
1036            return Ok(vec![]);
1037        }
1038        let namespaces: Vec<String> = token
1039            .visible_namespaces()
1040            .iter()
1041            .map(|ns| ns.as_str().to_owned())
1042            .collect();
1043        let filter = EntityFilter {
1044            ids: ids.to_vec(),
1045            namespaces,
1046            ..Default::default()
1047        };
1048        let page = self
1049            .entities(token)?
1050            .query_entities(
1051                token.namespace().as_str(),
1052                filter,
1053                PageRequest {
1054                    offset: 0,
1055                    limit: ids.len() as u32,
1056                },
1057            )
1058            .await?;
1059        Ok(page.items)
1060    }
1061
1062    /// Enforce that `record_ns` is within the caller's visible namespace set.
1063    ///
1064    /// Returns `Err(NotFound)` when the record namespace is not in the visible
1065    /// set — wrong-namespace and absent UUIDs must be indistinguishable
1066    /// externally (no existence oracle).
1067    ///
1068    /// When the visible set is a single entry equal to `caller_primary_ns`, this
1069    /// is identical to the former strict-equality check (backward-compatible).
1070    pub(crate) fn ensure_namespace(record_ns: &str, caller_primary_ns: &str) -> RuntimeResult<()> {
1071        if record_ns == caller_primary_ns {
1072            return Ok(());
1073        }
1074        Err(RuntimeError::NotFound("not found in this namespace".into()))
1075    }
1076
1077    /// Enforce that `record_ns` is a member of the token's visible namespace set.
1078    ///
1079    /// This is the multi-namespace-aware variant used when the token carries an
1080    /// extended visibility set. For single-namespace tokens (visible == [primary])
1081    /// this degenerates to the same strict-equality check as `ensure_namespace`.
1082    pub(crate) fn ensure_namespace_visible(
1083        record_ns: &str,
1084        token: &NamespaceToken,
1085    ) -> RuntimeResult<()> {
1086        for ns in token.visible_namespaces() {
1087            if record_ns == ns.as_str() {
1088                return Ok(());
1089            }
1090        }
1091        Err(RuntimeError::NotFound("not found in this namespace".into()))
1092    }
1093
1094    /// List entities visible to the token, optionally filtered by kind and entity_type.
1095    ///
1096    /// When the token carries a multi-namespace visible set, entities from all
1097    /// visible namespaces are returned. When the visible set is `[primary]`
1098    /// (the default) this behaves identically to the pre-visibility behaviour.
1099    pub async fn list_entities(
1100        &self,
1101        token: &NamespaceToken,
1102        kind: Option<&str>,
1103        entity_type: Option<&str>,
1104        limit: u32,
1105        offset: u32,
1106    ) -> RuntimeResult<Vec<Entity>> {
1107        let ns_strs: Vec<String> = token
1108            .visible_namespaces()
1109            .iter()
1110            .map(|ns| ns.as_str().to_owned())
1111            .collect();
1112        let filter = EntityFilter {
1113            kinds: match kind {
1114                Some(k) => vec![k.to_string()],
1115                None => vec![],
1116            },
1117            entity_types: match entity_type {
1118                Some(t) => vec![t.to_string()],
1119                None => vec![],
1120            },
1121            namespaces: ns_strs,
1122            ..Default::default()
1123        };
1124        let page = self
1125            .entities(token)?
1126            .query_entities(
1127                token.namespace().as_str(),
1128                filter,
1129                PageRequest {
1130                    offset: offset.into(),
1131                    limit,
1132                },
1133            )
1134            .await?;
1135        Ok(page.items)
1136    }
1137
1138    /// List entities filtered by kind, optional domain tag, limit, and offset.
1139    ///
1140    /// When `domain_tag` is Some, the query is restricted at the storage layer via
1141    /// `EntityFilter::tags_any` so the page result already reflects the domain
1142    /// constraint.  This avoids the silent truncation that occurs when filtering
1143    /// post-page (K-3). Multi-namespace visibility from the token is applied.
1144    pub async fn list_entities_tagged(
1145        &self,
1146        token: &NamespaceToken,
1147        kind: Option<&str>,
1148        domain_tag: Option<&str>,
1149        limit: u32,
1150        offset: u32,
1151    ) -> RuntimeResult<Vec<Entity>> {
1152        let ns_strs: Vec<String> = token
1153            .visible_namespaces()
1154            .iter()
1155            .map(|ns| ns.as_str().to_owned())
1156            .collect();
1157        let filter = EntityFilter {
1158            kinds: match kind {
1159                Some(k) => vec![k.to_string()],
1160                None => vec![],
1161            },
1162            tags_any: match domain_tag {
1163                Some(t) if !t.is_empty() => vec![t.to_string()],
1164                _ => vec![],
1165            },
1166            namespaces: ns_strs,
1167            ..Default::default()
1168        };
1169        let page = self
1170            .entities(token)?
1171            .query_entities(
1172                token.namespace().as_str(),
1173                filter,
1174                PageRequest {
1175                    offset: offset.into(),
1176                    limit,
1177                },
1178            )
1179            .await?;
1180        Ok(page.items)
1181    }
1182
1183    /// Count entities filtered by kind and optional domain tag.
1184    ///
1185    /// Used to report a meaningful `total` alongside a paginated listing (K-6).
1186    /// Multi-namespace visibility from the token is applied.
1187    pub async fn count_entities_tagged(
1188        &self,
1189        token: &NamespaceToken,
1190        kind: Option<&str>,
1191        domain_tag: Option<&str>,
1192    ) -> RuntimeResult<u64> {
1193        let ns_strs: Vec<String> = token
1194            .visible_namespaces()
1195            .iter()
1196            .map(|ns| ns.as_str().to_owned())
1197            .collect();
1198        let filter = EntityFilter {
1199            kinds: match kind {
1200                Some(k) => vec![k.to_string()],
1201                None => vec![],
1202            },
1203            tags_any: match domain_tag {
1204                Some(t) if !t.is_empty() => vec![t.to_string()],
1205                _ => vec![],
1206            },
1207            namespaces: ns_strs,
1208            ..Default::default()
1209        };
1210        Ok(self
1211            .entities(token)?
1212            .count_entities(token.namespace().as_str(), filter)
1213            .await?)
1214    }
1215
1216    /// List events in the namespace proven by the caller token.
1217    pub async fn list_events(
1218        &self,
1219        token: &NamespaceToken,
1220        filter: EventFilter,
1221        page: PageRequest,
1222    ) -> RuntimeResult<Page<Event>> {
1223        self.events(token)?
1224            .query_events(filter, page)
1225            .await
1226            .map_err(Into::into)
1227    }
1228
1229    // ---- Edge operations ----
1230
1231    /// Validate that `source_id` and `target_id` are legal endpoints for `relation`.
1232    ///
1233    /// Centralises the three-case relation contract so that both
1234    /// `link()` and `update_edge()` share identical enforcement:
1235    ///
1236    /// - `annotates`: source MUST be a note; target may be any substrate.
1237    /// - `supersedes` / `supports` / `refutes`: same-substrate only (note→note or entity→entity).
1238    /// - All other 13 relations: both endpoints MUST be entities.
1239    ///
1240    /// Returns `Ok(())` when valid; otherwise `InvalidInput` or `NotFound` with
1241    /// the same messages as the previous inline block (byte-identical behaviour).
1242    ///
1243    /// `pub(crate)`: the atomic prepare pass (`crate::atomic_prepare`) reuses
1244    /// this exact endpoint-type validation during its async prepare step,
1245    /// before building a `LinkPlan`, rather than re-deriving the checks.
1246    pub(crate) async fn validate_edge_relation_endpoints(
1247        &self,
1248        token: &NamespaceToken,
1249        source_id: Uuid,
1250        target_id: Uuid,
1251        relation: EdgeRelation,
1252    ) -> RuntimeResult<()> {
1253        if source_id == target_id {
1254            return Err(RuntimeError::InvalidInput(
1255                "self-loop edges are not allowed: source_id and target_id must be different".into(),
1256            ));
1257        }
1258        if relation == EdgeRelation::Annotates {
1259            // Source must be a note. By-ID endpoint resolution is namespace-agnostic:
1260            // link consumes two by-ID endpoints, so it must resolve exactly what
1261            // get() resolves, regardless of caller namespace.
1262            match self.resolve_edge_endpoint(token, source_id).await? {
1263                Some(Resolved::Note(_)) => {}
1264                Some(_) => {
1265                    return Err(RuntimeError::InvalidInput(format!(
1266                        "annotates source {source_id} must be a note"
1267                    )));
1268                }
1269                None => {
1270                    // Existing edge used as annotates source: wrong kind, not absent.
1271                    if self.get_edge(token, source_id).await?.is_some() {
1272                        return Err(RuntimeError::InvalidInput(format!(
1273                            "annotates source {source_id} must be a note"
1274                        )));
1275                    }
1276                    return Err(RuntimeError::NotFound(format!(
1277                        "link source {source_id} not found"
1278                    )));
1279                }
1280            }
1281            // Target may be any substrate (entity, note, event, or edge) — by-ID, unfiltered.
1282            if !self.substrate_exists_by_id(token, target_id).await? {
1283                return Err(RuntimeError::NotFound(format!(
1284                    "link target {target_id} not found"
1285                )));
1286            }
1287        } else if matches!(
1288            relation,
1289            EdgeRelation::Supersedes | EdgeRelation::Supports | EdgeRelation::Refutes
1290        ) {
1291            // supersedes / supports / refutes: same-substrate only (note→note or entity→entity).
1292            // Event and edge endpoints are invalid regardless of the other endpoint.
1293            // Endpoint resolution is by-ID and namespace-agnostic.
1294            let rel_name = relation.as_str();
1295            let src = match self.resolve_edge_endpoint(token, source_id).await? {
1296                Some(r) => r,
1297                None => {
1298                    if self.get_edge(token, source_id).await?.is_some() {
1299                        return Err(RuntimeError::InvalidInput(format!(
1300                            "{rel_name} source {source_id} must be a note or entity (got edge)"
1301                        )));
1302                    }
1303                    return Err(RuntimeError::NotFound(format!(
1304                        "link source {source_id} not found"
1305                    )));
1306                }
1307            };
1308            let tgt = match self.resolve_edge_endpoint(token, target_id).await? {
1309                Some(r) => r,
1310                None => {
1311                    if self.get_edge(token, target_id).await?.is_some() {
1312                        return Err(RuntimeError::InvalidInput(format!(
1313                            "{rel_name} target {target_id} must be a note or entity (got edge)"
1314                        )));
1315                    }
1316                    return Err(RuntimeError::NotFound(format!(
1317                        "link target {target_id} not found"
1318                    )));
1319                }
1320            };
1321            match (&src, &tgt) {
1322                (Resolved::Entity(src_e), Resolved::Entity(tgt_e)) => {
1323                    if !base_entity_rule_allows(&src_e.kind, relation, &tgt_e.kind) {
1324                        let rule_hint = match relation {
1325                            EdgeRelation::Supports | EdgeRelation::Refutes => {
1326                                "requires concept|document|dataset|artifact -> concept \
1327                                 (or same-substrate note -> note)"
1328                            }
1329                            _ => "requires same-kind entity endpoints",
1330                        };
1331                        return Err(RuntimeError::InvalidInput(format!(
1332                            "({}) -[{rel_name}]-> ({}) is not in the base endpoint \
1333                             allowlist; {rel_name} {rule_hint}",
1334                            src_e.kind, tgt_e.kind
1335                        )));
1336                    }
1337                }
1338                (Resolved::Note(_), Resolved::Note(_)) => {}
1339                (Resolved::Event(_), _) => {
1340                    return Err(RuntimeError::InvalidInput(format!(
1341                        "{rel_name} does not apply to events; source {source_id} is an event"
1342                    )));
1343                }
1344                (_, Resolved::Event(_)) => {
1345                    return Err(RuntimeError::InvalidInput(format!(
1346                        "{rel_name} does not apply to events; target {target_id} is an event"
1347                    )));
1348                }
1349                (Resolved::Entity(_), Resolved::Note(_)) => {
1350                    return Err(RuntimeError::InvalidInput(format!(
1351                        "{rel_name} endpoints must be the same substrate (note→note or entity→entity); \
1352                         got source={source_id} (entity) target={target_id} (note)"
1353                    )));
1354                }
1355                (Resolved::Note(_), Resolved::Entity(_)) => {
1356                    return Err(RuntimeError::InvalidInput(format!(
1357                        "{rel_name} endpoints must be the same substrate (note→note or entity→entity); \
1358                         got source={source_id} (note) target={target_id} (entity)"
1359                    )));
1360                }
1361                (Resolved::PackRecord { .. }, _) | (_, Resolved::PackRecord { .. }) => {
1362                    return Err(RuntimeError::InvalidInput(format!(
1363                        "pack-private record is not a valid edge endpoint for {rel_name}"
1364                    )));
1365                }
1366            }
1367        } else {
1368            // All remaining base relations require entity→entity with kind-level
1369            // restrictions (see base allowlist). Packs may extend the allowlist
1370            // additively via EDGE_RULES.
1371            //
1372            // Strategy: resolve both endpoints once (by-ID, unfiltered), consult pack
1373            // rules; on miss, fall through to the original base-rule error messages.
1374            let src_res = self.resolve_edge_endpoint(token, source_id).await?;
1375            let tgt_res = self.resolve_edge_endpoint(token, target_id).await?;
1376
1377            if pack_rule_allows(
1378                &self.pack_edge_rules(),
1379                relation,
1380                src_res.as_ref(),
1381                tgt_res.as_ref(),
1382            ) {
1383                return Ok(());
1384            }
1385
1386            // Substrate check: both endpoints must be entities.
1387            let src_kind = match src_res {
1388                Some(Resolved::Entity(e)) => e.kind,
1389                Some(_) => {
1390                    return Err(RuntimeError::InvalidInput(format!(
1391                        "link source {source_id} must be an entity for relation {relation:?} \
1392                         (only `annotates` crosses substrates)"
1393                    )));
1394                }
1395                None => {
1396                    if self.get_edge(token, source_id).await?.is_some() {
1397                        return Err(RuntimeError::InvalidInput(format!(
1398                            "link source {source_id} must be an entity for relation {relation:?} \
1399                             (only `annotates` crosses substrates)"
1400                        )));
1401                    }
1402                    return Err(RuntimeError::NotFound(format!(
1403                        "link source {source_id} not found"
1404                    )));
1405                }
1406            };
1407            let tgt_kind = match tgt_res {
1408                Some(Resolved::Entity(e)) => e.kind,
1409                Some(_) => {
1410                    return Err(RuntimeError::InvalidInput(format!(
1411                        "link target {target_id} must be an entity for relation {relation:?} \
1412                         (only `annotates` crosses substrates)"
1413                    )));
1414                }
1415                None => {
1416                    if self.get_edge(token, target_id).await?.is_some() {
1417                        return Err(RuntimeError::InvalidInput(format!(
1418                            "link target {target_id} must be an entity for relation {relation:?} \
1419                             (only `annotates` crosses substrates)"
1420                        )));
1421                    }
1422                    return Err(RuntimeError::NotFound(format!(
1423                        "link target {target_id} not found"
1424                    )));
1425                }
1426            };
1427            if !base_entity_rule_allows(&src_kind, relation, &tgt_kind) {
1428                return Err(RuntimeError::InvalidInput(format!(
1429                    "({src_kind}) -[{}]-> ({tgt_kind}) is not in the base endpoint \
1430                     allowlist; use pack EDGE_RULES to extend the allowlist",
1431                    relation.as_str()
1432                )));
1433            }
1434        }
1435        Ok(())
1436    }
1437
1438    /// Public delegator for cross-backend link validation.
1439    ///
1440    /// Exposes `validate_edge_relation_endpoints` for the `SubstrateCoordinator`
1441    /// so it can validate the relation before writing the edge on the source backend.
1442    pub async fn validate_link_endpoints(
1443        &self,
1444        token: &NamespaceToken,
1445        source_id: Uuid,
1446        target_id: Uuid,
1447        relation: EdgeRelation,
1448    ) -> RuntimeResult<()> {
1449        self.validate_edge_relation_endpoints(token, source_id, target_id, relation)
1450            .await
1451    }
1452
1453    /// Validate an edge relation using pre-fetched endpoint records.
1454    ///
1455    /// For cross-backend links the source and target live on different backends —
1456    /// the source runtime cannot resolve the target. The coordinator fetches each
1457    /// endpoint from its own backend, then calls this method to enforce the
1458    /// kind-pairing rules without a second DB round-trip.
1459    ///
1460    /// `src` and `tgt` are the `resolve_edge_endpoint` results from each backend. The
1461    /// `token` supplies the pack edge rules installed on this (source) runtime;
1462    /// no DB access is performed.
1463    pub fn validate_link_endpoints_by_resolved(
1464        &self,
1465        source_id: Uuid,
1466        target_id: Uuid,
1467        relation: EdgeRelation,
1468        src: Option<&Resolved>,
1469        tgt: Option<&Resolved>,
1470    ) -> RuntimeResult<()> {
1471        if source_id == target_id {
1472            return Err(RuntimeError::InvalidInput(
1473                "self-loop edges are not allowed: source_id and target_id must be different".into(),
1474            ));
1475        }
1476
1477        if relation == EdgeRelation::Annotates {
1478            match src {
1479                Some(Resolved::Note(_)) => {}
1480                Some(_) => {
1481                    return Err(RuntimeError::InvalidInput(format!(
1482                        "annotates source {source_id} must be a note"
1483                    )));
1484                }
1485                None => {
1486                    return Err(RuntimeError::NotFound(format!(
1487                        "link source {source_id} not found"
1488                    )));
1489                }
1490            }
1491            if tgt.is_none() {
1492                return Err(RuntimeError::NotFound(format!(
1493                    "link target {target_id} not found"
1494                )));
1495            }
1496            return Ok(());
1497        }
1498
1499        if matches!(
1500            relation,
1501            EdgeRelation::Supersedes | EdgeRelation::Supports | EdgeRelation::Refutes
1502        ) {
1503            let rel_name = relation.as_str();
1504            let src = src.ok_or_else(|| {
1505                RuntimeError::NotFound(format!("link source {source_id} not found"))
1506            })?;
1507            let tgt = tgt.ok_or_else(|| {
1508                RuntimeError::NotFound(format!("link target {target_id} not found"))
1509            })?;
1510            match (src, tgt) {
1511                (Resolved::Entity(src_e), Resolved::Entity(tgt_e)) => {
1512                    if !base_entity_rule_allows(&src_e.kind, relation, &tgt_e.kind) {
1513                        let rule_hint = match relation {
1514                            EdgeRelation::Supports | EdgeRelation::Refutes => {
1515                                "requires concept|document|dataset|artifact -> concept \
1516                                 (or same-substrate note -> note)"
1517                            }
1518                            _ => "requires same-kind entity endpoints",
1519                        };
1520                        return Err(RuntimeError::InvalidInput(format!(
1521                            "({}) -[{rel_name}]-> ({}) is not in the base endpoint \
1522                             allowlist; {rel_name} {rule_hint}",
1523                            src_e.kind, tgt_e.kind
1524                        )));
1525                    }
1526                }
1527                (Resolved::Note(_), Resolved::Note(_)) => {}
1528                (Resolved::Entity(_), Resolved::Note(_)) => {
1529                    return Err(RuntimeError::InvalidInput(format!(
1530                        "{rel_name} endpoints must be the same substrate \
1531                         (note→note or entity→entity); got source={source_id} (entity) \
1532                         target={target_id} (note)"
1533                    )));
1534                }
1535                (Resolved::Note(_), Resolved::Entity(_)) => {
1536                    return Err(RuntimeError::InvalidInput(format!(
1537                        "{rel_name} endpoints must be the same substrate \
1538                         (note→note or entity→entity); got source={source_id} (note) \
1539                         target={target_id} (entity)"
1540                    )));
1541                }
1542                (Resolved::PackRecord { .. }, _) | (_, Resolved::PackRecord { .. }) => {
1543                    return Err(RuntimeError::InvalidInput(format!(
1544                        "pack-private record is not a valid edge endpoint for {rel_name}"
1545                    )));
1546                }
1547                _ => {
1548                    return Err(RuntimeError::InvalidInput(format!(
1549                        "{rel_name} endpoints must be notes or entities (not events)"
1550                    )));
1551                }
1552            }
1553            return Ok(());
1554        }
1555
1556        // All remaining base relations: entity→entity with kind-level restrictions.
1557        // Consult pack rules installed on this (source) runtime first.
1558        if pack_rule_allows(&self.pack_edge_rules(), relation, src, tgt) {
1559            return Ok(());
1560        }
1561
1562        let src_kind = match src {
1563            Some(Resolved::Entity(e)) => &e.kind,
1564            Some(_) => {
1565                return Err(RuntimeError::InvalidInput(format!(
1566                    "link source {source_id} must be an entity for relation {relation:?} \
1567                     (only `annotates` crosses substrates)"
1568                )));
1569            }
1570            None => {
1571                return Err(RuntimeError::NotFound(format!(
1572                    "link source {source_id} not found"
1573                )));
1574            }
1575        };
1576        let tgt_kind = match tgt {
1577            Some(Resolved::Entity(e)) => &e.kind,
1578            Some(_) => {
1579                return Err(RuntimeError::InvalidInput(format!(
1580                    "link target {target_id} must be an entity for relation {relation:?} \
1581                     (only `annotates` crosses substrates)"
1582                )));
1583            }
1584            None => {
1585                return Err(RuntimeError::NotFound(format!(
1586                    "link target {target_id} not found"
1587                )));
1588            }
1589        };
1590
1591        if !base_entity_rule_allows(src_kind, relation, tgt_kind) {
1592            return Err(RuntimeError::InvalidInput(format!(
1593                "({src_kind}) -[{}]-> ({tgt_kind}) is not in the base endpoint \
1594                 allowlist; use pack EDGE_RULES to extend the allowlist",
1595                relation.as_str()
1596            )));
1597        }
1598
1599        Ok(())
1600    }
1601
1602    /// Validate an `annotates` edge relation using pre-located endpoint kinds.
1603    ///
1604    /// Sibling of [`Self::validate_link_endpoints_by_resolved`] for callers that
1605    /// only have an [`EdgeEndpointKind`] (entity/note/event/edge) rather than a
1606    /// full [`Resolved`] record — the `SubstrateCoordinator`'s cross-backend
1607    /// `locate_endpoint` resolves edge-substrate UUIDs too (matching `get`'s
1608    /// by-ID resolution order), but edges have no `Resolved` variant, so
1609    /// `validate_link_endpoints_by_resolved` cannot express them.
1610    ///
1611    /// `annotates` is the only relation this covers: source must be a note,
1612    /// target may be any substrate (entity, note, event, or edge).
1613    pub fn validate_annotates_endpoint_kinds(
1614        &self,
1615        source_id: Uuid,
1616        target_id: Uuid,
1617        source: Option<EdgeEndpointKind>,
1618        target: Option<EdgeEndpointKind>,
1619    ) -> RuntimeResult<()> {
1620        if source_id == target_id {
1621            return Err(RuntimeError::InvalidInput(
1622                "self-loop edges are not allowed: source_id and target_id must be different".into(),
1623            ));
1624        }
1625        match source {
1626            Some(EdgeEndpointKind::Note) => {}
1627            Some(_) => {
1628                return Err(RuntimeError::InvalidInput(format!(
1629                    "annotates source {source_id} must be a note"
1630                )));
1631            }
1632            None => {
1633                return Err(RuntimeError::NotFound(format!(
1634                    "link source {source_id} not found"
1635                )));
1636            }
1637        }
1638        if target.is_none() {
1639            return Err(RuntimeError::NotFound(format!(
1640                "link target {target_id} not found"
1641            )));
1642        }
1643        Ok(())
1644    }
1645
1646    /// Create a directed edge between two substrates.
1647    ///
1648    /// Enforces the three-case relation contract via
1649    /// `validate_edge_relation_endpoints`. See that method for the full contract.
1650    ///
1651    /// For symmetric relations (`competes_with`, `composed_with`) the endpoint
1652    /// pair is canonicalised to `source_uuid < target_uuid` so that A→B and B→A
1653    /// deduplicate to one row.
1654    ///
1655    /// `metadata` is validated against governed keys; `dependency_kind` is
1656    /// inferred for `depends_on` edges when absent.
1657    ///
1658    /// `target_backend` is always `None` for locally-routed edges written through
1659    /// this path. Both endpoints must exist in the local namespace, so setting
1660    /// `target_backend = None` is the only valid choice.
1661    ///
1662    /// Endpoint existence is a by-ID check and namespace-agnostic: a record
1663    /// that exists in a different namespace than the caller still resolves,
1664    /// exactly as `get()` would.
1665    pub async fn link(
1666        &self,
1667        token: &NamespaceToken,
1668        source_id: Uuid,
1669        target_id: Uuid,
1670        relation: EdgeRelation,
1671        weight: f64,
1672        metadata: Option<serde_json::Value>,
1673    ) -> RuntimeResult<Edge> {
1674        validate_edge_weight(weight)?;
1675        self.validate_edge_relation_endpoints(token, source_id, target_id, relation)
1676            .await?;
1677        let (source_id, target_id) = canonical_edge_endpoints(relation, source_id, target_id);
1678        let metadata = if relation == EdgeRelation::DependsOn {
1679            // By-ID, unfiltered — matches the namespace-agnostic endpoint validation
1680            // above. The visible-set-scoped `resolve` would silently drop the
1681            // dependency_kind inference for endpoints validation now allows outside
1682            // the caller's visible set.
1683            match (
1684                self.resolve_edge_endpoint(token, source_id).await?,
1685                self.resolve_edge_endpoint(token, target_id).await?,
1686            ) {
1687                (Some(Resolved::Entity(src_e)), Some(Resolved::Entity(tgt_e))) => {
1688                    merge_dependency_kind(&src_e.kind, &tgt_e.kind, metadata)
1689                }
1690                _ => metadata,
1691            }
1692        } else {
1693            metadata
1694        };
1695        validate_edge_metadata(relation, metadata.as_ref())?;
1696        let now = chrono::Utc::now();
1697        let ns = token.namespace().as_str();
1698        let edge = Edge {
1699            id: LinkId::from(Uuid::new_v4()),
1700            namespace: ns.to_string(),
1701            source_id,
1702            target_id,
1703            relation,
1704            weight,
1705            created_at: now,
1706            updated_at: now,
1707            deleted_at: None,
1708            metadata,
1709            target_backend: None,
1710        };
1711        // `upsert_edge_guarded` re-checks both endpoints exist as part of the same
1712        // write, not the separate `validate_edge_relation_endpoints` read above: a
1713        // concurrent hard-delete landing between that read and this write can no
1714        // longer create a durably dangling edge. Which endpoint(s) were missing is
1715        // reported by the guard's own in-transaction probe (`GuardedWriteOutcome::
1716        // Refused`), not reconstructed here by re-reading the endpoints after the
1717        // fact: a second concurrent write landing between the refusal and a
1718        // post-hoc read could otherwise misreport which endpoint was actually
1719        // missing at write time.
1720        match self.graph(token)?.upsert_edge_guarded(edge).await? {
1721            khive_storage::GuardedWriteOutcome::Written => {}
1722            khive_storage::GuardedWriteOutcome::Refused(missing) => {
1723                return Err(RuntimeError::GuardedWriteFailed(GuardedWriteFailure {
1724                    entry_index: None,
1725                    missing_source: missing.source.then_some(source_id),
1726                    missing_target: missing.target.then_some(target_id),
1727                }));
1728            }
1729        }
1730
1731        // Read back the persisted row by natural key so the returned
1732        // edge ID is always the one stored in the database, not the locally
1733        // generated UUID that was displaced by an ON CONFLICT DO UPDATE.
1734        // Under parallel calls for the same triple, every caller now returns
1735        // the same persisted edge ID — the winner's insert or the updated row.
1736        let persisted = self
1737            .list_edges(
1738                token,
1739                crate::curation::EdgeListFilter {
1740                    source_id: Some(source_id),
1741                    target_id: Some(target_id),
1742                    relations: vec![relation],
1743                    ..Default::default()
1744                },
1745                1,
1746                0,
1747            )
1748            .await?
1749            .into_iter()
1750            .next()
1751            .ok_or_else(|| {
1752                crate::RuntimeError::Internal(format!(
1753                    "upsert_edge succeeded but natural-key lookup for ({source_id}, {target_id}, {relation}) returned nothing"
1754                ))
1755            })?;
1756        Ok(persisted)
1757    }
1758
1759    /// Write an edge with an explicit `target_backend` stamp (ADR-029 D3).
1760    ///
1761    /// Called by the `SubstrateCoordinator` when source and target are on
1762    /// different backends. The coordinator validates endpoints before calling
1763    /// this method via [`Self::validate_link_endpoints`], so endpoint validation is
1764    /// skipped here. The edge is written on the source backend only.
1765    #[allow(clippy::too_many_arguments)]
1766    pub async fn link_with_target_backend(
1767        &self,
1768        token: &NamespaceToken,
1769        source_id: Uuid,
1770        target_id: Uuid,
1771        relation: EdgeRelation,
1772        weight: f64,
1773        metadata: Option<serde_json::Value>,
1774        target_backend: Option<String>,
1775    ) -> RuntimeResult<Edge> {
1776        validate_edge_weight(weight)?;
1777        let (source_id, target_id) = canonical_edge_endpoints(relation, source_id, target_id);
1778        validate_edge_metadata(relation, metadata.as_ref())?;
1779        let now = chrono::Utc::now();
1780        let ns = token.namespace().as_str();
1781        let edge = Edge {
1782            id: LinkId::from(Uuid::new_v4()),
1783            namespace: ns.to_string(),
1784            source_id,
1785            target_id,
1786            relation,
1787            weight,
1788            created_at: now,
1789            updated_at: now,
1790            deleted_at: None,
1791            metadata,
1792            target_backend,
1793        };
1794        self.graph(token)?.upsert_edge(edge).await?;
1795        let persisted = self
1796            .list_edges(
1797                token,
1798                crate::curation::EdgeListFilter {
1799                    source_id: Some(source_id),
1800                    target_id: Some(target_id),
1801                    relations: vec![relation],
1802                    ..Default::default()
1803                },
1804                1,
1805                0,
1806            )
1807            .await?
1808            .into_iter()
1809            .next()
1810            .ok_or_else(|| {
1811                crate::RuntimeError::Internal(format!(
1812                    "upsert_edge succeeded but natural-key lookup for ({source_id}, {target_id}, {relation}) returned nothing"
1813                ))
1814            })?;
1815        Ok(persisted)
1816    }
1817
1818    /// Returns `true` if `id` resolves to a live substrate record in the
1819    /// caller's visible namespace set.
1820    ///
1821    /// Covers entity, note, event (via `resolve`) and edge (via `get_edge_visible`).
1822    /// Only records that are accessible to the caller (primary or configured visible
1823    /// namespaces) return `true`; absent or foreign-invisible records return `false`.
1824    pub(crate) async fn substrate_exists_in_ns(
1825        &self,
1826        token: &NamespaceToken,
1827        id: Uuid,
1828    ) -> RuntimeResult<bool> {
1829        if self.resolve(token, id).await?.is_some() {
1830            return Ok(true);
1831        }
1832        match self.get_edge_visible(token, id).await {
1833            Ok(Some(_)) => Ok(true),
1834            Ok(None) | Err(RuntimeError::NotFound(_)) => Ok(false),
1835            Err(err) => Err(err),
1836        }
1837    }
1838
1839    /// Returns `true` if `id` resolves to a live substrate record, by ID, with
1840    /// no namespace filter.
1841    ///
1842    /// Used from `annotates` endpoint validation (`link` and `create`'s
1843    /// `annotates` targets), which consume a by-ID endpoint and so must follow
1844    /// the same namespace-agnostic by-ID contract as `get()`.
1845    pub(crate) async fn substrate_exists_by_id(
1846        &self,
1847        token: &NamespaceToken,
1848        id: Uuid,
1849    ) -> RuntimeResult<bool> {
1850        if self.resolve_edge_endpoint(token, id).await?.is_some() {
1851            return Ok(true);
1852        }
1853        match self.get_edge(token, id).await {
1854            Ok(Some(_)) => Ok(true),
1855            Ok(None) | Err(RuntimeError::NotFound(_)) => Ok(false),
1856            Err(err) => Err(err),
1857        }
1858    }
1859
1860    /// Get immediate neighbors of a node, optionally filtered by relation type.
1861    ///
1862    /// Pass `relations: Some(vec![EdgeRelation::Annotates])` to retrieve only
1863    /// annotation edges, enabling cross-substrate navigation.
1864    ///
1865    /// Symmetric relations (`competes_with`, `composed_with`) are stored
1866    /// with the canonical source as the lower UUID. Direction normalization is
1867    /// applied in `neighbors_with_query` so both callers see correct results.
1868    pub async fn neighbors(
1869        &self,
1870        token: &NamespaceToken,
1871        node_id: Uuid,
1872        direction: Direction,
1873        limit: Option<u32>,
1874        relations: Option<Vec<EdgeRelation>>,
1875    ) -> RuntimeResult<Vec<NeighborHit>> {
1876        self.neighbors_with_query(
1877            token,
1878            node_id,
1879            NeighborQuery {
1880                direction,
1881                relations,
1882                limit,
1883                min_weight: None,
1884            },
1885        )
1886        .await
1887    }
1888
1889    /// Get neighbors with full query control (includes `min_weight`).
1890    ///
1891    /// Applies symmetric-relation direction normalization: if the
1892    /// relations filter contains only symmetric relations the direction is
1893    /// overridden to `Both` so edges stored in canonical order are always found.
1894    ///
1895    /// Soft-deleted entity nodes are excluded from results unless the caller
1896    /// explicitly requested them (future: `include_deleted` flag; currently
1897    /// always false).
1898    pub async fn neighbors_with_query(
1899        &self,
1900        token: &NamespaceToken,
1901        node_id: Uuid,
1902        mut query: NeighborQuery,
1903    ) -> RuntimeResult<Vec<NeighborHit>> {
1904        if !self.substrate_exists_in_ns(token, node_id).await? {
1905            return Ok(Vec::new());
1906        }
1907
1908        query.direction =
1909            normalize_symmetric_direction(query.direction, query.relations.as_deref());
1910        let mut hits = Vec::new();
1911        for ns in token.visible_namespaces() {
1912            let temp = NamespaceToken::for_namespace(ns.clone());
1913            let mut ns_hits = self.graph(&temp)?.neighbors(node_id, query.clone()).await?;
1914            hits.append(&mut ns_hits);
1915        }
1916        hits.sort_by_key(|h| (h.node_id, h.edge_id));
1917        hits.dedup_by_key(|h| (h.node_id, h.edge_id));
1918        self.enrich_neighbor_hits(token, &mut hits).await;
1919        // Filter out soft-deleted entity nodes.
1920        let candidate_ids: Vec<Uuid> = hits.iter().map(|h| h.node_id).collect();
1921        let deleted = self.deleted_entity_ids(candidate_ids).await;
1922        if !deleted.is_empty() {
1923            hits.retain(|h| !deleted.contains(&h.node_id));
1924        }
1925        // Restore the weight-descending, node_id-ascending order the storage
1926        // layer established (khive-db graph.rs `ORDER BY weight DESC, node_id
1927        // ASC`) — the (node_id, edge_id) sort above exists only to make
1928        // `dedup_by_key` adjacent-comparable and otherwise discards it. This
1929        // ordering contract must hold at every call site of this op (context
1930        // and neighbors verb alike), for every direction.
1931        hits.sort_by(|a, b| {
1932            b.weight
1933                .partial_cmp(&a.weight)
1934                .unwrap_or(std::cmp::Ordering::Equal)
1935                .then(a.node_id.cmp(&b.node_id))
1936        });
1937        Ok(hits)
1938    }
1939
1940    /// Find live `annotates` edges targeting one record without applying a
1941    /// namespace predicate.
1942    ///
1943    /// This is the graph counterpart to the namespace-agnostic by-ID `get`
1944    /// contract (ADR-007 Rev 6). Multi-record neighbor traversal remains
1945    /// visibility-scoped; callers should use this only after resolving a live
1946    /// target through a by-ID operation.
1947    pub async fn annotation_neighbors_by_target_id(
1948        &self,
1949        target_id: Uuid,
1950    ) -> RuntimeResult<Vec<NeighborHit>> {
1951        let mut reader = self.sql().reader().await?;
1952        let rows = reader
1953            .query_all(SqlStatement {
1954                sql: "SELECT source_id, id, weight FROM graph_edges \
1955                      WHERE target_id = ?1 AND relation = ?2 AND deleted_at IS NULL \
1956                      ORDER BY weight DESC, source_id ASC"
1957                    .to_string(),
1958                params: vec![
1959                    SqlValue::Text(target_id.to_string()),
1960                    SqlValue::Text(EdgeRelation::Annotates.to_string()),
1961                ],
1962                label: Some("annotations.by_target_id_unfiltered".into()),
1963            })
1964            .await?;
1965
1966        rows.into_iter()
1967            .map(|row| {
1968                let parse_uuid = |name: &str| match row.get(name) {
1969                    Some(SqlValue::Text(value)) => Uuid::from_str(value).map_err(|error| {
1970                        RuntimeError::Internal(format!("graph_edges.{name} is not a UUID: {error}"))
1971                    }),
1972                    Some(value) => Err(RuntimeError::Internal(format!(
1973                        "graph_edges.{name} has unexpected SQL value {value:?}"
1974                    ))),
1975                    None => Err(RuntimeError::Internal(format!(
1976                        "graph_edges row missing {name}"
1977                    ))),
1978                };
1979                let weight = match row.get("weight") {
1980                    Some(SqlValue::Float(value)) => Ok(*value),
1981                    Some(value) => Err(RuntimeError::Internal(format!(
1982                        "graph_edges.weight has unexpected SQL value {value:?}"
1983                    ))),
1984                    None => Err(RuntimeError::Internal(
1985                        "graph_edges row missing weight".into(),
1986                    )),
1987                }?;
1988
1989                Ok(NeighborHit {
1990                    node_id: parse_uuid("source_id")?,
1991                    edge_id: parse_uuid("id")?,
1992                    relation: EdgeRelation::Annotates,
1993                    weight,
1994                    name: None,
1995                    kind: None,
1996                    entity_type: None,
1997                })
1998            })
1999            .collect()
2000    }
2001
2002    /// Get both-direction neighbors, each tagged with the direction (`Out`/
2003    /// `In`) it was found in, via a single storage query per visible
2004    /// namespace instead of two separate direction-scoped `neighbors_with_query`
2005    /// calls: halving the neighbor SELECT count for `context(direction="both")`
2006    /// expansion. `query.direction` is ignored: always both.
2007    ///
2008    /// Mirrors `neighbors_with_query`'s dedup/enrich/soft-delete-filter/order
2009    /// pipeline exactly, carrying the per-hit direction tag through unchanged.
2010    pub async fn neighbors_with_query_directed(
2011        &self,
2012        token: &NamespaceToken,
2013        node_id: Uuid,
2014        query: NeighborQuery,
2015    ) -> RuntimeResult<Vec<(NeighborHit, Direction)>> {
2016        if !self.substrate_exists_in_ns(token, node_id).await? {
2017            return Ok(Vec::new());
2018        }
2019
2020        let mut hits: Vec<DirectedNeighborHit> = Vec::new();
2021        for ns in token.visible_namespaces() {
2022            let temp = NamespaceToken::for_namespace(ns.clone());
2023            let mut ns_hits = self
2024                .graph(&temp)?
2025                .neighbors_both_directions(node_id, query.clone())
2026                .await?;
2027            hits.append(&mut ns_hits);
2028        }
2029        // Direction is part of the key (not just node_id/edge_id) so a
2030        // self-loop's Out row and In row — same node_id and edge_id, opposite
2031        // direction: sort adjacent but distinct and both survive dedup.
2032        hits.sort_by_key(|h| {
2033            (
2034                h.hit.node_id,
2035                h.hit.edge_id,
2036                direction_sort_rank(&h.direction),
2037            )
2038        });
2039        hits.dedup_by_key(|h| {
2040            (
2041                h.hit.node_id,
2042                h.hit.edge_id,
2043                direction_sort_rank(&h.direction),
2044            )
2045        });
2046
2047        let mut plain_hits: Vec<NeighborHit> = hits.iter().map(|h| h.hit.clone()).collect();
2048        self.enrich_neighbor_hits(token, &mut plain_hits).await;
2049        for (dh, enriched) in hits.iter_mut().zip(plain_hits) {
2050            dh.hit = enriched;
2051        }
2052
2053        // Filter out soft-deleted entity nodes.
2054        let candidate_ids: Vec<Uuid> = hits.iter().map(|h| h.hit.node_id).collect();
2055        let deleted = self.deleted_entity_ids(candidate_ids).await;
2056        if !deleted.is_empty() {
2057            hits.retain(|h| !deleted.contains(&h.hit.node_id));
2058        }
2059        // Same global weight-descending/node_id-ascending restore as
2060        // `neighbors_with_query`: the (node_id, edge_id, direction) sort above
2061        // exists only to make `dedup_by_key` adjacent-comparable.
2062        hits.sort_by(|a, b| {
2063            b.hit
2064                .weight
2065                .partial_cmp(&a.hit.weight)
2066                .unwrap_or(std::cmp::Ordering::Equal)
2067                .then(a.hit.node_id.cmp(&b.hit.node_id))
2068        });
2069        Ok(hits.into_iter().map(|h| (h.hit, h.direction)).collect())
2070    }
2071
2072    /// Traverse the graph from a set of root nodes.
2073    ///
2074    /// Roots in a foreign namespace are silently filtered before storage expansion.
2075    /// Soft-deleted entity nodes are excluded from results.
2076    pub async fn traverse(
2077        &self,
2078        token: &NamespaceToken,
2079        request: TraversalRequest,
2080    ) -> RuntimeResult<Vec<GraphPath>> {
2081        let mut request = request;
2082        let mut visible_roots = Vec::with_capacity(request.roots.len());
2083        for root in request.roots.drain(..) {
2084            if self.substrate_exists_in_ns(token, root).await? {
2085                visible_roots.push(root);
2086            }
2087        }
2088        request.roots = visible_roots;
2089        if request.roots.is_empty() {
2090            return Ok(Vec::new());
2091        }
2092
2093        let mut paths = Vec::new();
2094        for ns in token.visible_namespaces() {
2095            let temp = NamespaceToken::for_namespace(ns.clone());
2096            let mut ns_paths = self.graph(&temp)?.traverse(request.clone()).await?;
2097            paths.append(&mut ns_paths);
2098        }
2099        self.enrich_path_nodes(token, &mut paths, request.include_properties)
2100            .await;
2101        // Filter out soft-deleted entity nodes from all path nodes.
2102        let all_node_ids: Vec<Uuid> = paths
2103            .iter()
2104            .flat_map(|p| p.nodes.iter().map(|n| n.node_id))
2105            .collect();
2106        let deleted = self.deleted_entity_ids(all_node_ids).await;
2107        if !deleted.is_empty() {
2108            for path in paths.iter_mut() {
2109                path.nodes.retain(|n| !deleted.contains(&n.node_id));
2110            }
2111            paths.retain(|p| !p.nodes.is_empty());
2112        }
2113        Ok(paths)
2114    }
2115
2116    /// Batch-query for soft-deleted UUIDs in `ids`, across BOTH the entities
2117    /// and notes tables.
2118    ///
2119    /// Neighbor/traverse candidates can be note-kind nodes (e.g. reached via
2120    /// `annotates` edges) as well as entities; a screen that only consults
2121    /// `entities` lets soft-deleted note targets leak through and hydrate as
2122    /// blank/missing hits. This is a view-layer read-only screen: it does
2123    /// not touch edges or mutate any data.
2124    ///
2125    /// Returns the subset of `ids` that have `deleted_at IS NOT NULL` in
2126    /// either table. Takes `Vec<Uuid>` (not an iterator) so the async state
2127    /// machine holds only owned data — no iterator borrow across yields.
2128    async fn deleted_entity_ids(&self, ids: Vec<Uuid>) -> std::collections::HashSet<Uuid> {
2129        if ids.is_empty() {
2130            return std::collections::HashSet::new();
2131        }
2132        let id_strs: Vec<String> = ids.iter().map(|u| u.to_string()).collect();
2133        let n = id_strs.len();
2134        // Each UNION half gets its OWN numbered-placeholder block (?1..?n for
2135        // entities, ?(n+1)..?(2n) for notes) — numbered SQLite params bind by
2136        // index, so reusing the same numbers across halves would silently
2137        // collapse to a single shared block instead of binding the full list
2138        // twice (see khive-db/src/stores/graph.rs batch_neighbors: "each half
2139        // is a fully independent positional-parameter block").
2140        let entities_placeholders = (0..n)
2141            .map(|i| format!("?{}", i + 1))
2142            .collect::<Vec<_>>()
2143            .join(",");
2144        let notes_placeholders = (0..n)
2145            .map(|i| format!("?{}", n + i + 1))
2146            .collect::<Vec<_>>()
2147            .join(",");
2148        let sql_str = format!(
2149            "SELECT id FROM entities WHERE id IN ({entities_placeholders}) AND deleted_at IS NOT NULL \
2150             UNION \
2151             SELECT id FROM notes WHERE id IN ({notes_placeholders}) AND deleted_at IS NOT NULL"
2152        );
2153        // Same id list bound twice — once per UNION arm's independent placeholder block.
2154        let params: Vec<SqlValue> = id_strs
2155            .iter()
2156            .chain(id_strs.iter())
2157            .cloned()
2158            .map(SqlValue::Text)
2159            .collect();
2160        let stmt = SqlStatement {
2161            sql: sql_str,
2162            params,
2163            label: Some("deleted_entity_ids".into()),
2164        };
2165        let mut out = std::collections::HashSet::new();
2166        let sql = self.sql();
2167        if let Ok(mut reader) = sql.reader().await {
2168            if let Ok(rows) = reader.query_all(stmt).await {
2169                for row in rows {
2170                    if let Some(col) = row.columns.first() {
2171                        if let SqlValue::Text(s) = &col.value {
2172                            if let Ok(u) = s.parse::<Uuid>() {
2173                                out.insert(u);
2174                            }
2175                        }
2176                    }
2177                }
2178            }
2179            // best-effort: on reader or query error, treat none as deleted
2180        }
2181        out
2182    }
2183
2184    /// Populate `name` and `kind` on each `NeighborHit` from the corresponding
2185    /// entity or note record. Best-effort: unresolved IDs leave the fields `None`.
2186    ///
2187    /// Uses a single batched entity lookup via `get_entities_by_ids_visible`
2188    /// (scoped to the token's full visible-namespace set so that neighbors in
2189    /// extra-visible namespaces are enriched), then a batched note lookup
2190    /// (`get_notes_batch`) for the residual IDs not resolved as entities.
2191    /// Order and identity of hits is preserved via `HashMap` re-index.
2192    async fn enrich_neighbor_hits(&self, token: &NamespaceToken, hits: &mut [NeighborHit]) {
2193        if hits.is_empty() {
2194            return;
2195        }
2196
2197        // Deduplicated IDs for the batch call.
2198        let unique_ids: Vec<Uuid> = {
2199            let mut seen = std::collections::HashSet::new();
2200            hits.iter()
2201                .filter_map(|h| {
2202                    if seen.insert(h.node_id) {
2203                        Some(h.node_id)
2204                    } else {
2205                        None
2206                    }
2207                })
2208                .collect()
2209        };
2210
2211        let entity_map: HashMap<Uuid, Entity> = self
2212            .get_entities_by_ids_visible(token, &unique_ids)
2213            .await
2214            .unwrap_or_default()
2215            .into_iter()
2216            .map(|e| (e.id, e))
2217            .collect();
2218
2219        // Batch note lookup for IDs not found as entities.
2220        let residual_ids: Vec<Uuid> = unique_ids
2221            .iter()
2222            .filter(|id| !entity_map.contains_key(id))
2223            .copied()
2224            .collect();
2225
2226        let note_map: HashMap<Uuid, Note> = if !residual_ids.is_empty() {
2227            if let Ok(store) = self.notes(token) {
2228                store
2229                    .get_notes_batch(&residual_ids)
2230                    .await
2231                    .unwrap_or_default()
2232                    .into_iter()
2233                    .map(|n| (n.id, n))
2234                    .collect()
2235            } else {
2236                HashMap::new()
2237            }
2238        } else {
2239            HashMap::new()
2240        };
2241
2242        for hit in hits.iter_mut() {
2243            if let Some(entity) = entity_map.get(&hit.node_id) {
2244                hit.name = Some(entity.name.clone());
2245                hit.kind = Some(entity.kind.clone());
2246                hit.entity_type = entity.entity_type.clone();
2247            } else if let Some(note) = note_map.get(&hit.node_id) {
2248                let kind = note.kind.clone();
2249                let name = note
2250                    .name
2251                    .as_deref()
2252                    .filter(|s| !s.trim().is_empty())
2253                    .map(|s| s.to_owned())
2254                    .unwrap_or_else(|| format!("[{kind}]"));
2255                hit.name = Some(name);
2256                hit.kind = Some(kind);
2257            }
2258        }
2259    }
2260
2261    /// Populate `name` and `kind` on each `PathNode` from the corresponding
2262    /// entity record. Same best-effort policy as `enrich_neighbor_hits`.
2263    ///
2264    /// Uses `get_entities_by_ids_visible` so that path nodes whose entities
2265    /// live in extra-visible namespaces are enriched correctly. Node IDs that
2266    /// repeat across paths are fetched exactly once.
2267    ///
2268    /// `include_properties` gates whether `entity.properties` is cloned onto
2269    /// each node. When `false` (the default), the potentially large JSON blob
2270    /// is never read from the map, keeping the hot path allocation-free.
2271    async fn enrich_path_nodes(
2272        &self,
2273        token: &NamespaceToken,
2274        paths: &mut [GraphPath],
2275        include_properties: bool,
2276    ) {
2277        if paths.is_empty() {
2278            return;
2279        }
2280
2281        // Deduplicate node IDs across all paths before the batch call.
2282        let unique_ids: Vec<Uuid> = {
2283            let mut seen = std::collections::HashSet::new();
2284            paths
2285                .iter()
2286                .flat_map(|p| p.nodes.iter())
2287                .filter_map(|n| {
2288                    if seen.insert(n.node_id) {
2289                        Some(n.node_id)
2290                    } else {
2291                        None
2292                    }
2293                })
2294                .collect()
2295        };
2296
2297        let entity_map: HashMap<Uuid, Entity> = self
2298            .get_entities_by_ids_visible(token, &unique_ids)
2299            .await
2300            .unwrap_or_default()
2301            .into_iter()
2302            .map(|e| (e.id, e))
2303            .collect();
2304
2305        for path in paths.iter_mut() {
2306            for node in path.nodes.iter_mut() {
2307                if let Some(entity) = entity_map.get(&node.node_id) {
2308                    node.name = Some(entity.name.clone());
2309                    node.kind = Some(entity.kind.clone());
2310                    if include_properties {
2311                        node.properties = entity.properties.clone();
2312                    }
2313                }
2314            }
2315        }
2316    }
2317
2318    // ---- Note operations ----
2319
2320    /// Create and persist a note, optionally with properties and annotation targets.
2321    ///
2322    /// After creating the note:
2323    /// - Always indexes into FTS5 at the `notes_<namespace>` key.
2324    /// - If an embedding model is configured, indexes into the vector store with
2325    ///   `SubstrateKind::Note`.
2326    /// - For each UUID in `annotates`, creates an `EdgeRelation::Annotates` edge from
2327    ///   the note to that target.
2328    // REASON: note creation requires kind, name, content, salience, properties, annotates,
2329    // and namespace token — mirrors the MCP verb surface; a builder would not reduce
2330    // caller complexity for pack handler callers.
2331    #[allow(clippy::too_many_arguments)]
2332    pub async fn create_note(
2333        &self,
2334        token: &NamespaceToken,
2335        kind: &str,
2336        name: Option<&str>,
2337        content: &str,
2338        salience: Option<f64>,
2339        properties: Option<serde_json::Value>,
2340        annotates: Vec<Uuid>,
2341    ) -> RuntimeResult<Note> {
2342        self.create_note_inner(
2343            token, kind, name, content, None, salience, None, properties, annotates, None,
2344        )
2345        .await
2346    }
2347
2348    /// Like [`Self::create_note`], but lets the caller supply a smaller text
2349    /// to send to the vector embedder while the note's stored/FTS-indexed
2350    /// `content` remains the full text.
2351    ///
2352    /// `embedding_content`, when `Some`, must be non-empty and a proper
2353    /// prefix of `content` — anything else is rejected with `InvalidInput`
2354    /// before any write. `None` behaves exactly like [`Self::create_note`].
2355    /// Use this when `content` may exceed an embedder's input cap (e.g. a
2356    /// very long commit message) and only a capped head prefix should be
2357    /// embedded, while the full text is still stored and searchable via FTS.
2358    #[allow(clippy::too_many_arguments)]
2359    pub async fn create_note_with_embedding_content(
2360        &self,
2361        token: &NamespaceToken,
2362        kind: &str,
2363        name: Option<&str>,
2364        content: &str,
2365        embedding_content: Option<&str>,
2366        salience: Option<f64>,
2367        properties: Option<serde_json::Value>,
2368        annotates: Vec<Uuid>,
2369    ) -> RuntimeResult<Note> {
2370        self.create_note_inner(
2371            token,
2372            kind,
2373            name,
2374            content,
2375            embedding_content,
2376            salience,
2377            None,
2378            properties,
2379            annotates,
2380            None,
2381        )
2382        .await
2383    }
2384
2385    /// Like [`Self::create_note`] but also sets a non-zero decay factor on the note.
2386    // REASON: extends create_note with an additional decay_factor parameter; same
2387    // rationale — mirrors the MCP surface and reduces an extra builder layer.
2388    #[allow(clippy::too_many_arguments)]
2389    pub async fn create_note_with_decay(
2390        &self,
2391        token: &NamespaceToken,
2392        kind: &str,
2393        name: Option<&str>,
2394        content: &str,
2395        salience: Option<f64>,
2396        decay_factor: f64,
2397        properties: Option<serde_json::Value>,
2398        annotates: Vec<Uuid>,
2399    ) -> RuntimeResult<Note> {
2400        self.create_note_with_decay_for_embedding_model(
2401            token,
2402            kind,
2403            name,
2404            content,
2405            salience,
2406            decay_factor,
2407            properties,
2408            annotates,
2409            None,
2410        )
2411        .await
2412    }
2413
2414    /// Like [`Self::create_note_with_decay`] but targets a specific embedding model.
2415    // REASON: adds an embedding_model parameter to the decay variant; the full parameter
2416    // set is required for correct MCP verb routing and cannot be collapsed without
2417    // introducing a separate config struct that would obscure call sites.
2418    #[allow(clippy::too_many_arguments)]
2419    pub async fn create_note_with_decay_for_embedding_model(
2420        &self,
2421        token: &NamespaceToken,
2422        kind: &str,
2423        name: Option<&str>,
2424        content: &str,
2425        salience: Option<f64>,
2426        decay_factor: f64,
2427        properties: Option<serde_json::Value>,
2428        annotates: Vec<Uuid>,
2429        embedding_model: Option<&str>,
2430    ) -> RuntimeResult<Note> {
2431        self.create_note_inner(
2432            token,
2433            kind,
2434            name,
2435            content,
2436            None,
2437            salience,
2438            Some(decay_factor),
2439            properties,
2440            annotates,
2441            embedding_model,
2442        )
2443        .await
2444    }
2445
2446    /// Insert a note using `INSERT OR IGNORE` semantics for atomic deduplication.
2447    ///
2448    /// Returns `Ok(Some(note))` when the note was newly written.  Returns
2449    /// `Ok(None)` when a unique constraint (e.g. the `external_id` partial
2450    /// index on comm message notes) was already satisfied by an existing row,
2451    /// making this call a no-op.  FTS indexing and vector embedding are
2452    /// attempted on success but treated as best-effort: failures are logged
2453    /// and do not abort the write.
2454    ///
2455    /// This method is intentionally narrower than `create_note`: it skips
2456    /// salience/decay, annotates edges, and embedding-model selection, which
2457    /// are not needed for channel-ingest paths.
2458    pub async fn try_create_note(
2459        &self,
2460        token: &NamespaceToken,
2461        kind: &str,
2462        name: Option<&str>,
2463        content: &str,
2464        properties: Option<serde_json::Value>,
2465    ) -> RuntimeResult<Option<Note>> {
2466        self.validate_note_kind(kind)?;
2467        crate::secret_gate::check(content)?;
2468        if let Some(n) = name {
2469            crate::secret_gate::check(n)?;
2470        }
2471        if let Some(ref p) = properties {
2472            crate::secret_gate::check_json(p)?;
2473        }
2474
2475        let ns = token.namespace().as_str();
2476        let mut note = Note::new(ns, kind, content);
2477        if let Some(n) = name {
2478            note = note.with_name(n);
2479        }
2480        if let Some(p) = properties {
2481            note = note.with_properties(p);
2482        }
2483
2484        let inserted = self.notes(token)?.try_insert_note(note.clone()).await?;
2485        if !inserted {
2486            return Ok(None);
2487        }
2488
2489        // Best-effort FTS: log and continue on failure.
2490        if let Ok(fts) = self.text_for_notes(token) {
2491            if let Err(e) = fts.upsert_document(note_fts_document(&note)).await {
2492                tracing::warn!(
2493                    note_id = %note.id,
2494                    error = %e,
2495                    "try_create_note: FTS indexing failed (non-fatal)"
2496                );
2497            }
2498        }
2499
2500        // Best-effort vector embedding: log and continue on failure.
2501        let embed_model_names = self.registered_embedding_model_names();
2502        for model_name in &embed_model_names {
2503            match self
2504                .embed_document_with_model(model_name, &note.content)
2505                .await
2506            {
2507                Ok(vector) => {
2508                    if let Ok(vs) = self.vectors_for_model(token, model_name) {
2509                        if let Err(e) = vs
2510                            .insert(
2511                                note.id,
2512                                SubstrateKind::Note,
2513                                ns,
2514                                "note.content",
2515                                vec![vector],
2516                            )
2517                            .await
2518                        {
2519                            tracing::warn!(
2520                                note_id = %note.id,
2521                                model = %model_name,
2522                                error = %e,
2523                                "try_create_note: vector insert failed (non-fatal)"
2524                            );
2525                        }
2526                    }
2527                }
2528                Err(e) => {
2529                    tracing::warn!(
2530                        note_id = %note.id,
2531                        model = %model_name,
2532                        error = %e,
2533                        "try_create_note: embedding failed (non-fatal)"
2534                    );
2535                }
2536            }
2537        }
2538
2539        Ok(Some(note))
2540    }
2541
2542    // REASON: private inner function unifies all create_note variants; it receives every
2543    // optional parameter individually so that public variants can pass None without
2544    // requiring callers to construct an intermediate struct.
2545    #[allow(clippy::too_many_arguments)]
2546    async fn create_note_inner(
2547        &self,
2548        token: &NamespaceToken,
2549        kind: &str,
2550        name: Option<&str>,
2551        content: &str,
2552        embedding_content: Option<&str>,
2553        salience: Option<f64>,
2554        decay_factor: Option<f64>,
2555        properties: Option<serde_json::Value>,
2556        annotates: Vec<Uuid>,
2557        embedding_model: Option<&str>,
2558    ) -> RuntimeResult<Note> {
2559        self.validate_note_kind(kind)?;
2560        // Secret gate: scan content, optional name, and structured properties.
2561        crate::secret_gate::check(content)?;
2562        if let Some(n) = name {
2563            crate::secret_gate::check(n)?;
2564        }
2565        if let Some(ref p) = properties {
2566            crate::secret_gate::check_json(p)?;
2567        }
2568        // `embedding_content` is a caller-supplied alternate vector-embedding
2569        // input: it must be a non-empty proper prefix of `content` (never a
2570        // superset, an unrelated string, or the full text) and passes the
2571        // same secret gate as any other stored/embedded text. Rejected
2572        // before any write, same as the checks above.
2573        if let Some(ec) = embedding_content {
2574            if ec.is_empty() {
2575                return Err(RuntimeError::InvalidInput(
2576                    "embedding_content must not be empty".into(),
2577                ));
2578            }
2579            if ec.len() >= content.len() || !content.starts_with(ec) {
2580                return Err(RuntimeError::InvalidInput(
2581                    "embedding_content must be a proper prefix of content".into(),
2582                ));
2583            }
2584            crate::secret_gate::check(ec)?;
2585        }
2586        let ns = token.namespace().as_str();
2587
2588        // Validate all annotates targets before any write (atomicity: all-or-nothing).
2589        // Endpoint resolution is by-ID and namespace-agnostic.
2590        for &target_id in &annotates {
2591            if !self.substrate_exists_by_id(token, target_id).await? {
2592                return Err(RuntimeError::NotFound(format!(
2593                    "create_note annotates target {target_id} not found"
2594                )));
2595            }
2596        }
2597
2598        // Reject non-finite or out-of-range salience/decay at the runtime boundary
2599        // rather than letting storage silently clamp them (coding-standards §508-516).
2600        if let Some(s) = salience {
2601            if !s.is_finite() || !(0.0..=1.0).contains(&s) {
2602                return Err(RuntimeError::InvalidInput(format!(
2603                    "salience must be a finite value in [0.0, 1.0]; got {s}"
2604                )));
2605            }
2606        }
2607        if let Some(d) = decay_factor {
2608            if !d.is_finite() || d < 0.0 {
2609                return Err(RuntimeError::InvalidInput(format!(
2610                    "decay_factor must be a finite value >= 0.0; got {d}"
2611                )));
2612            }
2613        }
2614
2615        // Resolve embedding_model BEFORE any note/FTS/vector write so unknown-model
2616        // errors are atomic at the runtime layer, not just at one pack handler.
2617        // Direct Rust callers (other packs, integration tests) get the same guarantee.
2618        if let Some(model_name) = embedding_model {
2619            self.resolve_embedding_model(Some(model_name))?;
2620        }
2621
2622        let mut note = Note::new(ns, kind, content);
2623        if let Some(s) = salience {
2624            note = note.with_salience(s);
2625        }
2626        if let Some(df) = decay_factor {
2627            note = note.with_decay(df);
2628        }
2629        if let Some(n) = name {
2630            note = note.with_name(n);
2631        }
2632        if let Some(p) = properties {
2633            note = note.with_properties(p);
2634        }
2635        self.notes(token)?.upsert_note(note.clone()).await?;
2636
2637        // From here on, any error must compensate by removing the note row, its
2638        // FTS document, and any vector entries already inserted — the same
2639        // cleanup used by the annotates-edge block below.
2640
2641        // Decide which embedding models to use (before touching FTS/vectors).
2642        let embed_model_names: Vec<String> = if let Some(m) = embedding_model {
2643            vec![m.to_string()]
2644        } else {
2645            // Fan out to ALL registered models — includes both lattice models
2646            // from RuntimeConfig and any custom providers added via
2647            // register_embedder(). Gate on the registry, not
2648            // config().embedding_model, so that custom-only runtimes (no
2649            // lattice model in config) also fan out.
2650            let names = self.registered_embedding_model_names();
2651            if names.is_empty() {
2652                // No models configured at all — skip vector embedding.
2653                vec![]
2654            } else {
2655                names
2656            }
2657        };
2658
2659        // FTS step — compensate note row on failure.
2660        {
2661            // Injection: check FTS_FAIL_NS (armed by `arm_fts_fail(ns)`).
2662            // Fires only when the armed namespace matches this note's namespace,
2663            // then clears (one-shot).  No lock acquisition in release builds —
2664            // the cfg(not) branch is a const false so the compiler eliminates
2665            // the if-branch entirely.
2666            #[cfg(any(test, feature = "fault-injection"))]
2667            let fts_inject = {
2668                let mut g = FTS_FAIL_NS.lock().unwrap();
2669                if g.as_deref() == Some(ns) {
2670                    *g = None;
2671                    true
2672                } else {
2673                    false
2674                }
2675            };
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    /// Search notes using a hybrid FTS5 + vector pipeline with salience weighting.
3003    ///
3004    /// Pipeline:
3005    /// 1. FTS5 query against `notes_<namespace>`.
3006    /// 2. If embedding model is configured: vector search filtered to `kind="note"`.
3007    /// 3. RRF fusion (k=60).
3008    /// 4. Salience-weighted rerank: `score *= (0.5 + 0.5 * note.salience)`.
3009    /// 5. Filter soft-deleted notes, apply optional kind / tag / properties predicates.
3010    ///    Tags and properties are pushed into the per-note fetch loop BEFORE truncation
3011    ///    so that matching notes ranked beyond `limit` in the raw fusion are not silently
3012    ///    dropped.
3013    /// 6. Truncate to `limit`.
3014    ///
3015    /// `tags_any`: when non-empty, only notes that have at least one of these tags
3016    /// (stored in `properties["tags"]`, case-insensitive match) are retained. The
3017    /// check happens inside the alive-note loop, before `hits.truncate(limit)`.
3018    ///
3019    /// `properties_filter`: when `Some`, only notes whose `properties` JSON object is
3020    /// a superset of the given filter object are retained. Also applied before truncation.
3021    #[allow(clippy::too_many_arguments)]
3022    pub async fn search_notes(
3023        &self,
3024        token: &NamespaceToken,
3025        query_text: &str,
3026        query_vector: Option<Vec<f32>>,
3027        limit: u32,
3028        note_kind: Option<&str>,
3029        include_superseded: bool,
3030        tags_any: &[String],
3031        properties_filter: Option<&serde_json::Value>,
3032    ) -> RuntimeResult<Vec<NoteSearchHit>> {
3033        const RRF_K: usize = 60;
3034        let candidates = limit.saturating_mul(4).max(limit);
3035        let visible_ns: Vec<String> = token
3036            .visible_namespaces()
3037            .iter()
3038            .map(|ns| ns.as_str().to_owned())
3039            .collect();
3040
3041        // FTS5 over the notes index — search all visible namespaces.
3042        //
3043        // `sanitize_fts5_query` strips known-unsafe FTS5 metacharacters, but
3044        // residual punctuation the sanitizer does not strip can still reach
3045        // the FTS5 parser and error. This fails loud instead of degrading to
3046        // vector-only fusion, so callers see the bad query instead of
3047        // silently losing the lexical leg. Errors from any other leg (vector
3048        // search, note hydration) still propagate normally.
3049        //
3050        // Injection: check FTS_SEARCH_FAIL_NS (armed by `arm_fts_search_fail(ns)`),
3051        // exercising the propagate branch above. Fires only when the armed
3052        // namespace is among this call's visible namespaces, then clears (one-shot).
3053        #[cfg(any(test, feature = "fault-injection"))]
3054        let fts_search_inject = {
3055            let mut g = FTS_SEARCH_FAIL_NS.lock().unwrap();
3056            match g.as_deref() {
3057                Some(armed) if visible_ns.iter().any(|ns| ns == armed) => {
3058                    *g = None;
3059                    true
3060                }
3061                _ => false,
3062            }
3063        };
3064        #[cfg(not(any(test, feature = "fault-injection")))]
3065        let fts_search_inject = false;
3066
3067        let text_search_result = if fts_search_inject {
3068            Err(khive_storage::StorageError::Timeout {
3069                operation: "fts_search".into(),
3070            })
3071        } else {
3072            self.text_for_notes(token)?
3073                .search(TextSearchRequest {
3074                    query: query_text.to_string(),
3075                    mode: TextQueryMode::Plain,
3076                    filter: Some(TextFilter {
3077                        namespaces: visible_ns.clone(),
3078                        ..TextFilter::default()
3079                    }),
3080                    top_k: candidates,
3081                    snippet_chars: 200,
3082                })
3083                .await
3084        };
3085
3086        let text_hits = crate::error::fts_text_leg_or_err(
3087            text_search_result.map_err(RuntimeError::from),
3088            "search_notes",
3089            query_text,
3090        )?;
3091
3092        // Vector search filtered to notes.
3093        let vector_hits = if query_vector.is_some() || self.config().embedding_model.is_some() {
3094            self.vector_search(
3095                token,
3096                query_vector,
3097                Some(query_text),
3098                candidates,
3099                Some(SubstrateKind::Note),
3100            )
3101            .await?
3102        } else {
3103            vec![]
3104        };
3105
3106        // Keep the full text∪vector union through RRF — salience weighting and
3107        // soft-delete/kind filtering happen *after* this, and the final
3108        // `hits.truncate(limit)` is the only result-limiting cut. Truncating to
3109        // `candidates` here would drop a high-salience note ranked just outside
3110        // the raw RRF cutoff before salience ever applied.
3111        let fuse_k = text_hits.len() + vector_hits.len();
3112        let fused = crate::fusion::rrf_fuse_k(text_hits, vector_hits, RRF_K, fuse_k)?;
3113
3114        let candidate_ids: Vec<Uuid> = fused.iter().map(|hit| hit.entity_id).collect();
3115        if candidate_ids.is_empty() {
3116            return Ok(vec![]);
3117        }
3118
3119        // Fetch each candidate note individually to get salience and apply
3120        // soft-delete + (optional) kind filtering. Notes whose `kind` doesn't
3121        // match `note_kind` are dropped post-fetch — they're a small set
3122        // bounded by the text∪vector union (≤ 2×candidates), so the read is cheap.
3123        let note_store = self.notes(token)?;
3124        let mut alive_notes: HashMap<Uuid, Note> = HashMap::new();
3125        for id in &candidate_ids {
3126            if let Some(note) = note_store.get_note(*id).await? {
3127                if note.deleted_at.is_some() {
3128                    continue;
3129                }
3130                if let Some(want_kind) = note_kind {
3131                    if note.kind != want_kind {
3132                        continue;
3133                    }
3134                }
3135                // Apply tag predicate before adding to alive set: tags on notes live
3136                // inside `properties["tags"]` (a JSON array). This pushes the filter
3137                // before truncation so matching notes ranked beyond `limit` in the raw
3138                // fusion are not silently dropped.
3139                if !tags_any.is_empty() {
3140                    let note_tags: Vec<String> = note
3141                        .properties
3142                        .as_ref()
3143                        .and_then(|p| p.get("tags"))
3144                        .and_then(serde_json::Value::as_array)
3145                        .map(|arr| {
3146                            arr.iter()
3147                                .filter_map(serde_json::Value::as_str)
3148                                .map(str::to_owned)
3149                                .collect()
3150                        })
3151                        .unwrap_or_default();
3152                    if !note_tags
3153                        .iter()
3154                        .any(|t| tags_any.iter().any(|w| t.eq_ignore_ascii_case(w)))
3155                    {
3156                        continue;
3157                    }
3158                }
3159                // Apply properties predicate before truncation, same reasoning as tags above.
3160                if let Some(pf) = properties_filter {
3161                    if !note_props_match(note.properties.as_ref(), pf) {
3162                        continue;
3163                    }
3164                }
3165                alive_notes.insert(*id, note);
3166            }
3167        }
3168
3169        // Drop superseded notes unless include_superseded is true: any note targeted
3170        // by a `supersedes` edge is obsolete and excluded from default search.
3171        if !include_superseded && !alive_notes.is_empty() {
3172            let graph = self.graph(token)?;
3173            let mut superseded: std::collections::HashSet<Uuid> = std::collections::HashSet::new();
3174            for &note_id in alive_notes.keys() {
3175                let inbound = graph
3176                    .neighbors(
3177                        note_id,
3178                        NeighborQuery {
3179                            direction: Direction::In,
3180                            relations: Some(vec![EdgeRelation::Supersedes]),
3181                            limit: Some(1),
3182                            min_weight: None,
3183                        },
3184                    )
3185                    .await?;
3186                if !inbound.is_empty() {
3187                    superseded.insert(note_id);
3188                }
3189            }
3190            alive_notes.retain(|id, _| !superseded.contains(id));
3191        }
3192
3193        // Apply salience weighting and collect final hits.
3194        let mut hits: Vec<NoteSearchHit> = fused
3195            .into_iter()
3196            .filter_map(|hit| {
3197                let note = alive_notes.get(&hit.entity_id)?;
3198                let salience = note.salience.unwrap_or(0.5);
3199                let weight = 0.5 + 0.5 * salience;
3200                let weighted = DeterministicScore::from_f64(hit.score.to_f64() * weight);
3201                Some(NoteSearchHit {
3202                    note_id: hit.entity_id,
3203                    score: weighted,
3204                    title: hit.title.or_else(|| note_title(note)),
3205                    snippet: hit.snippet.or_else(|| note_snippet(note)),
3206                })
3207            })
3208            .collect();
3209
3210        hits.sort_by(|a, b| b.score.cmp(&a.score).then(a.note_id.cmp(&b.note_id)));
3211        hits.truncate(limit as usize);
3212        Ok(hits)
3213    }
3214
3215    /// Resolve a short UUID prefix (8+ hex chars) to a full UUID.
3216    ///
3217    /// Searches entities, notes, and edges tables for a UUID starting with the
3218    /// given prefix, scoped to the caller's primary namespace only. Returns
3219    /// `Ok(Some(uuid))` if exactly one match is found, `Ok(None)` if no
3220    /// matches, or an error if ambiguous (multiple matches).
3221    pub async fn resolve_prefix(
3222        &self,
3223        token: &NamespaceToken,
3224        prefix: &str,
3225    ) -> RuntimeResult<Option<Uuid>> {
3226        let namespaces = [token.namespace().as_str().to_owned()];
3227        self.resolve_prefix_inner(Some(&namespaces), prefix, false)
3228            .await
3229    }
3230
3231    pub async fn resolve_prefix_including_deleted(
3232        &self,
3233        token: &NamespaceToken,
3234        prefix: &str,
3235    ) -> RuntimeResult<Option<Uuid>> {
3236        let namespaces = [token.namespace().as_str().to_owned()];
3237        self.resolve_prefix_inner(Some(&namespaces), prefix, true)
3238            .await
3239    }
3240
3241    /// Resolve a short UUID prefix (8+ hex chars) to a full UUID with NO
3242    /// namespace filter at all: mirrors `resolve_by_id`'s by-ID contract:
3243    /// by-ID resolution is namespace-agnostic, since the Gate (not
3244    /// storage-layer filtering) is the authz seam. Used by the four by-ID
3245    /// CRUD verbs (get/update/delete/merge) so their prefix path matches
3246    /// their already-unfiltered full-UUID path. No token param: unlike
3247    /// `resolve_prefix`, there is no namespace to derive from one.
3248    pub async fn resolve_prefix_unfiltered(&self, prefix: &str) -> RuntimeResult<Option<Uuid>> {
3249        self.resolve_prefix_inner(None, prefix, false).await
3250    }
3251
3252    /// `resolve_prefix_unfiltered`, including soft-deleted rows — used by the
3253    /// hard-delete by-ID path.
3254    pub async fn resolve_prefix_unfiltered_including_deleted(
3255        &self,
3256        prefix: &str,
3257    ) -> RuntimeResult<Option<Uuid>> {
3258        self.resolve_prefix_inner(None, prefix, true).await
3259    }
3260
3261    /// Shared prefix-scan implementation over an explicit namespace set.
3262    ///
3263    /// `namespaces` selects the scan scope: `Some(&[ns])` reproduces the
3264    /// historical primary-only behaviour (`resolve_prefix` /
3265    /// `resolve_prefix_including_deleted`); `None` applies
3266    /// no namespace predicate at all (`resolve_prefix_unfiltered*`).
3267    /// Ambiguity (a prefix matching more than one UUID, even across
3268    /// different namespaces in the set, or across all namespaces when
3269    /// unfiltered) is still an error: UUIDs are globally unique, so two
3270    /// distinct rows sharing a prefix always requires caller disambiguation —
3271    /// no cross-namespace dedup is needed or performed.
3272    async fn resolve_prefix_inner(
3273        &self,
3274        namespaces: Option<&[String]>,
3275        prefix: &str,
3276        include_deleted: bool,
3277    ) -> RuntimeResult<Option<Uuid>> {
3278        use khive_storage::types::{SqlStatement, SqlValue};
3279
3280        // Every caller is expected to pre-validate hex-only input, but this is
3281        // the single choke point every `resolve_prefix*` variant funnels
3282        // through, so re-validate here too. A prefix containing anything other
3283        // than hex digits and
3284        // canonical hyphen separators (`%`, `_`, or other LIKE-wildcard /
3285        // injection-shaped input) never matches a real id and is rejected
3286        // before it can reach the LIKE pattern, instead of relying on bound
3287        // params alone to neutralize wildcard semantics.
3288        if !prefix.chars().all(|c| c.is_ascii_hexdigit() || c == '-') {
3289            return Ok(None);
3290        }
3291
3292        let pattern = format!("{}%", hex_prefix_to_uuid_pattern(prefix));
3293
3294        let tables = [
3295            ("entities", true),
3296            ("notes", true),
3297            ("events", false),
3298            ("graph_edges", false),
3299        ];
3300
3301        let ns_clause = namespaces.map(|ns| {
3302            let placeholders: Vec<String> = (0..ns.len()).map(|i| format!("?{}", i + 2)).collect();
3303            format!(" AND namespace IN ({})", placeholders.join(", "))
3304        });
3305
3306        // A UUID can legitimately exist in more than one scanned table
3307        // (e.g. an entity id string that also happens to be an edge id — the
3308        // scan is purely a text-prefix LIKE across independent tables, not a
3309        // substrate-exclusive lookup). Without dedup, a single record hit
3310        // twice across tables inflated `matches.len()` past 1 and produced a
3311        // false `AmbiguousPrefix` naming the SAME UUID twice. `seen` tracks
3312        // UUIDs already pushed so `matches` (and thus every length check,
3313        // including the early-exit below) reflects DISTINCT UUIDs only.
3314        let mut matches: Vec<String> = Vec::new();
3315        let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
3316        let mut reader = self.sql().reader().await.map_err(RuntimeError::Storage)?;
3317
3318        for (table, has_deleted_at) in tables {
3319            let deleted_filter = if has_deleted_at && !include_deleted {
3320                " AND deleted_at IS NULL"
3321            } else {
3322                ""
3323            };
3324            let mut params = vec![SqlValue::Text(pattern.clone())];
3325            if let Some(ns) = namespaces {
3326                params.extend(ns.iter().map(|n| SqlValue::Text(n.clone())));
3327            }
3328            let sql = SqlStatement {
3329                sql: format!(
3330                    "SELECT id FROM {table} WHERE id LIKE ?1{ns_clause}{deleted_filter} LIMIT 2",
3331                    ns_clause = ns_clause.as_deref().unwrap_or("")
3332                ),
3333                params,
3334                label: Some("resolve_prefix".into()),
3335            };
3336            match reader.query_all(sql).await {
3337                Ok(rows) => {
3338                    for row in rows {
3339                        if let Some(col) = row.columns.first() {
3340                            if let SqlValue::Text(s) = &col.value {
3341                                if seen.insert(s.clone()) {
3342                                    matches.push(s.clone());
3343                                }
3344                            }
3345                        }
3346                    }
3347                }
3348                Err(e) => {
3349                    let msg = e.to_string();
3350                    if msg.contains("no such table") {
3351                        continue;
3352                    }
3353                    return Err(RuntimeError::Storage(e));
3354                }
3355            }
3356            if matches.len() > 1 {
3357                break;
3358            }
3359        }
3360
3361        match matches.len() {
3362            0 => Ok(None),
3363            1 => {
3364                let uuid = Uuid::from_str(&matches[0])
3365                    .map_err(|e| RuntimeError::Internal(format!("stored UUID is invalid: {e}")))?;
3366                Ok(Some(uuid))
3367            }
3368            _ => {
3369                let uuids: Vec<uuid::Uuid> = matches
3370                    .iter()
3371                    .filter_map(|s| Uuid::from_str(s).ok())
3372                    .collect();
3373                Err(RuntimeError::AmbiguousPrefix {
3374                    prefix: prefix.to_string(),
3375                    matches: uuids,
3376                })
3377            }
3378        }
3379    }
3380
3381    /// Resolve a UUID to its substrate kind with NO namespace filter.
3382    ///
3383    /// By-ID contract: UUID v4 is globally unique: by-ID substrate
3384    /// inference must return the record regardless of caller namespace.  Used by
3385    /// the public `update` and `delete` verb handlers when no explicit `kind` is
3386    /// supplied.
3387    ///
3388    /// Does NOT consult the visible set or the primary-namespace check.  The
3389    /// token is still required to route to the correct backend pool but its
3390    /// namespace value is not used as a filter.
3391    pub async fn resolve_by_id(
3392        &self,
3393        token: &NamespaceToken,
3394        id: Uuid,
3395    ) -> RuntimeResult<Option<Resolved>> {
3396        // Entity: direct by-UUID fetch (ID-only, no namespace check).
3397        if let Some(entity) = self.entities(token)?.get_entity(id).await? {
3398            return Ok(Some(Resolved::Entity(entity)));
3399        }
3400
3401        // Note: direct by-UUID fetch (ID-only).
3402        if let Some(note) = self.notes(token)?.get_note(id).await? {
3403            return Ok(Some(Resolved::Note(note)));
3404        }
3405
3406        // Edges and events are not returned here; the caller's `_` arm handles
3407        // those with a separate get_edge / get_event check.
3408        Ok(None)
3409    }
3410
3411    /// Resolve a UUID to its substrate kind with NO namespace filter, including
3412    /// soft-deleted rows.
3413    ///
3414    /// Used by the hard-delete path when no explicit `kind` is supplied, so
3415    /// already-soft-deleted records can still be located by UUID alone.
3416    pub async fn resolve_by_id_including_deleted(
3417        &self,
3418        token: &NamespaceToken,
3419        id: Uuid,
3420    ) -> RuntimeResult<Option<Resolved>> {
3421        // Entity: including soft-deleted, no namespace check.
3422        if let Some(entity) = self
3423            .entities(token)?
3424            .get_entity_including_deleted(id)
3425            .await?
3426        {
3427            return Ok(Some(Resolved::Entity(entity)));
3428        }
3429
3430        // Note: including soft-deleted, no namespace check.
3431        if let Some(note) = self.notes(token)?.get_note_including_deleted(id).await? {
3432            return Ok(Some(Resolved::Note(note)));
3433        }
3434
3435        // Edges and events are not returned here; the caller's `_` arm handles
3436        // those with a separate get_edge_including_deleted check.
3437        Ok(None)
3438    }
3439
3440    /// Resolve a UUID to its substrate kind by trying entity, then note, then event stores.
3441    ///
3442    /// Returns `None` if the UUID is not found in any substrate.
3443    /// Cost: at most 3 store lookups per call (cheap for v0.1).
3444    pub async fn resolve(
3445        &self,
3446        token: &NamespaceToken,
3447        id: Uuid,
3448    ) -> RuntimeResult<Option<Resolved>> {
3449        // Entity: use the namespace-checked getter (errors on mismatch/absent).
3450        match self.get_entity(token, id).await {
3451            Ok(entity) => return Ok(Some(Resolved::Entity(entity))),
3452            Err(RuntimeError::NotFound(_) | RuntimeError::NamespaceMismatch { .. }) => {}
3453            Err(e) => return Err(e),
3454        }
3455
3456        // Note: storage get_note is ID-only — verify against visible set.
3457        if let Some(note) = self.notes(token)?.get_note(id).await? {
3458            if Self::ensure_namespace_visible(&note.namespace, token).is_ok() {
3459                return Ok(Some(Resolved::Note(note)));
3460            }
3461        }
3462
3463        // Event: storage get_event is ID-only — verify against visible set.
3464        if let Some(event) = self.events(token)?.get_event(id).await? {
3465            if Self::ensure_namespace_visible(&event.namespace, token).is_ok() {
3466                return Ok(Some(Resolved::Event(event)));
3467            }
3468        }
3469
3470        Ok(None)
3471    }
3472
3473    /// Resolve a UUID to its substrate kind with NO namespace filter, for edge
3474    /// endpoint validation.
3475    ///
3476    /// `link` and `create`'s `annotates` targets consume by-ID endpoints, so
3477    /// their existence check must follow the same by-ID contract as `get()`:
3478    /// by-ID ops are namespace-agnostic: the Gate, not storage-layer
3479    /// filtering, is the authz seam. Mirrors `resolve_by_id`
3480    /// (entity + note, unfiltered) and additionally resolves events,
3481    /// unfiltered, so edge endpoint validation resolves exactly what `get()`
3482    /// resolves regardless of the caller's namespace.
3483    pub async fn resolve_edge_endpoint(
3484        &self,
3485        token: &NamespaceToken,
3486        id: Uuid,
3487    ) -> RuntimeResult<Option<Resolved>> {
3488        if let Some(resolved) = self.resolve_by_id(token, id).await? {
3489            return Ok(Some(resolved));
3490        }
3491        if let Some(event) = self.events(token)?.get_event(id).await? {
3492            return Ok(Some(Resolved::Event(event)));
3493        }
3494        Ok(None)
3495    }
3496
3497    /// Resolve a UUID to its substrate kind using primary-namespace-only enforcement.
3498    ///
3499    /// Unlike `resolve`, never consults the visible set. Use from GTD dependency
3500    /// validation paths where strict primary ownership is required.
3501    pub async fn resolve_primary(
3502        &self,
3503        token: &NamespaceToken,
3504        id: Uuid,
3505    ) -> RuntimeResult<Option<Resolved>> {
3506        let ns = token.namespace().as_str();
3507
3508        // Entity: primary-only check (exclude entities in visible-only namespaces).
3509        if let Some(entity) = self.entities(token)?.get_entity(id).await? {
3510            if Self::ensure_namespace(&entity.namespace, ns).is_ok() {
3511                return Ok(Some(Resolved::Entity(entity)));
3512            }
3513        }
3514
3515        // Note: primary-only check.
3516        if let Some(note) = self.notes(token)?.get_note(id).await? {
3517            if Self::ensure_namespace(&note.namespace, ns).is_ok() {
3518                return Ok(Some(Resolved::Note(note)));
3519            }
3520        }
3521
3522        // Event: primary-only check.
3523        if let Some(event) = self.events(token)?.get_event(id).await? {
3524            if Self::ensure_namespace(&event.namespace, ns).is_ok() {
3525                return Ok(Some(Resolved::Event(event)));
3526            }
3527        }
3528
3529        Ok(None)
3530    }
3531
3532    /// Resolve a UUID to its substrate kind, including soft-deleted rows.
3533    ///
3534    /// Used exclusively by the hard-delete path to locate records that have
3535    /// already been soft-deleted. Namespace isolation is still enforced.
3536    pub async fn resolve_including_deleted(
3537        &self,
3538        token: &NamespaceToken,
3539        id: Uuid,
3540    ) -> RuntimeResult<Option<Resolved>> {
3541        let ns = token.namespace().as_str();
3542
3543        if let Some(entity) = self
3544            .entities(token)?
3545            .get_entity_including_deleted(id)
3546            .await?
3547        {
3548            if Self::ensure_namespace(&entity.namespace, ns).is_ok() {
3549                return Ok(Some(Resolved::Entity(entity)));
3550            }
3551        }
3552
3553        if let Some(note) = self.notes(token)?.get_note_including_deleted(id).await? {
3554            if Self::ensure_namespace(&note.namespace, ns).is_ok() {
3555                return Ok(Some(Resolved::Note(note)));
3556            }
3557        }
3558
3559        if let Some(event) = self.events(token)?.get_event(id).await? {
3560            if Self::ensure_namespace(&event.namespace, ns).is_ok() {
3561                return Ok(Some(Resolved::Event(event)));
3562            }
3563        }
3564
3565        Ok(None)
3566    }
3567
3568    /// Hard-delete a single graph node (entity, note, or edge-as-node row)
3569    /// AND purge its incident edges in ONE write transaction.
3570    ///
3571    /// The endpoint row delete and the incident-edge cascade used
3572    /// to run as two independently-committing storage calls. A concurrent
3573    /// guarded write (`upsert_edge_guarded`/`upsert_edges_guarded`) landing
3574    /// between them could see the endpoint still live, insert a fresh edge
3575    /// against it, and then survive the cascade that already ran — a
3576    /// durably dangling edge with no second purge. Routing both statements
3577    /// through one [`run_atomic_unit`] call closes the window: since every
3578    /// write (this one and the guarded insert) funnels through the same
3579    /// single-writer queue, a concurrent guarded write either fully commits
3580    /// before this unit starts (and its edge is then swept by the purge
3581    /// below, in the same transaction as the row delete) or fully commits
3582    /// after this unit has already committed (and its own endpoint-existence
3583    /// check then sees the endpoint gone and refuses the write) — there is
3584    /// no state in which it can observe the endpoint alive with edges
3585    /// already purged.
3586    ///
3587    /// `row_statement` is the exact hard-delete `DELETE` for the target row
3588    /// (entity, note, or edge). Returns `Ok(true)` if the row was deleted,
3589    /// `Ok(false)` if it no longer existed (lost a race with a concurrent
3590    /// delete of the same row) — never an error for that case, matching the
3591    /// non-atomic bool-returning shape callers had before this fix.
3592    async fn atomic_hard_delete_with_edge_purge(
3593        &self,
3594        row_statement: SqlStatement,
3595        node_id: Uuid,
3596    ) -> RuntimeResult<bool> {
3597        let plan = AtomicOpPlan::Delete(DeletePlan {
3598            target_id: node_id,
3599            statements: vec![
3600                PlanStatement {
3601                    statement: row_statement,
3602                    guard: Some(AffectedRowGuard::exactly(1)),
3603                },
3604                PlanStatement {
3605                    statement: purge_incident_edges_statement(node_id),
3606                    guard: None,
3607                },
3608            ],
3609            post_commit: PostCommitEffect::None,
3610        });
3611        match run_atomic_unit(self.sql().as_ref(), vec![plan]).await {
3612            Ok(AtomicRunOutcome::Committed { .. }) => Ok(true),
3613            Ok(AtomicRunOutcome::RolledBack {
3614                failure: AtomicOpFailure::GuardFailed { .. },
3615                ..
3616            }) => Ok(false),
3617            Ok(AtomicRunOutcome::RolledBack {
3618                failure: AtomicOpFailure::SqlError { message, .. },
3619                ..
3620            }) => Err(RuntimeError::Internal(format!(
3621                "hard delete + edge purge for {node_id} failed: {message}"
3622            ))),
3623            Err(e) => Err(RuntimeError::Internal(format!(
3624                "hard delete + edge purge for {node_id}: atomic unit seam failure: {}",
3625                e.0
3626            ))),
3627        }
3628    }
3629
3630    /// Soft-delete or hard-delete a note by ID.
3631    ///
3632    /// On hard delete, cascades to remove all incident edges (both inbound and
3633    /// outbound) and cleans up FTS and vector indexes, preventing dangling
3634    /// references for `annotates` edges that target this note.
3635    /// Soft delete also cleans FTS and vector indexes; edges are left in place.
3636    ///
3637    /// UUID v4 is globally unique: no namespace filter on by-ID ops.
3638    /// Cascade and index cleanup target the RECORD's stored namespace, not the caller token's.
3639    /// Returns `Ok(false)` if the note does not exist.
3640    pub async fn delete_note(
3641        &self,
3642        token: &NamespaceToken,
3643        id: Uuid,
3644        hard: bool,
3645    ) -> RuntimeResult<bool> {
3646        let note_store = self.notes(token)?;
3647        let note = if hard {
3648            match note_store.get_note_including_deleted(id).await? {
3649                Some(n) => n,
3650                None => return Ok(false),
3651            }
3652        } else {
3653            match note_store.get_note(id).await? {
3654                Some(n) => n,
3655                None => return Ok(false),
3656            }
3657        };
3658        let mode = if hard {
3659            DeleteMode::Hard
3660        } else {
3661            DeleteMode::Soft
3662        };
3663
3664        // Route index cleanup through the RECORD's namespace, not the caller's.
3665        let record_tok = NamespaceToken::for_namespace(
3666            khive_types::Namespace::parse(&note.namespace)
3667                .map_err(|e| RuntimeError::Internal(format!("note namespace invalid: {e}")))?,
3668        );
3669        let record_ns = note.namespace.clone();
3670
3671        // On hard delete, the row delete and the incident-edge cascade (including
3672        // already-soft-deleted edges) run as ONE write transaction: see
3673        // `atomic_hard_delete_with_edge_purge`. Index cleanup follows the
3674        // commit; it is best-effort and idempotent, unlike the row/edge pair.
3675        let deleted = if hard {
3676            let deleted = self
3677                .atomic_hard_delete_with_edge_purge(note_hard_delete_statement(id), id)
3678                .await?;
3679            self.text_for_notes(&record_tok)?
3680                .delete_document(&record_ns, id)
3681                .await?;
3682            // Scoped delete: iterate over EVERY registered embedding model's
3683            // vector store so non-default vectors don't orphan when the note is deleted.
3684            for model_name in self.registered_embedding_model_names() {
3685                self.vectors_for_model(&record_tok, &model_name)?
3686                    .delete(id)
3687                    .await?;
3688            }
3689            deleted
3690        } else {
3691            let deleted = note_store.delete_note(id, mode).await?;
3692            if deleted {
3693                self.text_for_notes(&record_tok)?
3694                    .delete_document(&record_ns, id)
3695                    .await?;
3696                for model_name in self.registered_embedding_model_names() {
3697                    self.vectors_for_model(&record_tok, &model_name)?
3698                        .delete(id)
3699                        .await?;
3700                }
3701            }
3702            deleted
3703        };
3704        if deleted {
3705            let event_store = self.events(token)?;
3706            let event = khive_storage::event::Event::new(
3707                record_ns.clone(),
3708                "delete",
3709                EventKind::NoteDeleted,
3710                SubstrateKind::Note,
3711                "",
3712            )
3713            .with_target(id)
3714            .with_payload(serde_json::json!({"id": id, "namespace": record_ns, "hard": hard}));
3715            event_store.append_event(event).await.map_err(|e| {
3716                RuntimeError::Internal(format!("delete_note: event store write failed: {e}"))
3717            })?;
3718            // A soft OR hard delete removes the note's vectors/FTS document
3719            // above: any pack-owned vector-derived cache (e.g.
3720            // khive-pack-memory's warm ANN index) needs to know the corpus
3721            // changed, reached via this generic hook so khive-runtime never
3722            // takes a dependency on khive-pack-memory. No-op when no pack has
3723            // installed a hook.
3724            self.fire_note_mutation_hook(&note.kind, id).await;
3725        }
3726        Ok(deleted)
3727    }
3728
3729    /// Row-first compensating delete for rolling back a partially-written note
3730    /// (e.g. `dual_write_message` rollback after a later delivery step fails).
3731    /// Unlike [`KhiveRuntime::delete_note`], which cleans up graph/FTS/
3732    /// vector indexes *before* removing the row, this removes the row first so
3733    /// that a cleanup failure afterward cannot leave the compensated note live.
3734    ///
3735    /// Returns `Ok(())` once the row is gone (whether or not cleanup fully
3736    /// succeeded). Returns `Err(RuntimeError::Internal)` naming the failed
3737    /// cleanup legs when row removal succeeded but cleanup did not — the
3738    /// message is gone, but stale index entries may remain and should be
3739    /// surfaced to the caller rather than silently discarded.
3740    ///
3741    /// Returns `Ok(())` immediately, with no cleanup attempted, if the note
3742    /// does not exist (nothing to compensate).
3743    ///
3744    /// Not a general-purpose replacement for `delete_note(..., hard=true)`:
3745    /// normal hard delete still needs cleanup-first semantics (no dangling
3746    /// references) since a caller-visible error there should not remove the row.
3747    pub async fn delete_note_row_first_for_compensation(
3748        &self,
3749        token: &NamespaceToken,
3750        id: Uuid,
3751    ) -> RuntimeResult<()> {
3752        let note_store = self.notes(token)?;
3753        let Some(note) = note_store.get_note_including_deleted(id).await? else {
3754            return Ok(());
3755        };
3756        let record_tok = NamespaceToken::for_namespace(
3757            khive_types::Namespace::parse(&note.namespace)
3758                .map_err(|e| RuntimeError::Internal(format!("note namespace invalid: {e}")))?,
3759        );
3760        let record_ns = note.namespace.clone();
3761
3762        // Critical ordering: remove the row before any cleanup that can fail.
3763        note_store.delete_note(id, DeleteMode::Hard).await?;
3764
3765        #[cfg(any(test, feature = "fault-injection"))]
3766        {
3767            let armed = ROLLBACK_CLEANUP_FAIL_NS.lock().unwrap().take();
3768            if armed.as_deref() == Some(record_ns.as_str()) {
3769                return Err(RuntimeError::Internal(
3770                    "row removed but compensation cleanup failed: injected=true".to_string(),
3771                ));
3772            }
3773        }
3774
3775        let mut cleanup_errors = Vec::new();
3776        if let Err(e) = self.graph(&record_tok)?.purge_incident_edges(id).await {
3777            cleanup_errors.push(format!("graph={e}"));
3778        }
3779        if let Err(e) = self
3780            .text_for_notes(&record_tok)?
3781            .delete_document(&record_ns, id)
3782            .await
3783        {
3784            cleanup_errors.push(format!("fts={e}"));
3785        }
3786        for model_name in self.registered_embedding_model_names() {
3787            if let Err(e) = self
3788                .vectors_for_model(&record_tok, &model_name)?
3789                .delete(id)
3790                .await
3791            {
3792                cleanup_errors.push(format!("vector[{model_name}]={e}"));
3793            }
3794        }
3795        if cleanup_errors.is_empty() {
3796            Ok(())
3797        } else {
3798            Err(RuntimeError::Internal(format!(
3799                "row removed but compensation cleanup failed: {}",
3800                cleanup_errors.join("; ")
3801            )))
3802        }
3803    }
3804}
3805
3806/// Result of a GQL/SPARQL query with optional validation warnings.
3807#[derive(Clone, Debug, Serialize)]
3808pub struct QueryResult {
3809    pub rows: Vec<SqlRow>,
3810    #[serde(skip_serializing_if = "Vec::is_empty")]
3811    pub warnings: Vec<String>,
3812}
3813
3814impl KhiveRuntime {
3815    // ---- Query operations ----
3816
3817    /// Execute a GQL or SPARQL query string, returning raw SQL rows.
3818    ///
3819    /// The query is compiled to SQL with the namespace scope applied.
3820    /// GQL syntax: `MATCH (a:concept)-[e:extends]->(b) RETURN a, b LIMIT 10`
3821    /// SPARQL syntax: `SELECT ?a WHERE { ?a :kind "concept" . }`
3822    pub async fn query(&self, token: &NamespaceToken, query: &str) -> RuntimeResult<Vec<SqlRow>> {
3823        Ok(self
3824            .query_with_metadata(token, query, khive_query::CompileOptions::default())
3825            .await?
3826            .rows)
3827    }
3828
3829    /// Execute a GQL/SPARQL query, returning rows and any validation warnings.
3830    pub async fn query_with_metadata(
3831        &self,
3832        token: &NamespaceToken,
3833        query: &str,
3834        mut opts: khive_query::CompileOptions,
3835    ) -> RuntimeResult<QueryResult> {
3836        use khive_query::QueryValue;
3837        use khive_storage::types::SqlValue;
3838
3839        let ast = khive_query::parse_auto(query)?;
3840        opts.scopes = token
3841            .visible_namespaces()
3842            .iter()
3843            .map(|ns| ns.as_str().to_string())
3844            .collect();
3845        let compiled = khive_query::compile(&ast, &opts)?;
3846        let mut warnings = compiled.warnings;
3847        let truncation_check = compiled.truncation_check;
3848
3849        // Convert QueryValue params (query-layer type) to SqlValue (storage-layer type)
3850        // at the query–storage boundary.
3851        let params: Vec<SqlValue> = compiled
3852            .params
3853            .into_iter()
3854            .map(|qv| match qv {
3855                QueryValue::Null => SqlValue::Null,
3856                QueryValue::Integer(n) => SqlValue::Integer(n),
3857                QueryValue::Float(f) => SqlValue::Float(f),
3858                QueryValue::Text(s) => SqlValue::Text(s),
3859                QueryValue::Blob(b) => SqlValue::Blob(b),
3860            })
3861            .collect();
3862
3863        let mut reader = self.sql().reader().await?;
3864        let stmt = SqlStatement {
3865            sql: compiled.sql,
3866            params,
3867            label: None,
3868        };
3869        let mut rows = reader.query_all(stmt).await?;
3870
3871        // When the server-side cap was the binding constraint, the compiled
3872        // SQL asked for one extra (sentinel) row. Its presence in the actual
3873        // result set — not the requested LIMIT — is the truncation signal
3874        // (a `LIMIT 1000` that only matches 20 rows must not warn, and a
3875        // query with no `LIMIT` that matches 501+ rows must).
3876        if let Some(check) = truncation_check {
3877            if rows.len() > check.max_limit {
3878                rows.truncate(check.max_limit);
3879                warnings.push(match check.requested_limit {
3880                    Some(requested) => format!(
3881                        "result set capped at {} rows; requested limit {requested} exceeds the \
3882                         cap — use LIMIT/OFFSET to page through the remaining results",
3883                        check.max_limit
3884                    ),
3885                    None => format!(
3886                        "result set capped at {} rows; more than {} rows matched with no LIMIT \
3887                         clause — use LIMIT/OFFSET to page through the remaining results",
3888                        check.max_limit, check.max_limit
3889                    ),
3890                });
3891            }
3892        }
3893
3894        Ok(QueryResult { rows, warnings })
3895    }
3896
3897    /// Soft-delete or hard-delete an entity by ID (soft delete by default).
3898    ///
3899    /// On hard delete, cascades to remove all incident edges (both inbound and
3900    /// outbound) to prevent dangling references. Soft delete also cleans FTS
3901    /// and vector indexes; edges are left in place.
3902    ///
3903    /// UUID v4 is globally unique: no namespace filter on by-ID ops.
3904    pub async fn delete_entity(
3905        &self,
3906        token: &NamespaceToken,
3907        id: Uuid,
3908        hard: bool,
3909    ) -> RuntimeResult<bool> {
3910        let entity = if hard {
3911            match self
3912                .entities(token)?
3913                .get_entity_including_deleted(id)
3914                .await?
3915            {
3916                Some(e) => e,
3917                None => return Ok(false),
3918            }
3919        } else {
3920            match self.entities(token)?.get_entity(id).await? {
3921                Some(e) => e,
3922                None => return Ok(false),
3923            }
3924        };
3925        let mode = if hard {
3926            DeleteMode::Hard
3927        } else {
3928            DeleteMode::Soft
3929        };
3930
3931        // Route cascade and index cleanup through the RECORD's namespace, not the caller's.
3932        let record_tok = NamespaceToken::for_namespace(
3933            khive_types::Namespace::parse(&entity.namespace)
3934                .map_err(|e| RuntimeError::Internal(format!("entity namespace invalid: {e}")))?,
3935        );
3936
3937        // On hard delete, the row delete and the incident-edge cascade (including
3938        // already-soft-deleted edges) run as ONE write transaction: see
3939        // `atomic_hard_delete_with_edge_purge`. Index cleanup follows the
3940        // commit; it is best-effort and idempotent, unlike the row/edge pair.
3941        let deleted = if hard {
3942            let deleted = self
3943                .atomic_hard_delete_with_edge_purge(entity_hard_delete_statement(id), id)
3944                .await?;
3945            self.remove_from_indexes(&record_tok, id).await?;
3946            deleted
3947        } else {
3948            let deleted = self.entities(token)?.delete_entity(id, mode).await?;
3949            if deleted {
3950                self.remove_from_indexes(&record_tok, id).await?;
3951            }
3952            deleted
3953        };
3954        if deleted {
3955            let event_store = self.events(token)?;
3956            let ns = entity.namespace.clone();
3957            let event = khive_storage::event::Event::new(
3958                ns.clone(),
3959                "delete",
3960                EventKind::EntityDeleted,
3961                SubstrateKind::Entity,
3962                "",
3963            )
3964            .with_target(id)
3965            .with_payload(serde_json::json!({"id": id, "namespace": ns, "hard": hard}));
3966            event_store.append_event(event).await.map_err(|e| {
3967                RuntimeError::Internal(format!("delete_entity: event store write failed: {e}"))
3968            })?;
3969        }
3970        Ok(deleted)
3971    }
3972
3973    /// Count entities in a namespace, optionally filtered.
3974    pub async fn count_entities(
3975        &self,
3976        token: &NamespaceToken,
3977        kind: Option<&str>,
3978    ) -> RuntimeResult<u64> {
3979        let filter = EntityFilter {
3980            kinds: match kind {
3981                Some(k) => vec![k.to_string()],
3982                None => vec![],
3983            },
3984            ..Default::default()
3985        };
3986        Ok(self
3987            .entities(token)?
3988            .count_entities(token.namespace().as_str(), filter)
3989            .await?)
3990    }
3991
3992    // ---- Edge CRUD operations ----
3993
3994    /// Fetch a single edge by id.
3995    ///
3996    /// UUID v4 is globally unique: returns the edge regardless of which
3997    /// namespace the token carries. `Ok(None)` means the edge does not exist at all.
3998    pub async fn get_edge(
3999        &self,
4000        _token: &NamespaceToken,
4001        edge_id: Uuid,
4002    ) -> RuntimeResult<Option<Edge>> {
4003        let mut reader = self.sql().reader().await?;
4004        let record_ns = reader
4005            .query_scalar(SqlStatement {
4006                sql: "SELECT namespace FROM graph_edges \
4007                      WHERE id = ?1 AND deleted_at IS NULL LIMIT 1"
4008                    .into(),
4009                params: vec![SqlValue::Text(edge_id.to_string())],
4010                label: Some("get_edge_namespace".into()),
4011            })
4012            .await?;
4013
4014        let Some(SqlValue::Text(record_ns)) = record_ns else {
4015            return Ok(None);
4016        };
4017        // Route the storage fetch through the record's own namespace — the token is
4018        // just the caller context; by-ID ops cross namespace boundaries.
4019        let record_tok = NamespaceToken::for_namespace(
4020            khive_types::Namespace::parse(&record_ns)
4021                .map_err(|e| RuntimeError::Internal(format!("edge namespace invalid: {e}")))?,
4022        );
4023        Ok(self
4024            .graph(&record_tok)?
4025            .get_edge(LinkId::from(edge_id))
4026            .await?)
4027    }
4028
4029    /// Fetch a single edge by id.
4030    ///
4031    /// Delegates to `get_edge`: no visible-set check.  By-ID ops are
4032    /// namespace-agnostic; UUID v4 is globally unique.
4033    pub async fn get_edge_visible(
4034        &self,
4035        token: &NamespaceToken,
4036        edge_id: Uuid,
4037    ) -> RuntimeResult<Option<Edge>> {
4038        self.get_edge(token, edge_id).await
4039    }
4040
4041    /// Fetch an edge by UUID including soft-deleted rows.
4042    ///
4043    /// Returns the edge regardless of which namespace the token carries:
4044    /// UUID v4 is globally unique. Used by the hard-delete path so that a
4045    /// soft-deleted edge can still be purged via its edge ID.
4046    pub async fn get_edge_including_deleted(
4047        &self,
4048        _token: &NamespaceToken,
4049        edge_id: Uuid,
4050    ) -> RuntimeResult<Option<Edge>> {
4051        let mut reader = self.sql().reader().await?;
4052        let record_ns = reader
4053            .query_scalar(SqlStatement {
4054                sql: "SELECT namespace FROM graph_edges WHERE id = ?1 LIMIT 1".into(),
4055                params: vec![SqlValue::Text(edge_id.to_string())],
4056                label: Some("get_edge_including_deleted_namespace".into()),
4057            })
4058            .await?;
4059
4060        let Some(SqlValue::Text(record_ns)) = record_ns else {
4061            return Ok(None);
4062        };
4063        // Route through the record's own namespace store (no namespace equality check).
4064        let record_tok = NamespaceToken::for_namespace(
4065            khive_types::Namespace::parse(&record_ns)
4066                .map_err(|e| RuntimeError::Internal(format!("edge namespace invalid: {e}")))?,
4067        );
4068        Ok(self
4069            .graph(&record_tok)?
4070            .get_edge_including_deleted(LinkId::from(edge_id))
4071            .await?)
4072    }
4073
4074    /// Maximum rows returned by a single [`Self::list_edges`] /
4075    /// [`Self::list_edges_after`] page. A lower bound the docs promise callers
4076    /// can rely on; kept as a named constant so tests can exercise pagination
4077    /// (page tiling, out-of-range offsets) without needing >1000 real rows.
4078    pub const EDGE_LIST_MAX_LIMIT: u32 = 1000;
4079
4080    /// List edges matching `filter`, paging by `offset`. `limit` is capped at
4081    /// [`Self::EDGE_LIST_MAX_LIMIT`]; defaults to 100.
4082    ///
4083    /// `offset` pages through the full matching set (previously hard-coded to
4084    /// 0, so every page returned the same first rows). For
4085    /// O(1)-at-depth walks over large edge populations, prefer
4086    /// [`Self::list_edges_after`] instead of paging offset deep.
4087    pub async fn list_edges(
4088        &self,
4089        token: &NamespaceToken,
4090        filter: crate::curation::EdgeListFilter,
4091        limit: u32,
4092        offset: u32,
4093    ) -> RuntimeResult<Vec<Edge>> {
4094        let limit = limit.clamp(1, Self::EDGE_LIST_MAX_LIMIT);
4095        let visible = token.visible_namespaces();
4096
4097        // Common case: a single visible namespace — page directly against the
4098        // store so `offset`/`limit` reach SQL unmodified.
4099        if let [ns] = visible {
4100            let temp = NamespaceToken::for_namespace(ns.clone());
4101            let page = self
4102                .graph(&temp)?
4103                .query_edges(
4104                    filter.into(),
4105                    vec![SortOrder {
4106                        field: EdgeSortField::CreatedAt,
4107                        direction: khive_storage::types::SortDirection::Asc,
4108                    }],
4109                    PageRequest {
4110                        offset: offset.into(),
4111                        limit,
4112                    },
4113                )
4114                .await?;
4115            return Ok(page.items);
4116        }
4117
4118        // Multi-namespace visibility: `offset` must apply to the combined,
4119        // deduplicated set rather than per-namespace pages, so fetch enough
4120        // of each namespace's page to cover it, merge, then slice.
4121        let fetch_limit = offset.saturating_add(limit);
4122        let mut results = Vec::new();
4123        for ns in visible {
4124            let temp = NamespaceToken::for_namespace(ns.clone());
4125            let page = self
4126                .graph(&temp)?
4127                .query_edges(
4128                    filter.clone().into(),
4129                    vec![SortOrder {
4130                        field: EdgeSortField::CreatedAt,
4131                        direction: khive_storage::types::SortDirection::Asc,
4132                    }],
4133                    PageRequest {
4134                        offset: 0,
4135                        limit: fetch_limit,
4136                    },
4137                )
4138                .await?;
4139            results.extend(page.items);
4140        }
4141        results.sort_by_key(|e| Uuid::from(e.id));
4142        results.dedup_by_key(|e| Uuid::from(e.id));
4143        let start = (offset as usize).min(results.len());
4144        let end = (start + limit as usize).min(results.len());
4145        Ok(results[start..end].to_vec())
4146    }
4147
4148    /// Keyset (seek) page of edges matching `filter`, ordered by edge `id`
4149    /// ascending. `after` is the last edge id from the previous page
4150    /// (exclusive); omit to start from the beginning. Returns
4151    /// `(items, next_after)` — `next_after` is `Some` when more rows remain
4152    /// past this page.
4153    ///
4154    /// Unlike [`Self::list_edges`], this is O(log n + limit) at any depth: the
4155    /// underlying store issues an indexed `id > ?` range scan instead of an
4156    /// `OFFSET` skip, avoiding the O(offset) daemon CPU cost of a naive
4157    /// offset-based paging loop over a large edge population.
4158    pub async fn list_edges_after(
4159        &self,
4160        token: &NamespaceToken,
4161        filter: crate::curation::EdgeListFilter,
4162        after: Option<Uuid>,
4163        limit: u32,
4164    ) -> RuntimeResult<(Vec<Edge>, Option<Uuid>)> {
4165        let limit = limit.clamp(1, Self::EDGE_LIST_MAX_LIMIT);
4166        let visible = token.visible_namespaces();
4167        let limit_usize = limit as usize;
4168
4169        if let [ns] = visible {
4170            let temp = NamespaceToken::for_namespace(ns.clone());
4171            let page = self
4172                .graph(&temp)?
4173                .query_edges_after(filter.into(), after, limit)
4174                .await?;
4175            return Ok((page.items, page.next_after));
4176        }
4177
4178        // Multi-namespace visibility: seek each namespace from the same
4179        // cursor (ids are globally unique UUIDs), merge, then take the head
4180        // of the merged set as this page.
4181        let probe_limit = limit + 1;
4182        let mut results = Vec::new();
4183        for ns in visible {
4184            let temp = NamespaceToken::for_namespace(ns.clone());
4185            let page = self
4186                .graph(&temp)?
4187                .query_edges_after(filter.clone().into(), after, probe_limit)
4188                .await?;
4189            results.extend(page.items);
4190        }
4191        results.sort_by_key(|e| Uuid::from(e.id));
4192        results.dedup_by_key(|e| Uuid::from(e.id));
4193        let has_more = results.len() > limit_usize;
4194        if has_more {
4195            results.truncate(limit_usize);
4196        }
4197        let next_after = if has_more {
4198            results.last().map(|e| Uuid::from(e.id))
4199        } else {
4200            None
4201        };
4202        Ok((results, next_after))
4203    }
4204
4205    /// Count edges by relation, ignoring soft-deleted rows. Used by
4206    /// `stats()` to report the true per-relation population so full-graph
4207    /// audits know what they're sampling from before they walk it.
4208    pub async fn count_edges_by_relation(
4209        &self,
4210        token: &NamespaceToken,
4211    ) -> RuntimeResult<std::collections::HashMap<String, u64>> {
4212        let mut totals: std::collections::HashMap<String, u64> = std::collections::HashMap::new();
4213        for ns in token.visible_namespaces() {
4214            let temp = NamespaceToken::for_namespace(ns.clone());
4215            for (relation, count) in self.graph(&temp)?.count_edges_by_relation().await? {
4216                *totals.entry(relation.to_string()).or_insert(0) += count;
4217            }
4218        }
4219        Ok(totals)
4220    }
4221
4222    /// DML-only body of the symmetric-relation conflict-resolution path in
4223    /// [`Self::update_edge`]. Runs the conflict-check SELECT, then either the
4224    /// DELETE+UPDATE (case b, a canonical row already exists) or the
4225    /// in-place UPDATE (case a, no conflict). Callers own the surrounding transaction
4226    /// boundary — this function issues DML only, no `BEGIN`/`COMMIT`/`ROLLBACK`.
4227    ///
4228    /// Returns `Ok(Some(existing_id))` when a canonical conflict was absorbed (the
4229    /// requested edge was deleted, the existing canonical row refreshed), or
4230    /// `Ok(None)` when the requested edge was updated in place.
4231    ///
4232    /// DML text is the single source of truth shared with the atomic
4233    /// `prepare_update_edge` symmetric branch:
4234    /// [`khive_db::stores::graph::EDGE_SYMMETRIC_CONFLICT_PROBE_SQL`] /
4235    /// `EDGE_SYMMETRIC_DELETE_NONCANONICAL_SQL` /
4236    /// `EDGE_SYMMETRIC_REFRESH_CANONICAL_SQL` /
4237    /// `EDGE_SYMMETRIC_UPDATE_INPLACE_SQL` — this function binds them against
4238    /// `rusqlite::params!` (it runs inside an existing transaction on a
4239    /// borrowed `&rusqlite::Connection`), the atomic path binds the same text
4240    /// via `SqlValue` plan params; see the constants' doc comment in
4241    /// `khive-db` for why a single bridge type isn't used for both.
4242    #[allow(clippy::too_many_arguments)]
4243    fn update_edge_symmetric_dml(
4244        conn: &rusqlite::Connection,
4245        ns: &str,
4246        edge_id_str: &str,
4247        canon_src_str: &str,
4248        canon_tgt_str: &str,
4249        relation_str: &str,
4250        weight: f64,
4251        metadata: Option<String>,
4252        target_backend: Option<String>,
4253    ) -> Result<Option<String>, SqliteError> {
4254        // `updated_at` is stored in MICROSECONDS on `graph_edges` (every other
4255        // write path — `edge_upsert_statement`, `edge_soft_delete_statement` —
4256        // uses `timestamp_micros()`; the column is read back via
4257        // `micros_to_datetime`). `timestamp()` (seconds) here was a
4258        // pre-existing bug in this raw-SQL path, found while unifying it with
4259        // the atomic builder (which already used `timestamp_micros()`
4260        // correctly).
4261        let now_ts = chrono::Utc::now().timestamp_micros();
4262
4263        // Check for a conflicting canonical row (same namespace + natural key,
4264        // different id). This catches conflicts whether or not endpoints were flipped.
4265        let conflict_id: Option<String> = conn
4266            .query_row(
4267                khive_db::stores::graph::EDGE_SYMMETRIC_CONFLICT_PROBE_SQL,
4268                rusqlite::params![
4269                    &ns,
4270                    &canon_src_str,
4271                    &canon_tgt_str,
4272                    &relation_str,
4273                    &edge_id_str
4274                ],
4275                |row| row.get(0),
4276            )
4277            .optional()
4278            .map_err(SqliteError::Rusqlite)?;
4279
4280        if let Some(existing_id) = conflict_id {
4281            // Case (b): canonical row already exists — delete the non-canonical
4282            // edge and refresh the existing canonical row. Return the surviving
4283            // id so the caller can re-fetch it (never the deleted edge's id).
4284            conn.execute(
4285                khive_db::stores::graph::EDGE_SYMMETRIC_DELETE_NONCANONICAL_SQL,
4286                rusqlite::params![&ns, &edge_id_str],
4287            )
4288            .map_err(SqliteError::Rusqlite)?;
4289            let affected = conn
4290                .execute(
4291                    khive_db::stores::graph::EDGE_SYMMETRIC_REFRESH_CANONICAL_SQL,
4292                    rusqlite::params![weight, now_ts, target_backend, metadata, &ns, &existing_id],
4293                )
4294                .map_err(SqliteError::Rusqlite)?;
4295            if affected == 0 {
4296                return Err(SqliteError::InvalidData(format!(
4297                    "update_edge: surviving canonical row {existing_id} vanished during update"
4298                )));
4299            }
4300            Ok(Some(existing_id))
4301        } else {
4302            // Case (a): no conflict — update source_id/target_id in-place,
4303            // preserving the original edge UUID.
4304            let affected = conn
4305                .execute(
4306                    khive_db::stores::graph::EDGE_SYMMETRIC_UPDATE_INPLACE_SQL,
4307                    rusqlite::params![
4308                        &canon_src_str,
4309                        &canon_tgt_str,
4310                        &relation_str,
4311                        weight,
4312                        now_ts,
4313                        metadata,
4314                        &ns,
4315                        &edge_id_str,
4316                    ],
4317                )
4318                .map_err(SqliteError::Rusqlite)?;
4319            if affected == 0 {
4320                // The edge row was not found under the record's namespace.
4321                // This must never happen because ns = record_ns (fetched above).
4322                return Err(SqliteError::InvalidData(format!(
4323                    "update_edge: zero rows affected updating edge {edge_id_str} \
4324                     in namespace {ns} — row vanished between fetch and update"
4325                )));
4326            }
4327            Ok(None)
4328        }
4329    }
4330
4331    /// Patch-style edge update. Only `Some(_)` fields are applied.
4332    ///
4333    /// When `relation` is `Some(new_rel)`, validates that the edge's existing endpoints
4334    /// are legal for `new_rel` before persisting. Weight-only updates (`relation = None`)
4335    /// skip validation. Returns `InvalidInput` if the new relation would violate the
4336    /// three-case endpoint contract; the edge is NOT mutated on error.
4337    ///
4338    /// For symmetric relations (`competes_with`, `composed_with`), endpoint order is
4339    /// canonicalised to `source_uuid < target_uuid` after validation. If a canonical
4340    /// row already exists at the target triple, the non-canonical edge is deleted and
4341    /// the existing canonical row is refreshed (DELETE + UPDATE pattern, mirroring
4342    /// `merge_entity_sql`).
4343    pub async fn update_edge(
4344        &self,
4345        token: &NamespaceToken,
4346        edge_id: Uuid,
4347        patch: crate::curation::EdgePatch,
4348    ) -> RuntimeResult<Edge> {
4349        // Fetch the edge by UUID: ID-only, no namespace check.
4350        // get_edge already uses the record's stored namespace internally.
4351        let graph_for_fetch = self.graph(token)?;
4352        let mut edge = graph_for_fetch
4353            .get_edge(LinkId::from(edge_id))
4354            .await?
4355            .ok_or_else(|| crate::RuntimeError::NotFound(format!("edge {edge_id}")))?;
4356
4357        // After fetching, all mutations and validation must use the
4358        // RECORD's namespace, not the caller's.  Derive record_tok from the stored edge
4359        // namespace so that endpoint validation, raw-SQL predicates, and graph routing
4360        // all address the correct backend partition.
4361        let record_ns: String = edge.namespace.clone();
4362        let record_tok = NamespaceToken::for_namespace(
4363            khive_types::Namespace::parse(&record_ns)
4364                .map_err(|e| RuntimeError::Internal(format!("edge namespace invalid: {e}")))?,
4365        );
4366        let graph = self.graph(&record_tok)?;
4367
4368        let mut changed_fields: Vec<&'static str> = Vec::new();
4369        if let Some(r) = patch.relation {
4370            // Validate before mutating — use the existing endpoints with the new relation.
4371            // Use record_tok so that endpoint existence checks look in the edge's own namespace.
4372            self.validate_edge_relation_endpoints(&record_tok, edge.source_id, edge.target_id, r)
4373                .await?;
4374            edge.relation = r;
4375            changed_fields.push("relation");
4376        }
4377        if let Some(w) = patch.weight {
4378            // Reject non-finite or out-of-range weight explicitly; do not silently
4379            // clamp invalid caller input (coding-standards §608-622).
4380            if !w.is_finite() || !(0.0..=1.0).contains(&w) {
4381                return Err(RuntimeError::InvalidInput(format!(
4382                    "edge weight must be a finite value in [0.0, 1.0]; got {w}"
4383                )));
4384            }
4385            edge.weight = w;
4386            changed_fields.push("weight");
4387        }
4388        if let Some(props) = patch.properties {
4389            edge.metadata = Some(props);
4390        }
4391
4392        // For symmetric relations, canonicalise endpoint order and check
4393        // for natural-key conflicts regardless of whether endpoints were flipped.
4394        //
4395        // The raw-SQL path is used for ALL symmetric relations because `upsert_edge`
4396        // resolves ON CONFLICT(namespace,id) first and cannot detect a duplicate at
4397        // the natural key (namespace, source_id, target_id, relation) with a different
4398        // id. Bug-fix: this path must also run when endpoints are already canonical
4399        // (endpoints_flipped=false) to catch conflicts arising from a relation change
4400        // that collides with an existing canonical row.
4401        let (canon_src, canon_tgt) =
4402            canonical_edge_endpoints(edge.relation, edge.source_id, edge.target_id);
4403
4404        if edge.relation.is_symmetric() {
4405            // Raw-SQL path (mirrors merge_entity_sql).
4406            // Use record_ns (the stored edge namespace) — NOT token.namespace() — so that
4407            // WHERE namespace = ?N predicates match the actual row.
4408            let ns = record_ns.clone();
4409            let edge_id_str = edge_id.to_string();
4410            let relation_str = edge.relation.to_string();
4411            let canon_src_str = canon_src.to_string();
4412            let canon_tgt_str = canon_tgt.to_string();
4413            let weight = edge.weight;
4414            let metadata = edge
4415                .metadata
4416                .as_ref()
4417                .map(|v| serde_json::to_string(v).unwrap_or_default());
4418            let target_backend = edge.target_backend.clone();
4419
4420            let pool = self.backend().pool_arc();
4421            // Route through the single-writer task when the write queue is
4422            // enabled; best-effort lookup degrades to the legacy pool-mutex
4423            // path (mirrors merge_entity/merge_note above).
4424            let writer_task = pool.writer_task_handle().ok().flatten();
4425
4426            // Some(surviving_id) when a canonical conflict was absorbed (the requested
4427            // edge was deleted, existing canonical row refreshed), or None when the
4428            // requested edge was updated in-place.
4429            let surviving_id: Option<String> = if let Some(writer_task) = writer_task {
4430                writer_task
4431                    .send(move |conn| {
4432                        Self::update_edge_symmetric_dml(
4433                            conn,
4434                            &ns,
4435                            &edge_id_str,
4436                            &canon_src_str,
4437                            &canon_tgt_str,
4438                            &relation_str,
4439                            weight,
4440                            metadata,
4441                            target_backend,
4442                        )
4443                        .map_err(|e| {
4444                            khive_storage::StorageError::driver(
4445                                khive_storage::StorageCapability::Graph,
4446                                "update_edge",
4447                                e,
4448                            )
4449                        })
4450                    })
4451                    .await
4452                    .map_err(RuntimeError::Storage)?
4453            } else {
4454                tokio::task::spawn_blocking(move || {
4455                    let guard = pool.writer()?;
4456                    guard.transaction(|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                    })
4469                })
4470                .await
4471                .map_err(|e| {
4472                    RuntimeError::Internal(format!("update_edge: spawn_blocking join: {e}"))
4473                })?
4474                .map_err(RuntimeError::Sqlite)?
4475            };
4476
4477            if let Some(sid) = surviving_id {
4478                // A conflict was absorbed: re-fetch the surviving canonical row so the
4479                // caller receives its real id.
4480                // Use record_tok — the surviving row lives in the same namespace as the original.
4481                let surviving_uuid = Uuid::parse_str(&sid).map_err(|e| {
4482                    RuntimeError::Internal(format!("update_edge: surviving id parse failed: {e}"))
4483                })?;
4484                edge = self
4485                    .get_edge(&record_tok, surviving_uuid)
4486                    .await?
4487                    .ok_or_else(|| {
4488                        RuntimeError::Internal(format!(
4489                            "update_edge: surviving canonical row {surviving_uuid} vanished after update"
4490                        ))
4491                    })?;
4492            } else {
4493                // Reflect canonical endpoints in the returned edge (no conflict absorbed).
4494                edge.source_id = canon_src;
4495                edge.target_id = canon_tgt;
4496            }
4497        } else {
4498            // Non-symmetric: upsert_edge takes namespace from edge.namespace (not from the
4499            // graph store's routing namespace), so this is already record-namespace correct.
4500            // `graph` is already self.graph(&record_tok)?.
4501            graph.upsert_edge(edge.clone()).await?;
4502        }
4503
4504        // Audit event: use the record's namespace (record_ns) for the event payload.
4505        let event_store = self.events(&record_tok)?;
4506        let event = khive_storage::event::Event::new(
4507            record_ns.clone(),
4508            "update",
4509            EventKind::EdgeUpdated,
4510            SubstrateKind::Entity,
4511            "",
4512        )
4513        .with_target(edge_id)
4514        .with_payload(
4515            serde_json::json!({"id": edge_id, "namespace": record_ns, "changed_fields": changed_fields}),
4516        );
4517        event_store.append_event(event).await.map_err(|e| {
4518            RuntimeError::Internal(format!("update_edge: event store write failed: {e}"))
4519        })?;
4520
4521        Ok(edge)
4522    }
4523
4524    /// Hard-delete an edge by id.
4525    ///
4526    /// Cascades to remove any `annotates` edges whose target is the deleted edge
4527    /// (`annotates` is note → anything; deleting an edge target leaves annotation
4528    /// edges dangling if not cleaned up). Returns `true` if the primary
4529    /// edge was removed.
4530    ///
4531    /// If `edge_id` does not refer to an edge (e.g. the caller passes an entity or
4532    /// note UUID by mistake), this method returns `Ok(false)` immediately with no
4533    /// side effects — it does **not** cascade inbound edges of the non-edge record.
4534    pub async fn delete_edge(
4535        &self,
4536        token: &NamespaceToken,
4537        edge_id: Uuid,
4538        hard: bool,
4539    ) -> RuntimeResult<bool> {
4540        let mode = if hard {
4541            DeleteMode::Hard
4542        } else {
4543            DeleteMode::Soft
4544        };
4545
4546        // Fetch the edge first to obtain the record's own namespace.
4547        // By-ID ops cross namespace boundaries; all graph routing and audit
4548        // events must use the record namespace, not the caller's (mirrors update_edge).
4549        // For hard delete we also check soft-deleted rows so a soft-deleted edge
4550        // can still be purged via its edge ID.
4551        let edge = if hard {
4552            self.get_edge_including_deleted(token, edge_id).await?
4553        } else {
4554            self.get_edge(token, edge_id).await?
4555        };
4556        let Some(edge) = edge else {
4557            return Ok(false);
4558        };
4559
4560        // Derive record_ns / record_tok from the fetched edge (mirrors update_edge).
4561        let record_ns: String = edge.namespace.clone();
4562        let record_tok = NamespaceToken::for_namespace(
4563            khive_types::Namespace::parse(&record_ns)
4564                .map_err(|e| RuntimeError::Internal(format!("edge namespace invalid: {e}")))?,
4565        );
4566        let graph = self.graph(&record_tok)?;
4567
4568        // Cascade: on hard delete, remove ALL annotates edges targeting this edge — including
4569        // already-soft-deleted ones: to prevent dangling graph_edges rows. The row
4570        // delete and the cascade purge run as ONE write transaction: see
4571        // `atomic_hard_delete_with_edge_purge`.
4572        // On soft delete the cascade is skipped (data-vs-view principle: soft-deleting the base
4573        // edge does not cascade to annotation edges; only a hard purge cleans up incident rows).
4574        let deleted = if hard {
4575            self.atomic_hard_delete_with_edge_purge(edge_hard_delete_statement(edge_id), edge_id)
4576                .await?
4577        } else {
4578            graph.delete_edge(LinkId::from(edge_id), mode).await?
4579        };
4580        if deleted {
4581            // Audit event: use the record's namespace (record_ns), not the caller's namespace.
4582            let event_store = self.events(&record_tok)?;
4583            let event = khive_storage::event::Event::new(
4584                record_ns.clone(),
4585                "delete",
4586                EventKind::EdgeDeleted,
4587                SubstrateKind::Entity,
4588                "",
4589            )
4590            .with_target(edge_id)
4591            .with_payload(serde_json::json!({"id": edge_id, "namespace": record_ns, "hard": hard}));
4592            event_store.append_event(event).await.map_err(|e| {
4593                RuntimeError::Internal(format!("delete_edge: event store write failed: {e}"))
4594            })?;
4595        }
4596        Ok(deleted)
4597    }
4598
4599    /// Count edges matching `filter`.
4600    pub async fn count_edges(
4601        &self,
4602        token: &NamespaceToken,
4603        filter: crate::curation::EdgeListFilter,
4604    ) -> RuntimeResult<u64> {
4605        Ok(self.graph(token)?.count_edges(filter.into()).await?)
4606    }
4607
4608    /// Validate and construct an edge from a [`LinkSpec`] without writing to storage.
4609    ///
4610    /// Applies the full edge contract (endpoint validation, symmetric
4611    /// canonicalization, `dependency_kind` inference and metadata validation).
4612    /// Returns the constructed `Edge` on success; the caller is responsible for
4613    /// persisting it (e.g. via `upsert_edge` or `link_many`).
4614    ///
4615    /// The `token` must be a pre-authorized namespace token from the dispatch
4616    /// layer. If `spec.namespace` is set it must match `token.namespace()`;
4617    /// a mismatch returns `RuntimeError::InvalidInput`.
4618    pub async fn build_edge(&self, token: &NamespaceToken, spec: &LinkSpec) -> RuntimeResult<Edge> {
4619        let ns_str = match &spec.namespace {
4620            Some(s) => {
4621                let spec_ns = crate::Namespace::parse(s)
4622                    .map_err(|e| RuntimeError::InvalidInput(format!("invalid namespace: {e}")))?;
4623                if &spec_ns != token.namespace() {
4624                    return Err(RuntimeError::InvalidInput(
4625                        "LinkSpec namespace does not match token namespace".into(),
4626                    ));
4627                }
4628                s.as_str()
4629            }
4630            None => token.namespace().as_str(),
4631        };
4632        self.validate_edge_relation_endpoints(token, spec.source_id, spec.target_id, spec.relation)
4633            .await?;
4634        let (source_id, target_id) =
4635            canonical_edge_endpoints(spec.relation, spec.source_id, spec.target_id);
4636        let metadata = if spec.relation == EdgeRelation::DependsOn {
4637            // By-ID, unfiltered — matches the namespace-agnostic endpoint validation
4638            // above. The visible-set-scoped `resolve` would silently drop the
4639            // dependency_kind inference for endpoints validation now allows outside
4640            // the caller's visible set.
4641            match (
4642                self.resolve_edge_endpoint(token, source_id).await?,
4643                self.resolve_edge_endpoint(token, target_id).await?,
4644            ) {
4645                (Some(Resolved::Entity(src_e)), Some(Resolved::Entity(tgt_e))) => {
4646                    merge_dependency_kind(&src_e.kind, &tgt_e.kind, spec.metadata.clone())
4647                }
4648                _ => spec.metadata.clone(),
4649            }
4650        } else {
4651            spec.metadata.clone()
4652        };
4653        validate_edge_metadata(spec.relation, metadata.as_ref())?;
4654        let now = chrono::Utc::now();
4655        Ok(Edge {
4656            id: LinkId::from(Uuid::new_v4()),
4657            namespace: ns_str.to_string(),
4658            source_id,
4659            target_id,
4660            relation: spec.relation,
4661            weight: spec.weight,
4662            created_at: now,
4663            updated_at: now,
4664            deleted_at: None,
4665            metadata,
4666            target_backend: None,
4667        })
4668    }
4669
4670    /// Validate and atomically upsert a batch of edges.
4671    ///
4672    /// All edges are validated and constructed with `build_edge` before any
4673    /// write. If validation fails for any entry the entire batch is rejected
4674    /// (no writes occur). On success, all edges are persisted in a single
4675    /// atomic transaction via `upsert_edges`.
4676    ///
4677    /// After the bulk upsert, each edge is read back by its natural key
4678    /// (namespace, source_id, target_id, relation) so that the returned IDs
4679    /// are always the persisted row IDs, not the locally-generated UUIDs that
4680    /// may have been displaced by an ON CONFLICT DO UPDATE. This mirrors the
4681    /// same read-back applied to singleton `link()` and prevents phantom-ID
4682    /// exposure when callers upsert overlapping triples with `verbose=true`.
4683    ///
4684    /// All specs must share the same namespace; the namespace is taken from
4685    /// `token` (or validated against it if `spec.namespace` is set).
4686    pub async fn link_many(
4687        &self,
4688        token: &NamespaceToken,
4689        specs: Vec<LinkSpec>,
4690    ) -> RuntimeResult<Vec<Edge>> {
4691        if specs.is_empty() {
4692            return Ok(vec![]);
4693        }
4694        let mut edges = Vec::with_capacity(specs.len());
4695        for spec in &specs {
4696            edges.push(self.build_edge(token, spec).await?);
4697        }
4698        // `upsert_edges_guarded` re-checks every edge's endpoints as part of the
4699        // same write, not the separate per-spec `build_edge` validation reads
4700        // above. A concurrent hard-delete of any endpoint landing between those
4701        // reads and this write aborts the whole batch (all-or-nothing, no
4702        // partial write) instead of persisting a dangling edge. The failing
4703        // entry's index and its missing endpoint(s) come from the guard's own
4704        // in-transaction pre-check (`GuardedBatchOutcome::refused`), not a
4705        // post-hoc re-read of the batch after the write already failed.
4706        let outcome = self
4707            .graph(token)?
4708            .upsert_edges_guarded(edges.clone())
4709            .await?;
4710        if let Some(refusal) = outcome.refused {
4711            return Err(RuntimeError::GuardedWriteFailed(GuardedWriteFailure {
4712                entry_index: Some(refusal.entry_index),
4713                missing_source: refusal
4714                    .missing
4715                    .source
4716                    .then_some(edges[refusal.entry_index].source_id),
4717                missing_target: refusal
4718                    .missing
4719                    .target
4720                    .then_some(edges[refusal.entry_index].target_id),
4721            }));
4722        }
4723        if outcome.summary.affected != edges.len() as u64 {
4724            return Err(RuntimeError::NotFound(format!(
4725                "link_many: one or more edge endpoints no longer exist at write time: {}",
4726                outcome.summary.first_error
4727            )));
4728        }
4729
4730        // Read back each persisted edge by natural key so callers always
4731        // receive the stored row ID, not the pre-upsert generated UUID.
4732        let mut persisted = Vec::with_capacity(edges.len());
4733        for edge in &edges {
4734            let row = self
4735                .list_edges(
4736                    token,
4737                    crate::curation::EdgeListFilter {
4738                        source_id: Some(edge.source_id),
4739                        target_id: Some(edge.target_id),
4740                        relations: vec![edge.relation],
4741                        ..Default::default()
4742                    },
4743                    1,
4744                    0,
4745                )
4746                .await?
4747                .into_iter()
4748                .next()
4749                .ok_or_else(|| {
4750                    crate::RuntimeError::Internal(format!(
4751                        "upsert_edges succeeded but natural-key lookup for ({}, {}, {}) returned nothing",
4752                        edge.source_id, edge.target_id, edge.relation.as_str()
4753                    ))
4754                })?;
4755            persisted.push(row);
4756        }
4757        Ok(persisted)
4758    }
4759
4760    /// Create a batch of entities atomically.
4761    ///
4762    /// All specs are validated before any write. If ANY spec fails validation
4763    /// (unknown kind, empty name, secret-gate violation), the method returns
4764    /// that error and no entities are written.
4765    ///
4766    /// Storage writes are issued as ONE `upsert_entities` call followed by ONE
4767    /// `upsert_documents` call — the same primitives that the single-entity path
4768    /// uses, but batched. Embedding is intentionally skipped: bulk structural
4769    /// ingest is the expected use-case, and dense vectors are backfilled later
4770    /// via a `reindex` call. Callers that need immediate vector search
4771    /// immediately after creation should use per-entity `create_entity` instead.
4772    ///
4773    /// On FTS failure, every newly written entity row is hard-deleted to maintain
4774    /// consistency (mirrors the single-entity rollback in `create_entity`).
4775    pub async fn create_many(
4776        &self,
4777        token: &NamespaceToken,
4778        specs: Vec<EntityCreateSpec>,
4779    ) -> RuntimeResult<Vec<Entity>> {
4780        if specs.is_empty() {
4781            return Ok(vec![]);
4782        }
4783        let ns = token.namespace().as_str();
4784
4785        // Phase 1: validate ALL specs before any write.
4786        // Includes entity-type validation via the pack-installed validator when available.
4787        // Any validation failure here guarantees zero rows are written.
4788        let mut entities = Vec::with_capacity(specs.len());
4789        for spec in &specs {
4790            self.validate_entity_kind(&spec.kind)?;
4791            // Validate entity_type at the runtime layer via pack-installed callback.
4792            // When no validator is installed (bare runtime, unit tests without packs),
4793            // the type passes through unchanged — same skip-when-absent pattern as
4794            // validate_entity_kind. The handler layer remains the primary enforcement point.
4795            let validated_type =
4796                self.validate_entity_type_for_kind(&spec.kind, spec.entity_type.as_deref())?;
4797            if spec.name.trim().is_empty() {
4798                return Err(RuntimeError::InvalidInput("name must not be empty".into()));
4799            }
4800            crate::secret_gate::check(&spec.name)?;
4801            if let Some(d) = &spec.description {
4802                crate::secret_gate::check(d)?;
4803            }
4804            if let Some(ref p) = spec.properties {
4805                crate::secret_gate::check_json(p)?;
4806            }
4807            crate::secret_gate::check_tags(&spec.tags)?;
4808
4809            let mut entity =
4810                Entity::new(ns, &spec.kind, &spec.name).with_entity_type(validated_type.as_deref());
4811            if let Some(d) = &spec.description {
4812                entity = entity.with_description(d);
4813            }
4814            if let Some(p) = spec.properties.clone() {
4815                entity = entity.with_properties(p);
4816            }
4817            if !spec.tags.is_empty() {
4818                entity = entity.with_tags(spec.tags.clone());
4819            }
4820            entities.push(entity);
4821        }
4822
4823        // Phase 2: single bulk entity write.
4824        // Capture the BatchWriteSummary to detect partial failures.
4825        // The store commits the transaction even when some rows fail (per-row error
4826        // isolation). If any row failed, compensate by hard-deleting the rows that DID
4827        // land, then return Err so the caller sees zero net writes.
4828        //
4829        // NOTE: this compensation path (delete-on-partial-failure) is a stopgap until
4830        // a true single-transaction bulk primitive is available in the entity store.
4831        // That primitive (writing entity rows and FTS rows in one SQL transaction) is
4832        // tracked as a follow-up issue.
4833        let entity_summary = self
4834            .entities(token)?
4835            .upsert_entities(entities.clone())
4836            .await?;
4837
4838        if entity_summary.failed > 0 {
4839            // Compensate: hard-delete any entity rows that did land.
4840            if let Ok(store) = self.entities(token) {
4841                for entity in &entities {
4842                    if let Err(ce) = store.delete_entity(entity.id, DeleteMode::Hard).await {
4843                        tracing::error!(
4844                            error = %ce,
4845                            id = %entity.id,
4846                            "create_many: failed to roll back entity row after partial entity write"
4847                        );
4848                    }
4849                }
4850            }
4851            return Err(RuntimeError::Internal(format!(
4852                "create_many: {}/{} entity rows failed to write (first error: {}); \
4853                 all rows rolled back",
4854                entity_summary.failed, entity_summary.attempted, entity_summary.first_error
4855            )));
4856        }
4857
4858        // Phase 3: single bulk FTS write.
4859        //
4860        // The FTS store commits partial batches and signals per-document failures
4861        // via BatchWriteSummary.failed (same as the entity store in Phase 2).
4862        // We must capture the summary and treat failed > 0 as an error.
4863        //
4864        // Compensation is symmetric: on any FTS failure (Err or failed > 0),
4865        // we first delete any FTS documents that may have landed, then
4866        // hard-delete the entity rows.  This order matters: the entity delete
4867        // is the authoritative write; FTS is a derived index.  Cleaning FTS
4868        // first avoids a window where entity rows are gone but stale FTS rows
4869        // survive.
4870        let docs: Vec<_> = entities.iter().map(entity_fts_document).collect();
4871
4872        #[cfg(any(test, feature = "fault-injection"))]
4873        let fts_many_inject = {
4874            let mut g = FTS_FAIL_MANY_NS.lock().unwrap();
4875            if g.as_deref() == Some(ns) {
4876                *g = None;
4877                true
4878            } else {
4879                false
4880            }
4881        };
4882        #[cfg(not(any(test, feature = "fault-injection")))]
4883        let fts_many_inject = false;
4884
4885        // Partial-failure seam: returns Ok(summary) with failed > 0 so the
4886        // `summary.failed > 0` rollback branch is exercised in tests.
4887        #[cfg(any(test, feature = "fault-injection"))]
4888        let fts_many_inject_partial = {
4889            let mut g = FTS_FAIL_MANY_PARTIAL_NS.lock().unwrap();
4890            if g.as_deref() == Some(ns) {
4891                *g = None;
4892                true
4893            } else {
4894                false
4895            }
4896        };
4897        #[cfg(not(any(test, feature = "fault-injection")))]
4898        let fts_many_inject_partial = false;
4899
4900        let fts_summary_result: RuntimeResult<BatchWriteSummary> = if fts_many_inject {
4901            Err(RuntimeError::Internal(
4902                "injected FTS failure for create_many".to_string(),
4903            ))
4904        } else if fts_many_inject_partial {
4905            Ok(BatchWriteSummary {
4906                attempted: docs.len() as u64,
4907                affected: docs.len().saturating_sub(1) as u64,
4908                failed: 1,
4909                first_error: "injected partial FTS failure for create_many".to_string(),
4910            })
4911        } else {
4912            match self.text(token) {
4913                Ok(fts) => fts.upsert_documents(docs).await.map_err(RuntimeError::from),
4914                Err(e) => Err(e),
4915            }
4916        };
4917
4918        let fts_err: Option<RuntimeError> = match fts_summary_result {
4919            Err(e) => Some(e),
4920            Ok(summary) if summary.failed > 0 => Some(RuntimeError::Internal(format!(
4921                "create_many: {}/{} FTS rows failed to index (first error: {}); \
4922                 all rows rolled back",
4923                summary.failed, summary.attempted, summary.first_error
4924            ))),
4925            Ok(_) => None,
4926        };
4927
4928        if let Some(e) = fts_err {
4929            // Clean up any FTS docs that landed before deleting entity rows.
4930            if let Ok(fts) = self.text(token) {
4931                for entity in &entities {
4932                    if let Err(ce) = fts.delete_document(ns, entity.id).await {
4933                        tracing::error!(
4934                            error = %ce,
4935                            id = %entity.id,
4936                            "create_many: failed to remove FTS doc during rollback"
4937                        );
4938                    }
4939                }
4940            }
4941            if let Ok(store) = self.entities(token) {
4942                for entity in &entities {
4943                    if let Err(ce) = store.delete_entity(entity.id, DeleteMode::Hard).await {
4944                        tracing::error!(
4945                            error = %ce,
4946                            id = %entity.id,
4947                            "create_many: failed to roll back entity row after FTS failure"
4948                        );
4949                    }
4950                }
4951            }
4952            return Err(e);
4953        }
4954
4955        // Embedding is skipped intentionally — see doc comment above.
4956        Ok(entities)
4957    }
4958}
4959
4960/// Fully specified edge creation request — input to [`KhiveRuntime::build_edge`]
4961/// and [`KhiveRuntime::link_many`].
4962#[derive(Clone, Debug)]
4963pub struct LinkSpec {
4964    pub namespace: Option<String>,
4965    pub source_id: Uuid,
4966    pub target_id: Uuid,
4967    pub relation: EdgeRelation,
4968    pub weight: f64,
4969    pub metadata: Option<serde_json::Value>,
4970}
4971
4972/// Fully specified entity creation request — input to [`KhiveRuntime::create_many`].
4973///
4974/// `entity_type` is validated at the runtime layer by the pack-installed
4975/// entity-type validator. When a validator
4976/// is installed (e.g. by `KgPack`), unknown types are rejected with the valid
4977/// set listed. When no validator is installed (bare runtime without packs),
4978/// the value passes through — the handler layer is the primary enforcement point.
4979#[derive(Clone, Debug)]
4980pub struct EntityCreateSpec {
4981    pub kind: String,
4982    pub entity_type: Option<String>,
4983    pub name: String,
4984    pub description: Option<String>,
4985    pub properties: Option<serde_json::Value>,
4986    pub tags: Vec<String>,
4987}
4988
4989// INLINE TEST JUSTIFICATION: tests here exercise private helpers (canonical_edge_endpoints,
4990// validate_edge_metadata, merge_dependency_kind, link-fail injection) and runtime methods
4991// that require pub(crate) KhiveRuntime construction. Moving them to tests/ would require
4992// pub-exporting those private helpers, which would widen the crate's public API surface
4993// undesirably. Broad behavioral tests live in tests/integration.rs.
4994#[cfg(test)]
4995mod tests {
4996    use super::*;
4997    use crate::curation::EdgeListFilter;
4998    use crate::embedder_registry::EmbedderProvider;
4999    use crate::error::RuntimeError;
5000    use crate::runtime::{KhiveRuntime, NamespaceToken};
5001    use crate::{ActorRef, Namespace};
5002    use async_trait::async_trait;
5003    use khive_storage::types::PathNode;
5004    use lattice_embed::{EmbedError, EmbeddingModel, EmbeddingService};
5005    use std::sync::atomic::{AtomicUsize, Ordering};
5006    use std::sync::Arc;
5007
5008    fn rt() -> KhiveRuntime {
5009        KhiveRuntime::memory().unwrap()
5010    }
5011
5012    // ── Custom embedder fan-out regression ──────────────────────────────────
5013    // A runtime with no `config.embedding_model` but a custom registered
5014    // embedder must fan out create_note through that embedder and store a
5015    // vector so recall can find the note.
5016
5017    /// Trivial constant-vector embedding service.  The model argument is ignored;
5018    /// the service always returns a synthetic `dims × 1.0f32` vector.
5019    struct ConstVecService {
5020        dims: usize,
5021    }
5022
5023    #[async_trait]
5024    impl EmbeddingService for ConstVecService {
5025        async fn embed(
5026            &self,
5027            texts: &[String],
5028            _model: EmbeddingModel,
5029        ) -> std::result::Result<Vec<Vec<f32>>, EmbedError> {
5030            Ok(texts.iter().map(|_| vec![1.0_f32; self.dims]).collect())
5031        }
5032
5033        fn supports_model(&self, _model: EmbeddingModel) -> bool {
5034            true
5035        }
5036
5037        fn name(&self) -> &'static str {
5038            "const-vec"
5039        }
5040    }
5041
5042    struct ConstVecProvider {
5043        provider_name: String,
5044        dims: usize,
5045        pub build_count: Arc<AtomicUsize>,
5046    }
5047
5048    impl ConstVecProvider {
5049        fn new(name: &str, dims: usize) -> (Self, Arc<AtomicUsize>) {
5050            let counter = Arc::new(AtomicUsize::new(0));
5051            let provider = Self {
5052                provider_name: name.to_owned(),
5053                dims,
5054                build_count: Arc::clone(&counter),
5055            };
5056            (provider, counter)
5057        }
5058    }
5059
5060    #[async_trait]
5061    impl EmbedderProvider for ConstVecProvider {
5062        fn name(&self) -> &str {
5063            &self.provider_name
5064        }
5065
5066        fn dimensions(&self) -> usize {
5067            self.dims
5068        }
5069
5070        async fn build(&self) -> crate::error::RuntimeResult<Arc<dyn EmbeddingService>> {
5071            self.build_count.fetch_add(1, Ordering::SeqCst);
5072            Ok(Arc::new(ConstVecService { dims: self.dims }))
5073        }
5074    }
5075
5076    /// Custom embedder with no lattice model in config must participate in
5077    /// fan-out: the gate must check `registered_embedding_model_names()`, not
5078    /// `config().embedding_model.is_some()`: the latter falls through to
5079    /// `vec![]` when only a custom provider is registered.
5080    #[tokio::test]
5081    async fn custom_embedder_only_runtime_fanout_stores_vector() {
5082        const MODEL_NAME: &str = "test-custom-encoder";
5083        const DIMS: usize = 8;
5084
5085        // Build a runtime with no lattice embedding_model.
5086        let rt = KhiveRuntime::memory().unwrap();
5087
5088        // Register the custom provider — this is the only embedder configured.
5089        let (provider, _counter) = ConstVecProvider::new(MODEL_NAME, DIMS);
5090        rt.register_embedder(provider);
5091
5092        // Sanity: config.embedding_model is None, but the registry has one entry.
5093        assert!(rt.config().embedding_model.is_none());
5094        assert_eq!(rt.registered_embedding_model_names(), vec![MODEL_NAME]);
5095
5096        let tok = NamespaceToken::local();
5097
5098        // create_note should fan out to the custom embedder and store a vector.
5099        let note = rt
5100            .create_note(
5101                &tok,
5102                "memory",
5103                None,
5104                "custom embedder integration test content",
5105                Some(0.7),
5106                None,
5107                vec![],
5108            )
5109            .await
5110            .expect("create_note with custom-only embedder must succeed");
5111
5112        // Verify: a vector was written in the custom model's store.
5113        use khive_storage::types::VectorSearchRequest;
5114        let query_vec = vec![1.0_f32; DIMS];
5115        let hits = rt
5116            .vectors_for_model(&tok, MODEL_NAME)
5117            .expect("vector store for custom model must be accessible")
5118            .search(VectorSearchRequest {
5119                query_vectors: vec![query_vec],
5120                top_k: 5,
5121                namespace: Some(tok.namespace().as_str().to_string()),
5122                kind: Some(khive_types::SubstrateKind::Note),
5123                embedding_model: Some(MODEL_NAME.to_string()),
5124                filter: None,
5125                backend_hints: None,
5126            })
5127            .await
5128            .expect("vector search succeeds");
5129
5130        assert!(
5131            hits.iter().any(|h| h.subject_id == note.id),
5132            "custom embedder must have written a vector for note {}: hits={hits:?}",
5133            note.id
5134        );
5135    }
5136
5137    /// Custom-only embedder participates in `embed_with_model` so recall
5138    /// fan-out also works: the lattice alias parse must be optional, with
5139    /// the embedder registry consulted directly, since requiring a lattice
5140    /// alias would reject valid custom provider names with `UnknownModel`.
5141    #[tokio::test]
5142    async fn embed_with_model_accepts_custom_provider_name() {
5143        const MODEL_NAME: &str = "my-custom-enc";
5144        const DIMS: usize = 4;
5145
5146        let rt = KhiveRuntime::memory().unwrap();
5147        let (provider, _counter) = ConstVecProvider::new(MODEL_NAME, DIMS);
5148        rt.register_embedder(provider);
5149
5150        let result = rt
5151            .embed_with_model(MODEL_NAME, "hello world")
5152            .await
5153            .expect("embed_with_model must accept custom provider names");
5154
5155        assert_eq!(
5156            result.len(),
5157            DIMS,
5158            "embedding dimension must match provider"
5159        );
5160        assert!(
5161            result.iter().all(|&v| (v - 1.0_f32).abs() < 1e-6),
5162            "ConstVecService must produce all-ones vector; got: {result:?}"
5163        );
5164    }
5165
5166    /// `embed_with_model` must still reject names that are not in the
5167    /// registry (neither lattice aliases nor custom providers).
5168    #[tokio::test]
5169    async fn embed_with_model_rejects_unregistered_name() {
5170        let rt = KhiveRuntime::memory().unwrap();
5171        let result = rt.embed_with_model("nonexistent-model", "hello").await;
5172        assert!(
5173            matches!(result.unwrap_err(), RuntimeError::UnknownModel(ref n) if n == "nonexistent-model"),
5174            "unregistered model name must return UnknownModel"
5175        );
5176    }
5177
5178    // ── No-embeddings config regression ─────────────────────────────────────
5179    // `RuntimeConfig::no_embeddings()` must register zero embedders, so
5180    // `create_note` never attempts to lazily build a lattice embedding model —
5181    // this is what lets `memory.remember` succeed on a machine with no local
5182    // model files present.
5183
5184    #[tokio::test]
5185    async fn no_embeddings_config_registers_zero_embedders() {
5186        let config = crate::config::RuntimeConfig {
5187            db_path: None,
5188            packs: vec!["kg".to_string()],
5189            ..crate::config::RuntimeConfig::no_embeddings()
5190        };
5191        let rt = KhiveRuntime::new(config).expect("runtime construction must succeed");
5192
5193        assert!(rt.config().embedding_model.is_none());
5194        assert!(
5195            rt.registered_embedding_model_names().is_empty(),
5196            "no_embeddings() runtime must register zero embedders"
5197        );
5198    }
5199
5200    #[tokio::test]
5201    async fn no_embeddings_runtime_create_note_succeeds_without_model_fanout() {
5202        let config = crate::config::RuntimeConfig {
5203            db_path: None,
5204            packs: vec!["kg".to_string()],
5205            ..crate::config::RuntimeConfig::no_embeddings()
5206        };
5207        let rt = KhiveRuntime::new(config).expect("runtime construction must succeed");
5208        let tok = NamespaceToken::local();
5209
5210        // With zero registered embedders, create_note's embed fan-out list is
5211        // empty and no lattice model build is ever attempted -- the write must
5212        // succeed, degrading to FTS-only.
5213        let note = rt
5214            .create_note(
5215                &tok,
5216                "memory",
5217                None,
5218                "issue-396 regression: model-less remember must succeed",
5219                Some(0.7),
5220                None,
5221                vec![],
5222            )
5223            .await
5224            .expect("create_note must succeed with zero registered embedders");
5225
5226        assert_eq!(
5227            note.content,
5228            "issue-396 regression: model-less remember must succeed"
5229        );
5230    }
5231
5232    #[tokio::test]
5233    async fn update_edge_changes_weight() {
5234        let rt = rt();
5235        let tok = NamespaceToken::local();
5236        let a = rt
5237            .create_entity(&tok, "concept", None, "A", None, None, vec![])
5238            .await
5239            .unwrap();
5240        let b = rt
5241            .create_entity(&tok, "concept", None, "B", None, None, vec![])
5242            .await
5243            .unwrap();
5244        let edge = rt
5245            .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
5246            .await
5247            .unwrap();
5248        let edge_id: Uuid = edge.id.into();
5249
5250        let updated = rt
5251            .update_edge(
5252                &tok,
5253                edge_id,
5254                crate::curation::EdgePatch {
5255                    weight: Some(0.5),
5256                    ..Default::default()
5257                },
5258            )
5259            .await
5260            .unwrap();
5261        assert!((updated.weight - 0.5).abs() < 0.001);
5262    }
5263
5264    /// Regression test: `update_edge_symmetric_dml` previously stored
5265    /// `updated_at` via `chrono::Utc::now().timestamp()` (SECONDS) while every
5266    /// other `graph_edges` write path (`edge_upsert_statement`,
5267    /// `edge_soft_delete_statement`) uses `timestamp_micros()`: a genuine
5268    /// pre-existing bug, found while unifying this raw-SQL path with the
5269    /// atomic builder (which already used `timestamp_micros()` correctly)
5270    /// onto the shared `EDGE_SYMMETRIC_*_SQL` text. A seconds value misread as
5271    /// microseconds round-trips to a date a few minutes after the Unix epoch,
5272    /// not "now".
5273    #[tokio::test]
5274    async fn update_edge_symmetric_relation_stores_microsecond_updated_at() {
5275        let rt = rt();
5276        let tok = NamespaceToken::local();
5277        let a = rt
5278            .create_entity(&tok, "concept", None, "A", None, None, vec![])
5279            .await
5280            .unwrap();
5281        let b = rt
5282            .create_entity(&tok, "concept", None, "B", None, None, vec![])
5283            .await
5284            .unwrap();
5285        let edge = rt
5286            .link(&tok, a.id, b.id, EdgeRelation::CompetesWith, 1.0, None)
5287            .await
5288            .unwrap();
5289        let edge_id: Uuid = edge.id.into();
5290
5291        let before = chrono::Utc::now();
5292        let updated = rt
5293            .update_edge(
5294                &tok,
5295                edge_id,
5296                crate::curation::EdgePatch {
5297                    weight: Some(0.5),
5298                    ..Default::default()
5299                },
5300            )
5301            .await
5302            .unwrap();
5303
5304        let drift = (updated.updated_at - before).num_seconds().abs();
5305        assert!(
5306            drift < 60,
5307            "updated_at must round-trip as a recent timestamp (micros, not \
5308             seconds); got {:?}, expected within 60s of {:?}",
5309            updated.updated_at,
5310            before
5311        );
5312    }
5313
5314    #[tokio::test]
5315    async fn update_edge_changes_relation() {
5316        let rt = rt();
5317        let tok = NamespaceToken::local();
5318        let a = rt
5319            .create_entity(&tok, "concept", None, "A", None, None, vec![])
5320            .await
5321            .unwrap();
5322        let b = rt
5323            .create_entity(&tok, "concept", None, "B", None, None, vec![])
5324            .await
5325            .unwrap();
5326        let edge = rt
5327            .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
5328            .await
5329            .unwrap();
5330        let edge_id: Uuid = edge.id.into();
5331
5332        let updated = rt
5333            .update_edge(
5334                &tok,
5335                edge_id,
5336                crate::curation::EdgePatch {
5337                    relation: Some(EdgeRelation::VariantOf),
5338                    ..Default::default()
5339                },
5340            )
5341            .await
5342            .unwrap();
5343        assert_eq!(updated.relation, EdgeRelation::VariantOf);
5344    }
5345
5346    // ---- update_edge endpoint validation ----
5347
5348    // update_edge: note→entity annotates → set relation=Supersedes → InvalidInput (crossing).
5349    // Edge must NOT be mutated in the store.
5350    #[tokio::test]
5351    async fn update_edge_annotates_note_to_entity_set_supersedes_returns_invalid_input() {
5352        let rt = rt();
5353        let tok = NamespaceToken::local();
5354        let note = rt
5355            .create_note(&tok, "observation", None, "a note", Some(0.5), None, vec![])
5356            .await
5357            .unwrap();
5358        let entity = rt
5359            .create_entity(&tok, "concept", None, "E", None, None, vec![])
5360            .await
5361            .unwrap();
5362        // Create a valid note→entity annotates edge.
5363        let edge = rt
5364            .link(&tok, note.id, entity.id, EdgeRelation::Annotates, 1.0, None)
5365            .await
5366            .unwrap();
5367        let edge_id: Uuid = edge.id.into();
5368
5369        // Attempt to change relation to Supersedes (crossing substrates → invalid).
5370        let result = rt
5371            .update_edge(
5372                &tok,
5373                edge_id,
5374                crate::curation::EdgePatch {
5375                    relation: Some(EdgeRelation::Supersedes),
5376                    ..Default::default()
5377                },
5378            )
5379            .await;
5380        assert!(
5381            matches!(result, Err(RuntimeError::InvalidInput(_))),
5382            "update to Supersedes on note→entity edge must return InvalidInput, got {result:?}"
5383        );
5384
5385        // Edge must NOT be mutated — re-fetch and verify relation unchanged.
5386        let fetched = rt.get_edge(&tok, edge_id).await.unwrap().unwrap();
5387        assert_eq!(
5388            fetched.relation,
5389            EdgeRelation::Annotates,
5390            "edge relation must be unchanged after failed update"
5391        );
5392    }
5393
5394    // update_edge: entity→entity extends → set relation=Annotates → InvalidInput
5395    // (annotates source must be a note).
5396    #[tokio::test]
5397    async fn update_edge_entity_to_entity_set_annotates_returns_invalid_input() {
5398        let rt = rt();
5399        let tok = NamespaceToken::local();
5400        let a = rt
5401            .create_entity(&tok, "concept", None, "A", None, None, vec![])
5402            .await
5403            .unwrap();
5404        let b = rt
5405            .create_entity(&tok, "concept", None, "B", None, None, vec![])
5406            .await
5407            .unwrap();
5408        let edge = rt
5409            .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
5410            .await
5411            .unwrap();
5412        let edge_id: Uuid = edge.id.into();
5413
5414        let result = rt
5415            .update_edge(
5416                &tok,
5417                edge_id,
5418                crate::curation::EdgePatch {
5419                    relation: Some(EdgeRelation::Annotates),
5420                    ..Default::default()
5421                },
5422            )
5423            .await;
5424        assert!(
5425            matches!(result, Err(RuntimeError::InvalidInput(_))),
5426            "update to Annotates on entity→entity edge must return InvalidInput, got {result:?}"
5427        );
5428    }
5429
5430    // update_edge: entity→entity extends → set relation=Supersedes → Ok
5431    // (entity→entity is valid for supersedes).
5432    #[tokio::test]
5433    async fn update_edge_entity_to_entity_set_supersedes_succeeds() {
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 updated = rt
5451            .update_edge(
5452                &tok,
5453                edge_id,
5454                crate::curation::EdgePatch {
5455                    relation: Some(EdgeRelation::Supersedes),
5456                    ..Default::default()
5457                },
5458            )
5459            .await
5460            .unwrap();
5461        assert_eq!(updated.relation, EdgeRelation::Supersedes);
5462
5463        // Verify persisted.
5464        let fetched = rt.get_edge(&tok, edge_id).await.unwrap().unwrap();
5465        assert_eq!(fetched.relation, EdgeRelation::Supersedes);
5466    }
5467
5468    // update_edge: weight-only (relation = None) → Ok, no validation, unchanged relation.
5469    #[tokio::test]
5470    async fn update_edge_weight_only_skips_validation() {
5471        let rt = rt();
5472        let tok = NamespaceToken::local();
5473        let a = rt
5474            .create_entity(&tok, "concept", None, "A", None, None, vec![])
5475            .await
5476            .unwrap();
5477        let b = rt
5478            .create_entity(&tok, "concept", None, "B", None, None, vec![])
5479            .await
5480            .unwrap();
5481        let edge = rt
5482            .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
5483            .await
5484            .unwrap();
5485        let edge_id: Uuid = edge.id.into();
5486
5487        let updated = rt
5488            .update_edge(
5489                &tok,
5490                edge_id,
5491                crate::curation::EdgePatch {
5492                    weight: Some(0.3),
5493                    ..Default::default()
5494                },
5495            )
5496            .await
5497            .unwrap();
5498        assert_eq!(updated.relation, EdgeRelation::Extends);
5499        assert!((updated.weight - 0.3).abs() < 0.001);
5500    }
5501
5502    // update_edge: entity→entity extends → set relation=VariantOf (same class) → Ok.
5503    #[tokio::test]
5504    async fn update_edge_same_class_relation_change_succeeds() {
5505        let rt = rt();
5506        let tok = NamespaceToken::local();
5507        let a = rt
5508            .create_entity(&tok, "concept", None, "A", None, None, vec![])
5509            .await
5510            .unwrap();
5511        let b = rt
5512            .create_entity(&tok, "concept", None, "B", None, None, vec![])
5513            .await
5514            .unwrap();
5515        let edge = rt
5516            .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
5517            .await
5518            .unwrap();
5519        let edge_id: Uuid = edge.id.into();
5520
5521        let updated = rt
5522            .update_edge(
5523                &tok,
5524                edge_id,
5525                crate::curation::EdgePatch {
5526                    relation: Some(EdgeRelation::VariantOf),
5527                    ..Default::default()
5528                },
5529            )
5530            .await
5531            .unwrap();
5532        assert_eq!(updated.relation, EdgeRelation::VariantOf);
5533    }
5534
5535    #[tokio::test]
5536    async fn list_edges_filters_by_relation() {
5537        let rt = rt();
5538        let tok = NamespaceToken::local();
5539        let a = rt
5540            .create_entity(&tok, "concept", None, "A", None, None, vec![])
5541            .await
5542            .unwrap();
5543        let b = rt
5544            .create_entity(&tok, "concept", None, "B", None, None, vec![])
5545            .await
5546            .unwrap();
5547        let c = rt
5548            .create_entity(&tok, "concept", None, "C", None, None, vec![])
5549            .await
5550            .unwrap();
5551
5552        rt.link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
5553            .await
5554            .unwrap();
5555        rt.link(&tok, a.id, c.id, EdgeRelation::Enables, 1.0, None)
5556            .await
5557            .unwrap();
5558
5559        let filter = EdgeListFilter {
5560            relations: vec![EdgeRelation::Extends],
5561            ..Default::default()
5562        };
5563        let edges = rt.list_edges(&tok, filter, 100, 0).await.unwrap();
5564        assert_eq!(edges.len(), 1);
5565        assert_eq!(edges[0].relation, EdgeRelation::Extends);
5566    }
5567
5568    #[tokio::test]
5569    async fn list_edges_filters_by_source() {
5570        let rt = rt();
5571        let tok = NamespaceToken::local();
5572        let a = rt
5573            .create_entity(&tok, "concept", None, "A", None, None, vec![])
5574            .await
5575            .unwrap();
5576        let b = rt
5577            .create_entity(&tok, "concept", None, "B", None, None, vec![])
5578            .await
5579            .unwrap();
5580        let c = rt
5581            .create_entity(&tok, "concept", None, "C", None, None, vec![])
5582            .await
5583            .unwrap();
5584        let d = rt
5585            .create_entity(&tok, "concept", None, "D", None, None, vec![])
5586            .await
5587            .unwrap();
5588
5589        rt.link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
5590            .await
5591            .unwrap();
5592        rt.link(&tok, c.id, d.id, EdgeRelation::Extends, 1.0, None)
5593            .await
5594            .unwrap();
5595
5596        let filter = EdgeListFilter {
5597            source_id: Some(a.id),
5598            ..Default::default()
5599        };
5600        let edges = rt.list_edges(&tok, filter, 100, 0).await.unwrap();
5601        assert_eq!(edges.len(), 1);
5602        let src: Uuid = edges[0].source_id;
5603        assert_eq!(src, a.id);
5604    }
5605
5606    /// Regression: `offset` was hard-coded to 0 in `list_edges`, so every
5607    /// page returned the identical first rows. Pages must now tile the full
5608    /// matching set with no gaps or duplicates, and an out-of-range offset
5609    /// must return empty rather than page 1.
5610    #[tokio::test]
5611    async fn list_edges_offset_pages_through_full_set() {
5612        let rt = rt();
5613        let tok = NamespaceToken::local();
5614        let a = rt
5615            .create_entity(&tok, "concept", None, "A", None, None, vec![])
5616            .await
5617            .unwrap();
5618        for i in 0..5 {
5619            let t = rt
5620                .create_entity(&tok, "concept", None, &format!("T{i}"), None, None, vec![])
5621                .await
5622                .unwrap();
5623            rt.link(&tok, a.id, t.id, EdgeRelation::Extends, 1.0, None)
5624                .await
5625                .unwrap();
5626        }
5627
5628        let filter = EdgeListFilter {
5629            source_id: Some(a.id),
5630            relations: vec![EdgeRelation::Extends],
5631            ..Default::default()
5632        };
5633
5634        let page0 = rt.list_edges(&tok, filter.clone(), 2, 0).await.unwrap();
5635        let page1 = rt.list_edges(&tok, filter.clone(), 2, 2).await.unwrap();
5636        let page2 = rt.list_edges(&tok, filter.clone(), 2, 4).await.unwrap();
5637        assert_eq!(page0.len(), 2);
5638        assert_eq!(page1.len(), 2);
5639        assert_eq!(page2.len(), 1);
5640
5641        let ids = |p: &[Edge]| p.iter().map(|e| Uuid::from(e.id)).collect::<Vec<_>>();
5642        assert_ne!(ids(&page0), ids(&page1), "page 2 must differ from page 1");
5643
5644        let mut all_ids: Vec<Uuid> = ids(&page0)
5645            .into_iter()
5646            .chain(ids(&page1))
5647            .chain(ids(&page2))
5648            .collect();
5649        all_ids.sort();
5650        all_ids.dedup();
5651        assert_eq!(all_ids.len(), 5, "pages must tile the full edge set");
5652
5653        let empty = rt.list_edges(&tok, filter.clone(), 2, 100).await.unwrap();
5654        assert!(
5655            empty.is_empty(),
5656            "offset past the end must return empty, not page 1"
5657        );
5658    }
5659
5660    /// `list_edges_after` seeks via `id > cursor` against the
5661    /// `(namespace, id)` primary key index instead of paging through OFFSET,
5662    /// so cost does not grow with how deep the walk goes.
5663    #[tokio::test]
5664    async fn list_edges_after_keyset_tiles_full_set() {
5665        let rt = rt();
5666        let tok = NamespaceToken::local();
5667        let a = rt
5668            .create_entity(&tok, "concept", None, "A", None, None, vec![])
5669            .await
5670            .unwrap();
5671        for i in 0..5 {
5672            let t = rt
5673                .create_entity(&tok, "concept", None, &format!("K{i}"), None, None, vec![])
5674                .await
5675                .unwrap();
5676            rt.link(&tok, a.id, t.id, EdgeRelation::Extends, 1.0, None)
5677                .await
5678                .unwrap();
5679        }
5680
5681        let filter = EdgeListFilter {
5682            source_id: Some(a.id),
5683            relations: vec![EdgeRelation::Extends],
5684            ..Default::default()
5685        };
5686
5687        let mut seen = Vec::new();
5688        let mut cursor: Option<Uuid> = None;
5689        for _ in 0..20 {
5690            let (page, next) = rt
5691                .list_edges_after(&tok, filter.clone(), cursor, 2)
5692                .await
5693                .unwrap();
5694            if page.is_empty() {
5695                break;
5696            }
5697            seen.extend(page.iter().map(|e| Uuid::from(e.id)));
5698            if next.is_none() {
5699                break;
5700            }
5701            cursor = next;
5702        }
5703        seen.sort();
5704        seen.dedup();
5705        assert_eq!(seen.len(), 5, "keyset walk must tile the full edge set");
5706
5707        // Stability: repeating the same cursor returns the same page — no
5708        // drift under a fixed snapshot. The seek is `WHERE id > ?` against
5709        // the `(namespace, id)` primary key index with `ORDER BY id ASC`
5710        // matching the index order, so this is an indexed range scan, not a
5711        // full-table scan+sort (see `query_edges_after` in khive-db).
5712        let (first_a, next_a) = rt
5713            .list_edges_after(&tok, filter.clone(), None, 2)
5714            .await
5715            .unwrap();
5716        let (first_b, next_b) = rt
5717            .list_edges_after(&tok, filter.clone(), None, 2)
5718            .await
5719            .unwrap();
5720        assert_eq!(
5721            first_a.iter().map(|e| e.id.0).collect::<Vec<_>>(),
5722            first_b.iter().map(|e| e.id.0).collect::<Vec<_>>(),
5723        );
5724        assert_eq!(next_a, next_b);
5725    }
5726
5727    #[tokio::test]
5728    async fn list_edges_after_single_namespace_exact_final_page_has_no_next_after() {
5729        let rt = rt();
5730        let tok = NamespaceToken::local();
5731        let a = rt
5732            .create_entity(&tok, "concept", None, "SingleCursorA", None, None, vec![])
5733            .await
5734            .unwrap();
5735        for i in 0..4 {
5736            let t = rt
5737                .create_entity(
5738                    &tok,
5739                    "concept",
5740                    None,
5741                    &format!("SingleCursorT{i}"),
5742                    None,
5743                    None,
5744                    vec![],
5745                )
5746                .await
5747                .unwrap();
5748            rt.link(&tok, a.id, t.id, EdgeRelation::Extends, 1.0, None)
5749                .await
5750                .unwrap();
5751        }
5752
5753        let filter = EdgeListFilter {
5754            source_id: Some(a.id),
5755            relations: vec![EdgeRelation::Extends],
5756            ..Default::default()
5757        };
5758
5759        let (page1, next1) = rt
5760            .list_edges_after(&tok, filter.clone(), None, 2)
5761            .await
5762            .unwrap();
5763        assert_eq!(page1.len(), 2);
5764        let cursor = next1.expect("first page must report a cursor when two rows remain");
5765
5766        let (page2, next2) = rt
5767            .list_edges_after(&tok, filter, Some(cursor), 2)
5768            .await
5769            .unwrap();
5770        assert_eq!(page2.len(), 2);
5771        assert_eq!(
5772            next2, None,
5773            "an exact-size final single-namespace page must not report a cursor"
5774        );
5775    }
5776
5777    #[tokio::test]
5778    async fn list_edges_after_multi_namespace_exact_final_page_has_no_next_after() {
5779        let rt = rt();
5780        let ns_a = Namespace::parse("cursor-ns-a").unwrap();
5781        let ns_b = Namespace::parse("cursor-ns-b").unwrap();
5782        let tok_a = NamespaceToken::for_namespace(ns_a.clone());
5783        let tok_b = NamespaceToken::for_namespace(ns_b.clone());
5784        let visible = NamespaceToken::mint_with_visibility(ns_a, vec![ns_b], ActorRef::anonymous());
5785
5786        for (tok, prefix) in [(&tok_a, "A"), (&tok_b, "B")] {
5787            let source = rt
5788                .create_entity(
5789                    tok,
5790                    "concept",
5791                    None,
5792                    &format!("MultiCursor{prefix}Source"),
5793                    None,
5794                    None,
5795                    vec![],
5796                )
5797                .await
5798                .unwrap();
5799            for i in 0..2 {
5800                let target = rt
5801                    .create_entity(
5802                        tok,
5803                        "concept",
5804                        None,
5805                        &format!("MultiCursor{prefix}Target{i}"),
5806                        None,
5807                        None,
5808                        vec![],
5809                    )
5810                    .await
5811                    .unwrap();
5812                rt.link(tok, source.id, target.id, EdgeRelation::Extends, 1.0, None)
5813                    .await
5814                    .unwrap();
5815            }
5816        }
5817
5818        let filter = EdgeListFilter {
5819            relations: vec![EdgeRelation::Extends],
5820            ..Default::default()
5821        };
5822        let (page1, next1) = rt
5823            .list_edges_after(&visible, filter.clone(), None, 2)
5824            .await
5825            .unwrap();
5826        assert_eq!(page1.len(), 2);
5827        let cursor = next1.expect("first merged page must report a cursor when rows remain");
5828
5829        let (page2, next2) = rt
5830            .list_edges_after(&visible, filter, Some(cursor), 2)
5831            .await
5832            .unwrap();
5833        assert_eq!(page2.len(), 2);
5834        assert_eq!(
5835            next2, None,
5836            "an exact-size final multi-namespace page must not report a cursor"
5837        );
5838    }
5839
5840    /// `stats()` should be able to report a per-relation breakdown so
5841    /// auditors know the true population per relation before sampling.
5842    #[tokio::test]
5843    async fn count_edges_by_relation_matches_fixtures() {
5844        let rt = rt();
5845        let tok = NamespaceToken::local();
5846        let a = rt
5847            .create_entity(&tok, "concept", None, "A", None, None, vec![])
5848            .await
5849            .unwrap();
5850        let b = rt
5851            .create_entity(&tok, "concept", None, "B", None, None, vec![])
5852            .await
5853            .unwrap();
5854        let c = rt
5855            .create_entity(&tok, "concept", None, "C", None, None, vec![])
5856            .await
5857            .unwrap();
5858
5859        rt.link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
5860            .await
5861            .unwrap();
5862        rt.link(&tok, a.id, c.id, EdgeRelation::Extends, 1.0, None)
5863            .await
5864            .unwrap();
5865        rt.link(&tok, b.id, c.id, EdgeRelation::Enables, 1.0, None)
5866            .await
5867            .unwrap();
5868
5869        let counts = rt.count_edges_by_relation(&tok).await.unwrap();
5870        assert_eq!(counts.get("extends").copied(), Some(2));
5871        assert_eq!(counts.get("enables").copied(), Some(1));
5872    }
5873
5874    #[tokio::test]
5875    async fn delete_edge_removes_from_storage() {
5876        let rt = rt();
5877        let tok = NamespaceToken::local();
5878        let a = rt
5879            .create_entity(&tok, "concept", None, "A", None, None, vec![])
5880            .await
5881            .unwrap();
5882        let b = rt
5883            .create_entity(&tok, "concept", None, "B", None, None, vec![])
5884            .await
5885            .unwrap();
5886        let edge = rt
5887            .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
5888            .await
5889            .unwrap();
5890        let edge_id: Uuid = edge.id.into();
5891
5892        let deleted = rt.delete_edge(&tok, edge_id, true).await.unwrap();
5893        assert!(deleted);
5894
5895        let fetched = rt.get_edge(&tok, edge_id).await.unwrap();
5896        assert!(fetched.is_none(), "edge should be gone after delete");
5897    }
5898
5899    #[tokio::test]
5900    async fn count_edges_matches_filter() {
5901        let rt = rt();
5902        let tok = NamespaceToken::local();
5903        let a = rt
5904            .create_entity(&tok, "concept", None, "A", None, None, vec![])
5905            .await
5906            .unwrap();
5907        let b = rt
5908            .create_entity(&tok, "concept", None, "B", None, None, vec![])
5909            .await
5910            .unwrap();
5911        let c = rt
5912            .create_entity(&tok, "concept", None, "C", None, None, vec![])
5913            .await
5914            .unwrap();
5915
5916        rt.link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
5917            .await
5918            .unwrap();
5919        rt.link(&tok, a.id, c.id, EdgeRelation::Enables, 1.0, None)
5920            .await
5921            .unwrap();
5922
5923        let all = rt
5924            .count_edges(&tok, EdgeListFilter::default())
5925            .await
5926            .unwrap();
5927        assert_eq!(all, 2);
5928
5929        let just_extends = rt
5930            .count_edges(
5931                &tok,
5932                EdgeListFilter {
5933                    relations: vec![EdgeRelation::Extends],
5934                    ..Default::default()
5935                },
5936            )
5937            .await
5938            .unwrap();
5939        assert_eq!(just_extends, 1);
5940    }
5941
5942    // ---- substrate_exists_in_ns must use get_edge_visible ----
5943
5944    /// An edge owned by a visible (non-primary) namespace must be found by
5945    /// `substrate_exists_in_ns` and therefore usable as a graph root in
5946    /// `neighbors` and `traverse`.
5947    #[tokio::test]
5948    async fn edge_in_visible_namespace_reachable_as_graph_root() {
5949        let rt = rt();
5950        let ns_a = Namespace::parse("vis-edge-a").unwrap();
5951        let ns_b = Namespace::parse("vis-edge-b").unwrap();
5952
5953        // Create two entities and an edge in namespace B.
5954        let tok_b = NamespaceToken::for_namespace(ns_b.clone());
5955        let src = rt
5956            .create_entity(&tok_b, "concept", None, "SrcB", None, None, vec![])
5957            .await
5958            .unwrap();
5959        let tgt = rt
5960            .create_entity(&tok_b, "concept", None, "TgtB", None, None, vec![])
5961            .await
5962            .unwrap();
5963        let edge = rt
5964            .link(&tok_b, src.id, tgt.id, EdgeRelation::Extends, 1.0, None)
5965            .await
5966            .unwrap();
5967
5968        // Namespace A with B in its visible set should be able to get the
5969        // edge and use it as a traverse root.
5970        let tok_a_vis = rt
5971            .authorize_with_visibility(ns_a.clone(), vec![ns_b.clone()])
5972            .unwrap();
5973
5974        // Direct get of the edge must succeed (visible namespace).
5975        let got = rt.get_edge_visible(&tok_a_vis, edge.id.0).await.unwrap();
5976        assert!(
5977            got.is_some(),
5978            "edge in visible namespace must be retrievable via get_edge_visible"
5979        );
5980
5981        // neighbors/traverse use substrate_exists_in_ns which now calls
5982        // get_edge_visible — they must not return empty for a visible-ns edge root.
5983        let neighbors = rt
5984            .neighbors(&tok_a_vis, src.id, Direction::Out, Some(16), None)
5985            .await
5986            .unwrap();
5987        assert!(
5988            neighbors.iter().any(|h| h.node_id == tgt.id),
5989            "neighbors of visible-ns node must include its visible-ns neighbor; got: {neighbors:?}"
5990        );
5991    }
5992
5993    // By-ID ops do not enforce namespace isolation. Shared-brain OSS model:
5994    // UUID is globally unique; get/update/delete find the record regardless
5995    // of caller's token namespace.
5996    #[tokio::test]
5997    async fn get_entity_cross_namespace_no_longer_denied() {
5998        let rt = rt();
5999        let ns_a = NamespaceToken::for_namespace(Namespace::parse("ns-a").unwrap());
6000        let ns_b = NamespaceToken::for_namespace(Namespace::parse("ns-b").unwrap());
6001        let entity = rt
6002            .create_entity(&ns_a, "concept", None, "Alpha", None, None, vec![])
6003            .await
6004            .unwrap();
6005
6006        // Same namespace: still works.
6007        let found = rt.get_entity(&ns_a, entity.id).await;
6008        assert!(found.is_ok(), "same-namespace get must succeed");
6009
6010        // Different namespace: now also returns the entity (shared brain).
6011        let cross = rt.get_entity(&ns_b, entity.id).await;
6012        assert!(
6013            cross.is_ok(),
6014            "cross-namespace get must succeed in shared-brain OSS (ADR-007 rule 2)"
6015        );
6016        assert_eq!(cross.unwrap().id, entity.id);
6017    }
6018
6019    #[tokio::test]
6020    async fn delete_entity_cross_namespace_no_longer_denied() {
6021        let rt = rt();
6022        let ns_a = NamespaceToken::for_namespace(Namespace::parse("ns-a").unwrap());
6023        let ns_b = NamespaceToken::for_namespace(Namespace::parse("ns-b").unwrap());
6024        let entity = rt
6025            .create_entity(&ns_a, "concept", None, "Beta", None, None, vec![])
6026            .await
6027            .unwrap();
6028
6029        // Cross-namespace delete now succeeds (shared brain).
6030        let cross_ns_result = rt.delete_entity(&ns_b, entity.id, true).await;
6031        assert!(
6032            cross_ns_result.is_ok(),
6033            "cross-namespace delete must succeed in shared-brain OSS; got {:?}",
6034            cross_ns_result
6035        );
6036        assert!(cross_ns_result.unwrap(), "delete must return true");
6037
6038        // Entity is gone — even from the original namespace.
6039        let gone = rt.get_entity(&ns_a, entity.id).await;
6040        assert!(gone.is_err(), "entity must be gone after delete");
6041    }
6042
6043    // ---- Note annotation tests ----
6044
6045    #[tokio::test]
6046    async fn create_note_indexes_into_fts5() {
6047        let rt = rt();
6048        let tok = NamespaceToken::local();
6049        let note = rt
6050            .create_note(
6051                &tok,
6052                "observation",
6053                None,
6054                "FlashAttention reduces memory by using tiling",
6055                Some(0.8),
6056                None,
6057                vec![],
6058            )
6059            .await
6060            .unwrap();
6061
6062        // FTS5 should have indexed the note content.
6063        let ns = tok.namespace().as_str().to_string();
6064        let hits = rt
6065            .text_for_notes(&tok)
6066            .unwrap()
6067            .search(khive_storage::types::TextSearchRequest {
6068                query: "FlashAttention".to_string(),
6069                mode: khive_storage::types::TextQueryMode::Plain,
6070                filter: Some(khive_storage::types::TextFilter {
6071                    namespaces: vec![ns],
6072                    ..Default::default()
6073                }),
6074                top_k: 10,
6075                snippet_chars: 100,
6076            })
6077            .await
6078            .unwrap();
6079
6080        assert!(
6081            hits.iter().any(|h| h.subject_id == note.id),
6082            "note should be indexed in FTS5 after create"
6083        );
6084    }
6085
6086    /// #916: `@` used to reach SQLite FTS5's bareword parser raw and error
6087    /// (`sanitize_fts5_query` did not strip it), surfacing as
6088    /// `RuntimeError::InvalidInput` per #569's fail-loud policy.
6089    /// `sanitize_fts5_token_group`'s bareword-safety gate now routes it
6090    /// through the quoted-phrase alternative instead, so `search_notes`
6091    /// succeeds and finds the seeded content.
6092    #[tokio::test]
6093    async fn search_notes_with_residual_fts5_char_now_sanitized() {
6094        let rt = rt();
6095        let tok = NamespaceToken::local();
6096        rt.create_note(
6097            &tok,
6098            "observation",
6099            None,
6100            "use foo@bar to chain calls",
6101            Some(0.5),
6102            None,
6103            vec![],
6104        )
6105        .await
6106        .unwrap();
6107
6108        let result = rt
6109            .search_notes(&tok, "foo@bar", None, 10, None, false, &[], None)
6110            .await;
6111
6112        let hits = result.unwrap_or_else(|e| {
6113            panic!("#916 search_notes must not fail on an '@'-bearing query, got: {e:?}")
6114        });
6115        assert!(
6116            !hits.is_empty(),
6117            "#916 '@'-bearing query must still find the seeded 'foo@bar' content via the \
6118             quoted-phrase alternative"
6119        );
6120    }
6121
6122    /// The `search_notes` FTS fail-open arm must only degrade genuine FTS5
6123    /// parser syntax errors. A non-parser `StorageError`: e.g. a pool
6124    /// exhaustion or connection timeout on the text-search backend — is not a
6125    /// bad query and must propagate as `Err`, not be silently swallowed into
6126    /// an empty (falsely "successful") result set. `search_notes`,
6127    /// `hybrid_search`, `hybrid_search_with_strategy`, and
6128    /// `collect_recall_text_hits` all share the same `is_fts5_syntax_error()`
6129    /// gate on `StorageError`, so this case generalizes to all four call sites.
6130    #[tokio::test]
6131    async fn search_notes_propagates_non_parser_fts_error() {
6132        let rt = rt();
6133        let tok = NamespaceToken::local();
6134        rt.create_note(
6135            &tok,
6136            "observation",
6137            None,
6138            "FlashAttention reduces memory by using tiling",
6139            Some(0.8),
6140            None,
6141            vec![],
6142        )
6143        .await
6144        .unwrap();
6145
6146        let ns = tok.namespace().as_str().to_string();
6147        arm_fts_search_fail(&ns);
6148
6149        let result = rt
6150            .search_notes(&tok, "FlashAttention", None, 10, None, false, &[], None)
6151            .await;
6152
6153        assert!(
6154            result.is_err(),
6155            "search_notes must propagate a non-parser FTS StorageError (Timeout) \
6156             as Err, not silently degrade it to an empty result, got: {:?}",
6157            result.ok()
6158        );
6159        assert!(
6160            matches!(
6161                result.unwrap_err(),
6162                RuntimeError::Storage(khive_storage::StorageError::Timeout { .. })
6163            ),
6164            "propagated error must be the injected StorageError::Timeout, unwrapped \
6165             through RuntimeError::Storage"
6166        );
6167    }
6168
6169    #[tokio::test]
6170    async fn create_note_with_properties() {
6171        let rt = rt();
6172        let tok = NamespaceToken::local();
6173        let props = serde_json::json!({"source": "arxiv:2205.14135"});
6174        let note = rt
6175            .create_note(
6176                &tok,
6177                "insight",
6178                None,
6179                "FlashAttention is IO-aware",
6180                Some(0.9),
6181                Some(props.clone()),
6182                vec![],
6183            )
6184            .await
6185            .unwrap();
6186
6187        assert_eq!(note.properties.as_ref().unwrap(), &props);
6188    }
6189
6190    #[tokio::test]
6191    async fn create_note_creates_annotates_edges() {
6192        let rt = rt();
6193        let tok = NamespaceToken::local();
6194        let entity = rt
6195            .create_entity(&tok, "concept", None, "FlashAttention", None, None, vec![])
6196            .await
6197            .unwrap();
6198
6199        let note = rt
6200            .create_note(
6201                &tok,
6202                "observation",
6203                None,
6204                "FlashAttention uses SRAM tiling for memory efficiency",
6205                Some(0.9),
6206                None,
6207                vec![entity.id],
6208            )
6209            .await
6210            .unwrap();
6211
6212        // The note should have an outbound `annotates` edge to the entity.
6213        let out_neighbors = rt
6214            .neighbors(
6215                &tok,
6216                note.id,
6217                Direction::Out,
6218                None,
6219                Some(vec![EdgeRelation::Annotates]),
6220            )
6221            .await
6222            .unwrap();
6223        assert_eq!(out_neighbors.len(), 1);
6224        assert_eq!(out_neighbors[0].node_id, entity.id);
6225        assert_eq!(out_neighbors[0].relation, EdgeRelation::Annotates);
6226
6227        // The entity should have an inbound `annotates` edge from the note.
6228        let in_neighbors = rt
6229            .neighbors(
6230                &tok,
6231                entity.id,
6232                Direction::In,
6233                None,
6234                Some(vec![EdgeRelation::Annotates]),
6235            )
6236            .await
6237            .unwrap();
6238        assert_eq!(in_neighbors.len(), 1);
6239        assert_eq!(in_neighbors[0].node_id, note.id);
6240    }
6241
6242    #[tokio::test]
6243    async fn neighbors_without_relation_filter_returns_all() {
6244        let rt = rt();
6245        let tok = NamespaceToken::local();
6246        let a = rt
6247            .create_entity(&tok, "concept", None, "A", None, None, vec![])
6248            .await
6249            .unwrap();
6250        let b = rt
6251            .create_entity(&tok, "concept", None, "B", None, None, vec![])
6252            .await
6253            .unwrap();
6254        let c = rt
6255            .create_entity(&tok, "concept", None, "C", None, None, vec![])
6256            .await
6257            .unwrap();
6258
6259        rt.link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
6260            .await
6261            .unwrap();
6262        rt.link(&tok, a.id, c.id, EdgeRelation::Enables, 1.0, None)
6263            .await
6264            .unwrap();
6265
6266        let all = rt
6267            .neighbors(&tok, a.id, Direction::Out, None, None)
6268            .await
6269            .unwrap();
6270        assert_eq!(all.len(), 2);
6271    }
6272
6273    #[tokio::test]
6274    async fn neighbors_with_relation_filter_returns_subset() {
6275        let rt = rt();
6276        let tok = NamespaceToken::local();
6277        let a = rt
6278            .create_entity(&tok, "concept", None, "A", None, None, vec![])
6279            .await
6280            .unwrap();
6281        let b = rt
6282            .create_entity(&tok, "concept", None, "B", None, None, vec![])
6283            .await
6284            .unwrap();
6285        let c = rt
6286            .create_entity(&tok, "concept", None, "C", None, None, vec![])
6287            .await
6288            .unwrap();
6289
6290        rt.link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
6291            .await
6292            .unwrap();
6293        rt.link(&tok, a.id, c.id, EdgeRelation::Enables, 1.0, None)
6294            .await
6295            .unwrap();
6296
6297        let filtered = rt
6298            .neighbors(
6299                &tok,
6300                a.id,
6301                Direction::Out,
6302                None,
6303                Some(vec![EdgeRelation::Extends]),
6304            )
6305            .await
6306            .unwrap();
6307        assert_eq!(filtered.len(), 1);
6308        assert_eq!(filtered[0].node_id, b.id);
6309        assert_eq!(filtered[0].relation, EdgeRelation::Extends);
6310    }
6311
6312    /// Self-loop direction parity:
6313    /// `neighbors_with_query_directed`'s post-merge dedup must not collapse a
6314    /// self-loop edge's Out row and In row into one — they share `(node_id,
6315    /// edge_id)` but are opposite directions, matching what a separate `Out`
6316    /// call plus a separate `In` call would return for the same edge. The
6317    /// self-loop edge is inserted directly through the graph store (`link()`
6318    /// rejects source_id == target_id) to exercise the merge/dedup path.
6319    #[tokio::test]
6320    async fn neighbors_with_query_directed_preserves_self_loop_direction_parity() {
6321        let rt = rt();
6322        let tok = NamespaceToken::local();
6323        let centre = rt
6324            .create_entity(&tok, "concept", None, "Centre", None, None, vec![])
6325            .await
6326            .unwrap();
6327
6328        let now = chrono::Utc::now();
6329        rt.graph(&tok)
6330            .unwrap()
6331            .upsert_edge(Edge {
6332                id: LinkId::from(Uuid::new_v4()),
6333                namespace: "local".to_string(),
6334                source_id: centre.id,
6335                target_id: centre.id,
6336                relation: EdgeRelation::Extends,
6337                weight: 0.7,
6338                created_at: now,
6339                updated_at: now,
6340                deleted_at: None,
6341                metadata: None,
6342                target_backend: None,
6343            })
6344            .await
6345            .unwrap();
6346
6347        let directed = rt
6348            .neighbors_with_query_directed(
6349                &tok,
6350                centre.id,
6351                NeighborQuery {
6352                    direction: Direction::Both,
6353                    relations: None,
6354                    limit: None,
6355                    min_weight: None,
6356                },
6357            )
6358            .await
6359            .unwrap();
6360
6361        assert_eq!(
6362            directed.len(),
6363            2,
6364            "a self-loop edge must produce both an Out hit and an In hit, not one collapsed hit"
6365        );
6366        let directions: Vec<Direction> = directed.iter().map(|(_, d)| d.clone()).collect();
6367        assert!(
6368            directions.contains(&Direction::Out),
6369            "self-loop must retain its Out-tagged hit"
6370        );
6371        assert!(
6372            directions.contains(&Direction::In),
6373            "self-loop must retain its In-tagged hit"
6374        );
6375    }
6376
6377    #[tokio::test]
6378    async fn search_notes_returns_relevant_note() {
6379        let rt = rt();
6380        let tok = NamespaceToken::local();
6381        rt.create_note(
6382            &tok,
6383            "observation",
6384            None,
6385            "GQA reduces KV cache memory for large models",
6386            Some(0.8),
6387            None,
6388            vec![],
6389        )
6390        .await
6391        .unwrap();
6392
6393        let results = rt
6394            .search_notes(&tok, "GQA KV cache", None, 10, None, false, &[], None)
6395            .await
6396            .unwrap();
6397
6398        assert!(!results.is_empty(), "search should return the indexed note");
6399        let hit = &results[0];
6400        assert!(
6401            hit.title.is_some(),
6402            "note hit title should be populated (falls back to content)"
6403        );
6404        assert!(
6405            hit.snippet.is_some(),
6406            "note hit snippet should be populated"
6407        );
6408    }
6409
6410    #[tokio::test]
6411    async fn search_notes_excludes_soft_deleted() {
6412        let rt = rt();
6413        let tok = NamespaceToken::local();
6414        let note = rt
6415            .create_note(
6416                &tok,
6417                "observation",
6418                None,
6419                "RoPE positional encoding rotary embeddings",
6420                Some(0.7),
6421                None,
6422                vec![],
6423            )
6424            .await
6425            .unwrap();
6426
6427        // Soft-delete the note.
6428        rt.notes(&tok)
6429            .unwrap()
6430            .delete_note(note.id, DeleteMode::Soft)
6431            .await
6432            .unwrap();
6433
6434        let results = rt
6435            .search_notes(
6436                &tok,
6437                "RoPE rotary positional",
6438                None,
6439                10,
6440                None,
6441                false,
6442                &[],
6443                None,
6444            )
6445            .await
6446            .unwrap();
6447
6448        assert!(
6449            results.iter().all(|h| h.note_id != note.id),
6450            "soft-deleted note should be excluded from search"
6451        );
6452    }
6453
6454    // ---- predicate pushdown before truncation (note branch) ----
6455
6456    /// Regression: notes store tags inside `properties["tags"]`: there is no
6457    /// separate tags column. Without pushdown, the tag filter is applied after
6458    /// `hits.truncate(limit)`, so a tag-matching note ranked beyond `limit` in
6459    /// the raw RRF fusion is silently dropped.
6460    ///
6461    /// Scenario: `limit=1`, tags_any=["note-target-tag"]. Two notes are inserted:
6462    ///   - decoy: high FTS rank (repeats query terms), NO target tag.
6463    ///   - target: lower FTS rank, HAS "note-target-tag" in `properties["tags"]`.
6464    ///
6465    /// Without pushdown: decoy occupies the slot, target is dropped.
6466    /// With pushdown: decoy is excluded in the alive-note loop, target survives, returned.
6467    #[tokio::test]
6468    async fn search_notes_tag_filter_pushed_before_truncation() {
6469        let rt = rt();
6470        let tok = NamespaceToken::local();
6471
6472        // Decoy note: repeats query tokens → higher FTS rank. No target tag.
6473        rt.create_note(
6474            &tok,
6475            "observation",
6476            None,
6477            "kappa lambda mu note decoy kappa lambda mu note decoy kappa lambda mu",
6478            Some(0.5),
6479            Some(serde_json::json!({"tags": ["other-note-tag"]})),
6480            vec![],
6481        )
6482        .await
6483        .unwrap();
6484
6485        // Target note: fewer query tokens → lower FTS rank. Has the target tag.
6486        let target = rt
6487            .create_note(
6488                &tok,
6489                "observation",
6490                None,
6491                "kappa lambda mu note target",
6492                Some(0.5),
6493                Some(serde_json::json!({"tags": ["note-target-tag"]})),
6494                vec![],
6495            )
6496            .await
6497            .unwrap();
6498
6499        // With limit=1 and tags_any, the fix must return the target note despite the
6500        // decoy ranking higher in raw FTS.
6501        let hits = rt
6502            .search_notes(
6503                &tok,
6504                "kappa lambda mu note",
6505                None,
6506                1,
6507                None,
6508                false,
6509                &["note-target-tag".to_string()],
6510                None,
6511            )
6512            .await
6513            .unwrap();
6514
6515        assert_eq!(
6516            hits.len(),
6517            1,
6518            "exactly one hit expected (tag-matching note)"
6519        );
6520        assert_eq!(
6521            hits[0].note_id, target.id,
6522            "tag-filtered note must be returned even when ranked below limit in raw fusion"
6523        );
6524    }
6525
6526    /// Regression: without pushdown, the properties filter is applied after truncation; a matching
6527    /// note ranked beyond `limit` is silently dropped.
6528    ///
6529    /// Scenario: `limit=1`, properties_filter={{"source": "target"}}. Two notes:
6530    ///   - decoy: high FTS rank, properties {{"source": "other"}}.
6531    ///   - target: lower FTS rank, properties {{"source": "target"}}.
6532    #[tokio::test]
6533    async fn search_notes_props_filter_pushed_before_truncation() {
6534        let rt = rt();
6535        let tok = NamespaceToken::local();
6536
6537        rt.create_note(
6538            &tok,
6539            "observation",
6540            None,
6541            "nu xi omicron note decoy nu xi omicron note decoy nu xi omicron",
6542            Some(0.5),
6543            Some(serde_json::json!({"source": "other"})),
6544            vec![],
6545        )
6546        .await
6547        .unwrap();
6548
6549        let target = rt
6550            .create_note(
6551                &tok,
6552                "observation",
6553                None,
6554                "nu xi omicron note target",
6555                Some(0.5),
6556                Some(serde_json::json!({"source": "target"})),
6557                vec![],
6558            )
6559            .await
6560            .unwrap();
6561
6562        let filter = serde_json::json!({"source": "target"});
6563        let hits = rt
6564            .search_notes(
6565                &tok,
6566                "nu xi omicron note",
6567                None,
6568                1,
6569                None,
6570                false,
6571                &[],
6572                Some(&filter),
6573            )
6574            .await
6575            .unwrap();
6576
6577        assert_eq!(
6578            hits.len(),
6579            1,
6580            "exactly one hit expected (properties-matching note)"
6581        );
6582        assert_eq!(
6583            hits[0].note_id, target.id,
6584            "properties-filtered note must be returned even when ranked below limit"
6585        );
6586    }
6587
6588    #[tokio::test]
6589    async fn resolve_returns_entity() {
6590        let rt = rt();
6591        let tok = NamespaceToken::local();
6592        let entity = rt
6593            .create_entity(&tok, "concept", None, "LoRA", None, None, vec![])
6594            .await
6595            .unwrap();
6596
6597        let resolved = rt.resolve(&tok, entity.id).await.unwrap();
6598        match resolved {
6599            Some(Resolved::Entity(e)) => assert_eq!(e.id, entity.id),
6600            other => panic!("expected Resolved::Entity, got {:?}", other),
6601        }
6602    }
6603
6604    #[tokio::test]
6605    async fn resolve_returns_note() {
6606        let rt = rt();
6607        let tok = NamespaceToken::local();
6608        let note = rt
6609            .create_note(
6610                &tok,
6611                "observation",
6612                None,
6613                "LoRA fine-tunes LLMs with low-rank adapters",
6614                Some(0.85),
6615                None,
6616                vec![],
6617            )
6618            .await
6619            .unwrap();
6620
6621        let resolved = rt.resolve(&tok, note.id).await.unwrap();
6622        match resolved {
6623            Some(Resolved::Note(n)) => assert_eq!(n.id, note.id),
6624            other => panic!("expected Resolved::Note, got {:?}", other),
6625        }
6626    }
6627
6628    #[tokio::test]
6629    async fn resolve_returns_none_for_unknown_uuid() {
6630        let rt = rt();
6631        let tok = NamespaceToken::local();
6632        let unknown = Uuid::new_v4();
6633        let resolved = rt.resolve(&tok, unknown).await.unwrap();
6634        assert!(resolved.is_none(), "unknown UUID should resolve to None");
6635    }
6636
6637    #[tokio::test]
6638    async fn resolve_prefix_finds_entity_in_own_namespace() {
6639        let rt = rt();
6640        let tok = NamespaceToken::local();
6641        let entity = rt
6642            .create_entity(&tok, "concept", None, "PrefixTest", None, None, vec![])
6643            .await
6644            .unwrap();
6645        let prefix = &entity.id.to_string()[..8];
6646
6647        let resolved = rt.resolve_prefix(&tok, prefix).await.unwrap();
6648        assert_eq!(resolved, Some(entity.id));
6649    }
6650
6651    #[test]
6652    fn hex_prefix_to_uuid_pattern_inserts_hyphens_at_canonical_boundaries() {
6653        let full = "aabbccdd112240008000000000000ab1";
6654        let cases: &[(usize, &str)] = &[
6655            (1, "a"),
6656            (7, "aabbccd"),
6657            (8, "aabbccdd"),
6658            (9, "aabbccdd-1"),
6659            (12, "aabbccdd-1122"),
6660            (13, "aabbccdd-1122-4"),
6661            (16, "aabbccdd-1122-4000"),
6662            (18, "aabbccdd-1122-4000-80"),
6663            (20, "aabbccdd-1122-4000-8000"),
6664            (23, "aabbccdd-1122-4000-8000-000"),
6665            (24, "aabbccdd-1122-4000-8000-0000"),
6666            (28, "aabbccdd-1122-4000-8000-00000000"),
6667            (31, "aabbccdd-1122-4000-8000-000000000ab"),
6668        ];
6669        for (len, expected) in cases {
6670            let input = &full[..*len];
6671            assert_eq!(
6672                hex_prefix_to_uuid_pattern(input),
6673                *expected,
6674                "len={len} input={input:?}"
6675            );
6676        }
6677    }
6678
6679    #[test]
6680    fn hex_prefix_to_uuid_pattern_full_32_char_matches_canonical_uuid() {
6681        let compact = "aabbccdd112240008000000000000ab1";
6682        let compact32 = &compact[..32];
6683        assert_eq!(
6684            hex_prefix_to_uuid_pattern(compact32),
6685            "aabbccdd-1122-4000-8000-000000000ab1"
6686        );
6687    }
6688
6689    /// Input longer than 32 hex chars is NOT truncated: the extra chars land
6690    /// past the canonical 12-char final
6691    /// segment with no further hyphen, so the pattern can never match a real
6692    /// (36-char) stored `id`, instead of silently truncating down to a
6693    /// pattern that matches the valid 32-char UUID.
6694    #[test]
6695    fn hex_prefix_to_uuid_pattern_overlong_input_is_not_truncated() {
6696        let compact32 = "aabbccdd112240008000000000000ab1";
6697        let overlong = format!("{compact32}extrahex");
6698        let pattern = hex_prefix_to_uuid_pattern(&overlong);
6699        assert_eq!(
6700            pattern, "aabbccdd-1122-4000-8000-000000000ab1extrahex",
6701            "overlong input must keep its extra chars, not truncate to the valid UUID"
6702        );
6703        assert_ne!(
6704            pattern, "aabbccdd-1122-4000-8000-000000000ab1",
6705            "overlong pattern must not collapse to the canonical 36-char UUID form"
6706        );
6707    }
6708
6709    #[test]
6710    fn hex_prefix_to_uuid_pattern_passes_through_hyphenated_input() {
6711        let hyphenated = "aabbccdd-1122-4000-8000-000000000ab1";
6712        assert_eq!(hex_prefix_to_uuid_pattern(hyphenated), hyphenated);
6713
6714        let partial = "aabbccdd-11";
6715        assert_eq!(hex_prefix_to_uuid_pattern(partial), partial);
6716    }
6717
6718    #[tokio::test]
6719    async fn resolve_prefix_compact_9_to_31_char_matches() {
6720        let rt = rt();
6721        let tok = NamespaceToken::local();
6722        let entity = rt
6723            .create_entity(
6724                &tok,
6725                "concept",
6726                None,
6727                "CompactPrefixTest",
6728                None,
6729                None,
6730                vec![],
6731            )
6732            .await
6733            .unwrap();
6734        let compact = entity.id.simple().to_string();
6735
6736        for len in [9, 12, 16, 20, 24, 28, 31] {
6737            let prefix = &compact[..len];
6738            let resolved = rt.resolve_prefix(&tok, prefix).await.unwrap();
6739            assert_eq!(
6740                resolved,
6741                Some(entity.id),
6742                "compact prefix of len {len} should resolve"
6743            );
6744        }
6745    }
6746
6747    #[tokio::test]
6748    async fn resolve_prefix_compact_full_32_char_matches() {
6749        let rt = rt();
6750        let tok = NamespaceToken::local();
6751        let entity = rt
6752            .create_entity(&tok, "concept", None, "Full32Test", None, None, vec![])
6753            .await
6754            .unwrap();
6755        let compact = entity.id.simple().to_string();
6756        assert_eq!(compact.len(), 32);
6757
6758        let resolved = rt.resolve_prefix(&tok, &compact).await.unwrap();
6759        assert_eq!(resolved, Some(entity.id));
6760    }
6761
6762    /// A valid 32-char compact id with extra trailing hex chars appended must
6763    /// fail to resolve, not silently resolve to the valid entity via truncation.
6764    #[tokio::test]
6765    async fn resolve_prefix_rejects_overlong_all_hex_input() {
6766        let rt = rt();
6767        let tok = NamespaceToken::local();
6768        let entity = rt
6769            .create_entity(&tok, "concept", None, "OverlongTest", None, None, vec![])
6770            .await
6771            .unwrap();
6772        let compact = entity.id.simple().to_string();
6773        assert_eq!(compact.len(), 32);
6774
6775        let overlong = format!("{compact}ab");
6776        let resolved = rt.resolve_prefix(&tok, &overlong).await.unwrap();
6777        assert_eq!(
6778            resolved, None,
6779            "a 32-char id plus extra hex chars must not resolve to the valid entity"
6780        );
6781    }
6782
6783    /// The `resolve_prefix*` boundary rejects
6784    /// non-hex/non-hyphen input (e.g. LIKE wildcards `%`/`_`) instead of
6785    /// letting it reach the bound `LIKE` pattern unfiltered — covers callers
6786    /// (like khive-pack-git/src/ingest.rs) that resolve raw input without
6787    /// their own all-hex gate.
6788    #[tokio::test]
6789    async fn resolve_prefix_rejects_like_wildcard_input() {
6790        let rt = rt();
6791        let tok = NamespaceToken::local();
6792        let entity = rt
6793            .create_entity(&tok, "concept", None, "WildcardTest", None, None, vec![])
6794            .await
6795            .unwrap();
6796        let compact = entity.id.simple().to_string();
6797        // A caller that forgot to hex-gate might pass a `%`-bearing string
6798        // straight through, hoping to broaden a scan; the resolver boundary
6799        // must reject it instead of running it as a wildcard LIKE.
6800        let wildcard_prefix = format!("{}%", &compact[..8]);
6801
6802        let resolved = rt.resolve_prefix(&tok, &wildcard_prefix).await.unwrap();
6803        assert_eq!(
6804            resolved, None,
6805            "prefix containing a LIKE wildcard must be rejected, not resolved"
6806        );
6807    }
6808
6809    #[tokio::test]
6810    async fn resolve_prefix_boundary_at_hyphen_positions() {
6811        let rt = rt();
6812        let tok = NamespaceToken::local();
6813        let entity = rt
6814            .create_entity(&tok, "concept", None, "BoundaryTest", None, None, vec![])
6815            .await
6816            .unwrap();
6817        let compact = entity.id.simple().to_string();
6818
6819        for len in [8, 12, 16, 20, 24] {
6820            let prefix = &compact[..len];
6821            let resolved = rt.resolve_prefix(&tok, prefix).await.unwrap();
6822            assert_eq!(
6823                resolved,
6824                Some(entity.id),
6825                "boundary prefix of len {len} should resolve"
6826            );
6827        }
6828    }
6829
6830    #[tokio::test]
6831    async fn resolve_prefix_ambiguous_still_detected_after_normalization() {
6832        use khive_storage::entity::Entity;
6833
6834        let rt = rt();
6835        let tok = NamespaceToken::local();
6836        let id_a = Uuid::parse_str("aabbccdd-1111-4000-8000-000000000001").unwrap();
6837        let id_b = Uuid::parse_str("aabbccdd-1111-4000-8000-000000000002").unwrap();
6838
6839        let mut entity_a = Entity::new("local", "concept", "AmbigCompactA");
6840        entity_a.id = id_a;
6841        let mut entity_b = Entity::new("local", "concept", "AmbigCompactB");
6842        entity_b.id = id_b;
6843
6844        let store = rt.entities(&tok).unwrap();
6845        store.upsert_entity(entity_a).await.unwrap();
6846        store.upsert_entity(entity_b).await.unwrap();
6847
6848        // Shared 20-char compact prefix (past the first hyphen boundary).
6849        let shared_compact = &id_a.simple().to_string()[..20];
6850        let err = rt.resolve_prefix(&tok, shared_compact).await.unwrap_err();
6851        assert!(
6852            matches!(
6853                err,
6854                RuntimeError::AmbiguousPrefix { ref matches, .. } if matches.len() == 2
6855            ),
6856            "shared compact prefix must still return AmbiguousPrefix; got {err:?}"
6857        );
6858    }
6859
6860    #[tokio::test]
6861    async fn resolve_prefix_invisible_across_namespaces() {
6862        let rt = rt();
6863        let ns_a = NamespaceToken::for_namespace(Namespace::parse("ns-a").unwrap());
6864        let ns_b = NamespaceToken::for_namespace(Namespace::parse("ns-b").unwrap());
6865        let entity = rt
6866            .create_entity(&ns_a, "concept", None, "Invisible", None, None, vec![])
6867            .await
6868            .unwrap();
6869        let prefix = &entity.id.to_string()[..8];
6870
6871        // From ns_b, the entity in ns_a should not be visible.
6872        let resolved = rt.resolve_prefix(&ns_b, prefix).await.unwrap();
6873        assert_eq!(resolved, None);
6874    }
6875
6876    #[tokio::test]
6877    async fn resolve_prefix_ambiguous_same_namespace() {
6878        use khive_storage::entity::Entity;
6879
6880        let rt = rt();
6881        let tok = NamespaceToken::local();
6882        // Two entities with UUIDs sharing the same 8-char prefix "aabbccdd".
6883        let id_a = Uuid::parse_str("aabbccdd-1111-4000-8000-000000000001").unwrap();
6884        let id_b = Uuid::parse_str("aabbccdd-2222-4000-8000-000000000002").unwrap();
6885
6886        let mut entity_a = Entity::new("local", "concept", "AmbigA");
6887        entity_a.id = id_a;
6888        let mut entity_b = Entity::new("local", "concept", "AmbigB");
6889        entity_b.id = id_b;
6890
6891        let store = rt.entities(&tok).unwrap();
6892        store.upsert_entity(entity_a).await.unwrap();
6893        store.upsert_entity(entity_b).await.unwrap();
6894
6895        let err = rt.resolve_prefix(&tok, "aabbccdd").await.unwrap_err();
6896        assert!(
6897            matches!(
6898                err,
6899                RuntimeError::AmbiguousPrefix { ref prefix, ref matches }
6900                    if prefix == "aabbccdd" && matches.len() == 2
6901            ),
6902            "shared 8-char prefix must return AmbiguousPrefix; got {err:?}"
6903        );
6904    }
6905
6906    /// A single UUID legitimately present in TWO scanned tables (entities and
6907    /// notes here) must resolve cleanly to that one UUID, not a false
6908    /// `AmbiguousPrefix` naming the same UUID twice: without cross-table
6909    /// dedup, `matches.len()` becomes 2 for a single record.
6910    #[tokio::test]
6911    async fn resolve_prefix_cross_table_duplicate_uuid_resolves_cleanly() {
6912        use khive_storage::entity::Entity;
6913
6914        let rt = rt();
6915        let tok = NamespaceToken::local();
6916        let shared_id = Uuid::parse_str("ccddeeff-1111-4000-8000-000000000001").unwrap();
6917
6918        let mut entity = Entity::new("local", "concept", "Nvk749Entity");
6919        entity.id = shared_id;
6920        rt.entities(&tok)
6921            .unwrap()
6922            .upsert_entity(entity)
6923            .await
6924            .unwrap();
6925
6926        let mut note = Note::new("local", "observation", "nvk749 note with the same id");
6927        note.id = shared_id;
6928        rt.notes(&tok).unwrap().upsert_note(note).await.unwrap();
6929
6930        let resolved = rt
6931            .resolve_prefix(&tok, "ccddeeff")
6932            .await
6933            .expect("#749: a UUID present in two tables must not be reported as ambiguous");
6934        assert_eq!(
6935            resolved,
6936            Some(shared_id),
6937            "#749: cross-table duplicate must resolve to the single shared UUID"
6938        );
6939    }
6940
6941    /// The early-exit inside the per-table scan loop (`if matches.len()
6942    /// > 1 { break }`) must also operate on DEDUPED state — otherwise a
6943    /// cross-table duplicate could still short-circuit the scan before a
6944    /// later table contributes the SAME UUID again, which would have masked
6945    /// the bug rather than exercising it. This drives the duplicate through
6946    /// the earliest two tables scanned (entities, notes) so the early-exit
6947    /// path is the one under test, not a post-loop dedup applied too late.
6948    #[tokio::test]
6949    async fn resolve_prefix_early_exit_uses_deduped_match_count() {
6950        use khive_storage::entity::Entity;
6951
6952        let rt = rt();
6953        let tok = NamespaceToken::local();
6954        let shared_id = Uuid::parse_str("ddeeff11-2222-4000-8000-000000000002").unwrap();
6955
6956        // entities and notes are the first two tables scanned inside
6957        // resolve_prefix_inner — the same UUID in both must not trip the
6958        // mid-scan `matches.len() > 1` break as if two distinct UUIDs had
6959        // been found.
6960        let mut entity = Entity::new("local", "concept", "Nvk749bEntity");
6961        entity.id = shared_id;
6962        rt.entities(&tok)
6963            .unwrap()
6964            .upsert_entity(entity)
6965            .await
6966            .unwrap();
6967
6968        let mut note = Note::new("local", "observation", "nvk749b note with the same id");
6969        note.id = shared_id;
6970        rt.notes(&tok).unwrap().upsert_note(note).await.unwrap();
6971
6972        let resolved = rt
6973            .resolve_prefix(&tok, "ddeeff11")
6974            .await
6975            .expect("#749: deduped early-exit must not falsely report ambiguity");
6976        assert_eq!(resolved, Some(shared_id));
6977    }
6978
6979    // ---- Event resolution tests ----
6980    //
6981    // resolve_prefix and handle_get already include events; these tests are
6982    // regression coverage confirming event UUIDs are resolvable and that get()
6983    // returns kind="event".
6984
6985    #[tokio::test]
6986    async fn resolve_finds_event_by_full_uuid() {
6987        use khive_storage::Event;
6988        use khive_types::{EventKind, SubstrateKind};
6989
6990        let rt = rt();
6991        let tok = NamespaceToken::local();
6992        let ns = tok.namespace().as_str();
6993        let event = Event::new(
6994            ns,
6995            "test_verb",
6996            EventKind::Audit,
6997            SubstrateKind::Entity,
6998            "actor",
6999        );
7000        let event_id = event.id;
7001        rt.events(&tok).unwrap().append_event(event).await.unwrap();
7002
7003        let resolved = rt.resolve(&tok, event_id).await.unwrap();
7004        assert!(
7005            matches!(resolved, Some(Resolved::Event(_))),
7006            "event UUID must resolve to Resolved::Event, got {resolved:?}"
7007        );
7008    }
7009
7010    #[tokio::test]
7011    async fn resolve_prefix_finds_event() {
7012        use khive_storage::Event;
7013        use khive_types::{EventKind, SubstrateKind};
7014
7015        let rt = rt();
7016        let tok = NamespaceToken::local();
7017        let ns = tok.namespace().as_str();
7018        let event = Event::new(
7019            ns,
7020            "test_verb",
7021            EventKind::Audit,
7022            SubstrateKind::Entity,
7023            "actor",
7024        );
7025        let event_id = event.id;
7026        rt.events(&tok).unwrap().append_event(event).await.unwrap();
7027
7028        let prefix = &event_id.to_string()[..8];
7029        let resolved = rt.resolve_prefix(&tok, prefix).await.unwrap();
7030        assert_eq!(
7031            resolved,
7032            Some(event_id),
7033            "resolve_prefix must return event UUID for 8-char prefix"
7034        );
7035    }
7036
7037    // ---- Referential integrity tests ----
7038
7039    #[tokio::test]
7040    async fn link_phantom_source_returns_not_found() {
7041        let rt = rt();
7042        let tok = NamespaceToken::local();
7043        let b = rt
7044            .create_entity(&tok, "concept", None, "B", None, None, vec![])
7045            .await
7046            .unwrap();
7047        let phantom = Uuid::new_v4();
7048
7049        let result = rt
7050            .link(&tok, phantom, b.id, EdgeRelation::Extends, 1.0, None)
7051            .await;
7052        match result {
7053            Err(RuntimeError::NotFound(msg)) => {
7054                assert!(
7055                    msg.contains("source"),
7056                    "error message must name 'source': {msg}"
7057                );
7058            }
7059            other => panic!("expected NotFound for phantom source, got {other:?}"),
7060        }
7061    }
7062
7063    #[tokio::test]
7064    async fn link_phantom_target_returns_not_found() {
7065        let rt = rt();
7066        let tok = NamespaceToken::local();
7067        let a = rt
7068            .create_entity(&tok, "concept", None, "A", None, None, vec![])
7069            .await
7070            .unwrap();
7071        let phantom = Uuid::new_v4();
7072
7073        let result = rt
7074            .link(&tok, a.id, phantom, EdgeRelation::Extends, 1.0, None)
7075            .await;
7076        match result {
7077            Err(RuntimeError::NotFound(msg)) => {
7078                assert!(
7079                    msg.contains("target"),
7080                    "error message must name 'target': {msg}"
7081                );
7082            }
7083            other => panic!("expected NotFound for phantom target, got {other:?}"),
7084        }
7085    }
7086
7087    #[tokio::test]
7088    async fn link_real_entities_succeeds() {
7089        let rt = rt();
7090        let tok = NamespaceToken::local();
7091        let a = rt
7092            .create_entity(&tok, "concept", None, "A", None, None, vec![])
7093            .await
7094            .unwrap();
7095        let b = rt
7096            .create_entity(&tok, "concept", None, "B", None, None, vec![])
7097            .await
7098            .unwrap();
7099
7100        let edge = rt
7101            .link(&tok, a.id, b.id, EdgeRelation::Extends, 0.8, None)
7102            .await
7103            .unwrap();
7104        assert_eq!(edge.source_id, a.id);
7105        assert_eq!(edge.target_id, b.id);
7106        assert_eq!(edge.relation, EdgeRelation::Extends);
7107    }
7108
7109    // ---- commit-time endpoint guard vs concurrent hard-delete ----
7110
7111    /// Deterministic form of the regression, exercised directly at the
7112    /// write step `link` performs after prepare-time validation: build the
7113    /// exact `Edge` `link` would build, delete the target the way a
7114    /// concurrent racer would, and confirm the guarded write refuses it.
7115    #[tokio::test]
7116    async fn link_write_time_guard_blocks_dangling_edge_after_target_vanishes() {
7117        let rt = rt();
7118        let tok = NamespaceToken::local();
7119        let a = rt
7120            .create_entity(&tok, "concept", None, "A", None, None, vec![])
7121            .await
7122            .unwrap();
7123        let x = rt
7124            .create_entity(&tok, "concept", None, "X", None, None, vec![])
7125            .await
7126            .unwrap();
7127
7128        rt.validate_edge_relation_endpoints(&tok, a.id, x.id, EdgeRelation::Extends)
7129            .await
7130            .expect("prepare-time validation must pass while X is live");
7131
7132        assert!(rt.delete_entity(&tok, x.id, true).await.unwrap());
7133
7134        let now = chrono::Utc::now();
7135        let edge = Edge {
7136            id: LinkId::from(Uuid::new_v4()),
7137            namespace: tok.namespace().as_str().to_string(),
7138            source_id: a.id,
7139            target_id: x.id,
7140            relation: EdgeRelation::Extends,
7141            weight: 1.0,
7142            created_at: now,
7143            updated_at: now,
7144            deleted_at: None,
7145            metadata: None,
7146            target_backend: None,
7147        };
7148        let outcome = rt
7149            .graph(&tok)
7150            .unwrap()
7151            .upsert_edge_guarded(edge)
7152            .await
7153            .unwrap();
7154        match outcome {
7155            khive_storage::GuardedWriteOutcome::Refused(missing) => {
7156                assert!(missing.target, "target must be reported missing");
7157            }
7158            other => panic!(
7159                "guarded write must refuse an edge whose target vanished before commit, got {other:?}"
7160            ),
7161        }
7162
7163        let edges = rt
7164            .list_edges(
7165                &tok,
7166                crate::curation::EdgeListFilter {
7167                    source_id: Some(a.id),
7168                    target_id: Some(x.id),
7169                    relations: vec![EdgeRelation::Extends],
7170                    ..Default::default()
7171                },
7172                10,
7173                0,
7174            )
7175            .await
7176            .unwrap();
7177        assert!(
7178            edges.is_empty(),
7179            "no dangling edge may be persisted after the guarded write refused it"
7180        );
7181    }
7182
7183    #[tokio::test]
7184    async fn link_many_writes_nothing_when_one_target_vanishes_before_write() {
7185        let rt = rt();
7186        let tok = NamespaceToken::local();
7187        let a = rt
7188            .create_entity(&tok, "concept", None, "A", None, None, vec![])
7189            .await
7190            .unwrap();
7191        let b = rt
7192            .create_entity(&tok, "concept", None, "B", None, None, vec![])
7193            .await
7194            .unwrap();
7195        let x = rt
7196            .create_entity(&tok, "concept", None, "X", None, None, vec![])
7197            .await
7198            .unwrap();
7199
7200        let specs = vec![
7201            LinkSpec {
7202                namespace: None,
7203                source_id: a.id,
7204                target_id: x.id,
7205                relation: EdgeRelation::Extends,
7206                weight: 1.0,
7207                metadata: None,
7208            },
7209            LinkSpec {
7210                namespace: None,
7211                source_id: a.id,
7212                target_id: b.id,
7213                relation: EdgeRelation::Extends,
7214                weight: 1.0,
7215                metadata: None,
7216            },
7217        ];
7218
7219        // Both specs validate fine at build_edge time (X and B both live).
7220        let mut edges = Vec::with_capacity(specs.len());
7221        for spec in &specs {
7222            edges.push(rt.build_edge(&tok, spec).await.unwrap());
7223        }
7224
7225        // X vanishes before the batched write — mirrors a concurrent
7226        // hard-delete landing between per-spec validation and link_many's
7227        // single guarded batch write.
7228        assert!(rt.delete_entity(&tok, x.id, true).await.unwrap());
7229
7230        let outcome = rt
7231            .graph(&tok)
7232            .unwrap()
7233            .upsert_edges_guarded(edges)
7234            .await
7235            .unwrap();
7236        assert_eq!(
7237            outcome.summary.affected, 0,
7238            "no edge from the batch may be persisted when any endpoint vanished"
7239        );
7240        assert!(
7241            outcome.refused.is_some(),
7242            "refused batch entry must be reported"
7243        );
7244
7245        let edges = rt
7246            .list_edges(
7247                &tok,
7248                crate::curation::EdgeListFilter {
7249                    source_id: Some(a.id),
7250                    relations: vec![EdgeRelation::Extends],
7251                    ..Default::default()
7252                },
7253                10,
7254                0,
7255            )
7256            .await
7257            .unwrap();
7258        assert!(
7259            edges.is_empty(),
7260            "link_many's guarded batch must be all-or-nothing: the live A-B edge \
7261             must not have been persisted alongside the doomed A-X edge"
7262        );
7263    }
7264
7265    // ---- hard-delete row + incident-edge purge is ONE transaction ----
7266    //
7267    // Six tests below cover both orderings (write-then-delete, and a
7268    // concurrent write raced against delete via `tokio::join!`) across all
7269    // three hard-delete paths that cascade-purge incident edges: entity,
7270    // note, and edge-as-node. No sleeps — the "concurrent" tests assert an
7271    // invariant that must hold for EITHER interleaving the async scheduler
7272    // picks, rather than forcing one specific interleaving, so they are
7273    // deterministic (never flaky) without a barrier.
7274
7275    fn raw_edge(source_id: Uuid, target_id: Uuid, ns: &str) -> Edge {
7276        let now = chrono::Utc::now();
7277        Edge {
7278            id: LinkId::from(Uuid::new_v4()),
7279            namespace: ns.to_string(),
7280            source_id,
7281            target_id,
7282            relation: EdgeRelation::Extends,
7283            weight: 1.0,
7284            created_at: now,
7285            updated_at: now,
7286            deleted_at: None,
7287            metadata: None,
7288            target_backend: None,
7289        }
7290    }
7291
7292    async fn assert_no_edges_touch(rt: &KhiveRuntime, tok: &NamespaceToken, node_id: Uuid) {
7293        let as_source = rt
7294            .list_edges(
7295                tok,
7296                crate::curation::EdgeListFilter {
7297                    source_id: Some(node_id),
7298                    ..Default::default()
7299                },
7300                10,
7301                0,
7302            )
7303            .await
7304            .unwrap();
7305        let as_target = rt
7306            .list_edges(
7307                tok,
7308                crate::curation::EdgeListFilter {
7309                    target_id: Some(node_id),
7310                    ..Default::default()
7311                },
7312                10,
7313                0,
7314            )
7315            .await
7316            .unwrap();
7317        assert!(
7318            as_source.is_empty() && as_target.is_empty(),
7319            "no edge may reference hard-deleted node {node_id}: source-side={as_source:?} \
7320             target-side={as_target:?}"
7321        );
7322    }
7323
7324    #[tokio::test]
7325    async fn hard_delete_entity_purges_edge_written_before_delete() {
7326        let rt = rt();
7327        let tok = NamespaceToken::local();
7328        let a = rt
7329            .create_entity(&tok, "concept", None, "A", None, None, vec![])
7330            .await
7331            .unwrap();
7332        let x = rt
7333            .create_entity(&tok, "concept", None, "X", None, None, vec![])
7334            .await
7335            .unwrap();
7336
7337        let edge = raw_edge(a.id, x.id, tok.namespace().as_str());
7338        assert_eq!(
7339            rt.graph(&tok)
7340                .unwrap()
7341                .upsert_edge_guarded(edge)
7342                .await
7343                .unwrap(),
7344            khive_storage::GuardedWriteOutcome::Written
7345        );
7346
7347        assert!(rt.delete_entity(&tok, x.id, true).await.unwrap());
7348        assert_no_edges_touch(&rt, &tok, x.id).await;
7349    }
7350
7351    #[tokio::test]
7352    async fn hard_delete_entity_concurrent_with_guarded_write_never_leaves_dangling_edge() {
7353        let rt = std::sync::Arc::new(rt());
7354        let tok = NamespaceToken::local();
7355        let a = rt
7356            .create_entity(&tok, "concept", None, "A", None, None, vec![])
7357            .await
7358            .unwrap();
7359        let x = rt
7360            .create_entity(&tok, "concept", None, "X", None, None, vec![])
7361            .await
7362            .unwrap();
7363
7364        let delete_rt = std::sync::Arc::clone(&rt);
7365        let delete_tok = tok.clone();
7366        let delete_task =
7367            tokio::spawn(async move { delete_rt.delete_entity(&delete_tok, x.id, true).await });
7368
7369        let write_rt = std::sync::Arc::clone(&rt);
7370        let write_tok = tok.clone();
7371        let ns = tok.namespace().as_str().to_string();
7372        let write_task = tokio::spawn(async move {
7373            let edge = raw_edge(a.id, x.id, &ns);
7374            write_rt
7375                .graph(&write_tok)
7376                .unwrap()
7377                .upsert_edge_guarded(edge)
7378                .await
7379        });
7380
7381        let (deleted, _written) = tokio::join!(delete_task, write_task);
7382        deleted.unwrap().unwrap();
7383        assert_no_edges_touch(&rt, &tok, x.id).await;
7384    }
7385
7386    #[tokio::test]
7387    async fn hard_delete_note_purges_edge_written_before_delete() {
7388        let rt = rt();
7389        let tok = NamespaceToken::local();
7390        let a = rt
7391            .create_entity(&tok, "concept", None, "A", None, None, vec![])
7392            .await
7393            .unwrap();
7394        let n = rt
7395            .create_note(
7396                &tok,
7397                "observation",
7398                None,
7399                "note content",
7400                None,
7401                None,
7402                vec![],
7403            )
7404            .await
7405            .unwrap();
7406
7407        let edge = raw_edge(a.id, n.id, tok.namespace().as_str());
7408        assert_eq!(
7409            rt.graph(&tok)
7410                .unwrap()
7411                .upsert_edge_guarded(edge)
7412                .await
7413                .unwrap(),
7414            khive_storage::GuardedWriteOutcome::Written
7415        );
7416
7417        assert!(rt.delete_note(&tok, n.id, true).await.unwrap());
7418        assert_no_edges_touch(&rt, &tok, n.id).await;
7419    }
7420
7421    #[tokio::test]
7422    async fn hard_delete_note_concurrent_with_guarded_write_never_leaves_dangling_edge() {
7423        let rt = std::sync::Arc::new(rt());
7424        let tok = NamespaceToken::local();
7425        let a = rt
7426            .create_entity(&tok, "concept", None, "A", None, None, vec![])
7427            .await
7428            .unwrap();
7429        let n = rt
7430            .create_note(
7431                &tok,
7432                "observation",
7433                None,
7434                "note content",
7435                None,
7436                None,
7437                vec![],
7438            )
7439            .await
7440            .unwrap();
7441
7442        let delete_rt = std::sync::Arc::clone(&rt);
7443        let delete_tok = tok.clone();
7444        let note_id = n.id;
7445        let delete_task =
7446            tokio::spawn(async move { delete_rt.delete_note(&delete_tok, note_id, true).await });
7447
7448        let write_rt = std::sync::Arc::clone(&rt);
7449        let write_tok = tok.clone();
7450        let ns = tok.namespace().as_str().to_string();
7451        let write_task = tokio::spawn(async move {
7452            let edge = raw_edge(a.id, note_id, &ns);
7453            write_rt
7454                .graph(&write_tok)
7455                .unwrap()
7456                .upsert_edge_guarded(edge)
7457                .await
7458        });
7459
7460        let (deleted, _written) = tokio::join!(delete_task, write_task);
7461        deleted.unwrap().unwrap();
7462        assert_no_edges_touch(&rt, &tok, note_id).await;
7463    }
7464
7465    #[tokio::test]
7466    async fn hard_delete_edge_endpoint_purges_annotating_edge_written_before_delete() {
7467        let rt = rt();
7468        let tok = NamespaceToken::local();
7469        let a = rt
7470            .create_entity(&tok, "concept", None, "A", None, None, vec![])
7471            .await
7472            .unwrap();
7473        let b = rt
7474            .create_entity(&tok, "concept", None, "B", None, None, vec![])
7475            .await
7476            .unwrap();
7477        let n = rt
7478            .create_note(
7479                &tok,
7480                "observation",
7481                None,
7482                "note content",
7483                None,
7484                None,
7485                vec![],
7486            )
7487            .await
7488            .unwrap();
7489        let base_edge = rt
7490            .link(&tok, a.id, b.id, EdgeRelation::Extends, 0.8, None)
7491            .await
7492            .unwrap();
7493        let base_edge_id = Uuid::from(base_edge.id);
7494
7495        // An edge whose TARGET is another edge — the "edge-as-node" case
7496        // `delete_edge`'s cascade must sweep.
7497        let annotating = raw_edge(n.id, base_edge_id, tok.namespace().as_str());
7498        assert_eq!(
7499            rt.graph(&tok)
7500                .unwrap()
7501                .upsert_edge_guarded(annotating)
7502                .await
7503                .unwrap(),
7504            khive_storage::GuardedWriteOutcome::Written
7505        );
7506
7507        assert!(rt.delete_edge(&tok, base_edge_id, true).await.unwrap());
7508        assert_no_edges_touch(&rt, &tok, base_edge_id).await;
7509    }
7510
7511    #[tokio::test]
7512    async fn hard_delete_edge_endpoint_concurrent_with_guarded_write_never_leaves_dangling_edge() {
7513        let rt = std::sync::Arc::new(rt());
7514        let tok = NamespaceToken::local();
7515        let a = rt
7516            .create_entity(&tok, "concept", None, "A", None, None, vec![])
7517            .await
7518            .unwrap();
7519        let b = rt
7520            .create_entity(&tok, "concept", None, "B", None, None, vec![])
7521            .await
7522            .unwrap();
7523        let n = rt
7524            .create_note(
7525                &tok,
7526                "observation",
7527                None,
7528                "note content",
7529                None,
7530                None,
7531                vec![],
7532            )
7533            .await
7534            .unwrap();
7535        let base_edge = rt
7536            .link(&tok, a.id, b.id, EdgeRelation::Extends, 0.8, None)
7537            .await
7538            .unwrap();
7539        let base_edge_id = Uuid::from(base_edge.id);
7540
7541        let delete_rt = std::sync::Arc::clone(&rt);
7542        let delete_tok = tok.clone();
7543        let delete_task =
7544            tokio::spawn(
7545                async move { delete_rt.delete_edge(&delete_tok, base_edge_id, true).await },
7546            );
7547
7548        let write_rt = std::sync::Arc::clone(&rt);
7549        let write_tok = tok.clone();
7550        let ns = tok.namespace().as_str().to_string();
7551        let write_task = tokio::spawn(async move {
7552            let edge = raw_edge(n.id, base_edge_id, &ns);
7553            write_rt
7554                .graph(&write_tok)
7555                .unwrap()
7556                .upsert_edge_guarded(edge)
7557                .await
7558        });
7559
7560        let (deleted, _written) = tokio::join!(delete_task, write_task);
7561        deleted.unwrap().unwrap();
7562        assert_no_edges_touch(&rt, &tok, base_edge_id).await;
7563    }
7564
7565    // ---- file-backed, both write-queue configs ----
7566    //
7567    // The six tests above run against `KhiveRuntime::memory()` and race
7568    // delete against the guarded write via `tokio::join!` with no explicit
7569    // ordering control, so the scheduler could run them fully sequentially
7570    // on one thread without ever exercising real interleaving, and neither
7571    // the file-backed storage path nor `write_queue_enabled: true`
7572    // (`KHIVE_WRITE_QUEUE=1`, the `WriterTask`-routed write path in
7573    // `SqlGraphStore`) is covered at all. The four tests below close both
7574    // gaps: file-backed databases, one run with the writer queue off
7575    // (default) and one with it on, each provably forcing the guarded write
7576    // to land on one specific side of the delete — fully committed before
7577    // the delete starts (swept by the delete's cascade) and attempted only
7578    // after the delete has already committed (refused by the guard) — via
7579    // plain `.await` sequencing rather than a race whose outcome the test
7580    // does not control.
7581
7582    fn file_backed_runtime(
7583        dir: &tempfile::TempDir,
7584        name: &str,
7585        write_queue_enabled: bool,
7586    ) -> KhiveRuntime {
7587        let path = dir.path().join(name);
7588        if write_queue_enabled {
7589            std::env::set_var("KHIVE_WRITE_QUEUE", "1");
7590        } else {
7591            std::env::remove_var("KHIVE_WRITE_QUEUE");
7592        }
7593        let rt = KhiveRuntime::new(crate::config::RuntimeConfig {
7594            db_path: Some(path),
7595            packs: vec!["kg".to_string()],
7596            brain_profile: None,
7597            actor_id: None,
7598            ..crate::config::RuntimeConfig::no_embeddings()
7599        })
7600        .unwrap();
7601        std::env::remove_var("KHIVE_WRITE_QUEUE");
7602        rt
7603    }
7604
7605    async fn assert_guarded_write_committed_before_delete_is_swept(rt: &KhiveRuntime) {
7606        let tok = NamespaceToken::local();
7607        let a = rt
7608            .create_entity(&tok, "concept", None, "A", None, None, vec![])
7609            .await
7610            .unwrap();
7611        let x = rt
7612            .create_entity(&tok, "concept", None, "X", None, None, vec![])
7613            .await
7614            .unwrap();
7615
7616        // Write lands fully committed while X is still live — squarely
7617        // inside the window before the delete's cascade runs.
7618        let edge = raw_edge(a.id, x.id, tok.namespace().as_str());
7619        assert_eq!(
7620            rt.graph(&tok)
7621                .unwrap()
7622                .upsert_edge_guarded(edge)
7623                .await
7624                .unwrap(),
7625            khive_storage::GuardedWriteOutcome::Written,
7626            "write must succeed while both endpoints are still live"
7627        );
7628
7629        assert!(rt.delete_entity(&tok, x.id, true).await.unwrap());
7630        assert_no_edges_touch(rt, &tok, x.id).await;
7631    }
7632
7633    async fn assert_guarded_write_attempted_after_delete_is_refused(rt: &KhiveRuntime) {
7634        let tok = NamespaceToken::local();
7635        let a = rt
7636            .create_entity(&tok, "concept", None, "A", None, None, vec![])
7637            .await
7638            .unwrap();
7639        let x = rt
7640            .create_entity(&tok, "concept", None, "X", None, None, vec![])
7641            .await
7642            .unwrap();
7643
7644        // The delete's transaction has fully committed before the guarded
7645        // write is even attempted — squarely after the window has closed.
7646        assert!(rt.delete_entity(&tok, x.id, true).await.unwrap());
7647
7648        let edge = raw_edge(a.id, x.id, tok.namespace().as_str());
7649        let outcome = rt
7650            .graph(&tok)
7651            .unwrap()
7652            .upsert_edge_guarded(edge)
7653            .await
7654            .unwrap();
7655        match outcome {
7656            khive_storage::GuardedWriteOutcome::Refused(missing) => {
7657                assert!(
7658                    missing.target,
7659                    "target must be reported missing once the delete has committed"
7660                );
7661                assert!(!missing.source, "source was never deleted");
7662            }
7663            other => panic!(
7664                "guarded write attempted after the delete committed must be refused, got {other:?}"
7665            ),
7666        }
7667        assert_no_edges_touch(rt, &tok, x.id).await;
7668    }
7669
7670    #[tokio::test]
7671    #[serial_test::serial(khive_write_queue_env)]
7672    async fn guarded_write_before_delete_swept_file_backed_write_queue_off() {
7673        let dir = tempfile::tempdir().unwrap();
7674        let rt = file_backed_runtime(&dir, "guard_before_off.db", false);
7675        assert_guarded_write_committed_before_delete_is_swept(&rt).await;
7676    }
7677
7678    #[tokio::test]
7679    #[serial_test::serial(khive_write_queue_env)]
7680    async fn guarded_write_after_delete_refused_file_backed_write_queue_off() {
7681        let dir = tempfile::tempdir().unwrap();
7682        let rt = file_backed_runtime(&dir, "guard_after_off.db", false);
7683        assert_guarded_write_attempted_after_delete_is_refused(&rt).await;
7684    }
7685
7686    #[tokio::test]
7687    #[serial_test::serial(khive_write_queue_env)]
7688    async fn guarded_write_before_delete_swept_file_backed_write_queue_on() {
7689        let dir = tempfile::tempdir().unwrap();
7690        let rt = file_backed_runtime(&dir, "guard_before_on.db", true);
7691        assert_guarded_write_committed_before_delete_is_swept(&rt).await;
7692    }
7693
7694    #[tokio::test]
7695    #[serial_test::serial(khive_write_queue_env)]
7696    async fn guarded_write_after_delete_refused_file_backed_write_queue_on() {
7697        let dir = tempfile::tempdir().unwrap();
7698        let rt = file_backed_runtime(&dir, "guard_after_on.db", true);
7699        assert_guarded_write_attempted_after_delete_is_refused(&rt).await;
7700    }
7701
7702    #[tokio::test]
7703    async fn create_note_annotates_phantom_returns_not_found() {
7704        let rt = rt();
7705        let tok = NamespaceToken::local();
7706        let phantom = Uuid::new_v4();
7707
7708        let result = rt
7709            .create_note(
7710                &tok,
7711                "observation",
7712                None,
7713                "some content",
7714                Some(0.5),
7715                None,
7716                vec![phantom],
7717            )
7718            .await;
7719        assert!(
7720            matches!(result, Err(RuntimeError::NotFound(_))),
7721            "annotates with phantom uuid must return NotFound, got {result:?}"
7722        );
7723    }
7724
7725    #[tokio::test]
7726    async fn create_note_annotates_real_entity_succeeds() {
7727        let rt = rt();
7728        let tok = NamespaceToken::local();
7729        let entity = rt
7730            .create_entity(&tok, "concept", None, "RealTarget", None, None, vec![])
7731            .await
7732            .unwrap();
7733
7734        let note = rt
7735            .create_note(
7736                &tok,
7737                "observation",
7738                None,
7739                "content",
7740                Some(0.5),
7741                None,
7742                vec![entity.id],
7743            )
7744            .await
7745            .unwrap();
7746
7747        let neighbors = rt
7748            .neighbors(
7749                &tok,
7750                note.id,
7751                Direction::Out,
7752                None,
7753                Some(vec![EdgeRelation::Annotates]),
7754            )
7755            .await
7756            .unwrap();
7757        assert_eq!(neighbors.len(), 1);
7758        assert_eq!(neighbors[0].node_id, entity.id);
7759    }
7760
7761    // Atomicity: multi-target annotates golden path — all edges created, note present.
7762    #[tokio::test]
7763    async fn create_note_multi_annotates_creates_all_edges() {
7764        let rt = rt();
7765        let tok = NamespaceToken::local();
7766        let t1 = rt
7767            .create_entity(&tok, "concept", None, "Target1", None, None, vec![])
7768            .await
7769            .unwrap();
7770        let t2 = rt
7771            .create_entity(&tok, "concept", None, "Target2", None, None, vec![])
7772            .await
7773            .unwrap();
7774
7775        let note = rt
7776            .create_note(
7777                &tok,
7778                "observation",
7779                None,
7780                "content",
7781                Some(0.5),
7782                None,
7783                vec![t1.id, t2.id],
7784            )
7785            .await
7786            .unwrap();
7787
7788        let neighbors = rt
7789            .neighbors(
7790                &tok,
7791                note.id,
7792                Direction::Out,
7793                None,
7794                Some(vec![EdgeRelation::Annotates]),
7795            )
7796            .await
7797            .unwrap();
7798        assert_eq!(
7799            neighbors.len(),
7800            2,
7801            "multi-annotates note must have exactly 2 outbound annotates edges"
7802        );
7803        let target_ids: Vec<Uuid> = neighbors.iter().map(|n| n.node_id).collect();
7804        assert!(target_ids.contains(&t1.id));
7805        assert!(target_ids.contains(&t2.id));
7806    }
7807
7808    /// `link` endpoint existence is a by-ID check and therefore namespace-agnostic:
7809    /// a target living in a different namespace than the caller must still
7810    /// resolve, exactly as `get()` would.
7811    #[tokio::test]
7812    async fn link_target_in_different_namespace_succeeds() {
7813        let rt = rt();
7814        let ns_a = NamespaceToken::for_namespace(Namespace::parse("ns-a").unwrap());
7815        let ns_b = NamespaceToken::for_namespace(Namespace::parse("ns-b").unwrap());
7816        let a = rt
7817            .create_entity(&ns_a, "concept", None, "A", None, None, vec![])
7818            .await
7819            .unwrap();
7820        let b = rt
7821            .create_entity(&ns_b, "concept", None, "B", None, None, vec![])
7822            .await
7823            .unwrap();
7824
7825        // Linking from ns-a: target b lives in ns-b — by-ID resolution finds it anyway.
7826        let result = rt
7827            .link(&ns_a, a.id, b.id, EdgeRelation::Extends, 1.0, None)
7828            .await;
7829        assert!(
7830            result.is_ok(),
7831            "target in a different namespace than the caller must resolve (#631), got {result:?}"
7832        );
7833    }
7834
7835    #[tokio::test]
7836    async fn link_phantom_self_loop_returns_invalid_input() {
7837        let rt = rt();
7838        let tok = NamespaceToken::local();
7839        let phantom = Uuid::new_v4();
7840
7841        let result = rt
7842            .link(&tok, phantom, phantom, EdgeRelation::Extends, 1.0, None)
7843            .await;
7844        match result {
7845            Err(RuntimeError::InvalidInput(msg)) => {
7846                assert!(
7847                    msg.contains("self-loop"),
7848                    "self-loop must be rejected with self-loop message: {msg}"
7849                );
7850            }
7851            other => panic!("expected InvalidInput for self-loop, got {other:?}"),
7852        }
7853    }
7854
7855    // ---- edge target coverage + atomicity ----
7856
7857    #[tokio::test]
7858    async fn link_note_to_edge_annotates_succeeds() {
7859        let rt = rt();
7860        let tok = NamespaceToken::local();
7861        let a = rt
7862            .create_entity(&tok, "concept", None, "A", None, None, vec![])
7863            .await
7864            .unwrap();
7865        let b = rt
7866            .create_entity(&tok, "concept", None, "B", None, None, vec![])
7867            .await
7868            .unwrap();
7869        // Create a real edge between a and b, capture its UUID.
7870        let edge = rt
7871            .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
7872            .await
7873            .unwrap();
7874        let edge_uuid: Uuid = edge.id.into();
7875
7876        // Create a note and annotate the edge itself (edge is a valid substrate target for annotates).
7877        let note = rt
7878            .create_note(
7879                &tok,
7880                "observation",
7881                None,
7882                "edge note",
7883                Some(0.5),
7884                None,
7885                vec![],
7886            )
7887            .await
7888            .unwrap();
7889
7890        let result = rt
7891            .link(&tok, note.id, edge_uuid, EdgeRelation::Annotates, 1.0, None)
7892            .await;
7893        assert!(
7894            result.is_ok(),
7895            "note→edge Annotates must succeed, got {result:?}"
7896        );
7897    }
7898    /// #803: `neighbors(edge_id, direction=In, relations=[Annotates])` must
7899    /// find the annotating note — the storage-layer `graph_edges` query
7900    /// filters on `target_id = node_id` with no substrate-type check, so an
7901    /// edge id works as a neighbor-query node the same as an entity or note
7902    /// id. This is the runtime capability `get(edge_id)`'s new `annotations`
7903    /// field (khive-pack-kg) builds on.
7904    #[tokio::test]
7905    async fn neighbors_edge_id_finds_annotating_note() {
7906        let rt = rt();
7907        let tok = NamespaceToken::local();
7908        let a = rt
7909            .create_entity(&tok, "concept", None, "A", None, None, vec![])
7910            .await
7911            .unwrap();
7912        let b = rt
7913            .create_entity(&tok, "concept", None, "B", None, None, vec![])
7914            .await
7915            .unwrap();
7916        let edge = rt
7917            .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
7918            .await
7919            .unwrap();
7920        let edge_uuid: Uuid = edge.id.into();
7921
7922        let note = rt
7923            .create_note(
7924                &tok,
7925                "observation",
7926                None,
7927                "edge note",
7928                Some(0.5),
7929                None,
7930                vec![edge_uuid],
7931            )
7932            .await
7933            .unwrap();
7934
7935        let neighbors = rt
7936            .neighbors(
7937                &tok,
7938                edge_uuid,
7939                Direction::In,
7940                None,
7941                Some(vec![EdgeRelation::Annotates]),
7942            )
7943            .await
7944            .unwrap();
7945        assert_eq!(neighbors.len(), 1, "expected annotating note to show up");
7946        assert_eq!(neighbors[0].node_id, note.id);
7947    }
7948
7949    #[tokio::test]
7950    async fn create_note_annotates_real_edge_succeeds() {
7951        let rt = rt();
7952        let tok = NamespaceToken::local();
7953        let a = rt
7954            .create_entity(&tok, "concept", None, "A", None, None, vec![])
7955            .await
7956            .unwrap();
7957        let b = rt
7958            .create_entity(&tok, "concept", None, "B", None, None, vec![])
7959            .await
7960            .unwrap();
7961        let edge = rt
7962            .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
7963            .await
7964            .unwrap();
7965        let edge_uuid: Uuid = edge.id.into();
7966
7967        let note = rt
7968            .create_note(
7969                &tok,
7970                "observation",
7971                None,
7972                "annotating an edge",
7973                Some(0.5),
7974                None,
7975                vec![edge_uuid],
7976            )
7977            .await
7978            .unwrap();
7979
7980        let neighbors = rt
7981            .neighbors(
7982                &tok,
7983                note.id,
7984                Direction::Out,
7985                None,
7986                Some(vec![EdgeRelation::Annotates]),
7987            )
7988            .await
7989            .unwrap();
7990        assert_eq!(neighbors.len(), 1);
7991        assert_eq!(neighbors[0].node_id, edge_uuid);
7992    }
7993
7994    #[tokio::test]
7995    async fn create_note_annotates_phantom_is_atomic_no_note_persisted() {
7996        let rt = rt();
7997        let tok = NamespaceToken::local();
7998        let phantom = Uuid::new_v4();
7999
8000        let before_count = rt.list_notes(&tok, None, 1000, 0).await.unwrap().len();
8001
8002        let result = rt
8003            .create_note(
8004                &tok,
8005                "observation",
8006                None,
8007                "should not persist",
8008                Some(0.5),
8009                None,
8010                vec![phantom],
8011            )
8012            .await;
8013        assert!(
8014            matches!(result, Err(RuntimeError::NotFound(_))),
8015            "phantom annotates target must return NotFound, got {result:?}"
8016        );
8017
8018        // Atomicity: the note row must NOT have been written.
8019        let after_count = rt.list_notes(&tok, None, 1000, 0).await.unwrap().len();
8020        assert_eq!(
8021            before_count, after_count,
8022            "failed create_note must not persist any note row (atomicity)"
8023        );
8024
8025        // FTS must not contain the content either.
8026        let search_hits = rt
8027            .search_notes(&tok, "should not persist", None, 10, None, false, &[], None)
8028            .await
8029            .unwrap();
8030        assert!(
8031            search_hits.is_empty(),
8032            "failed create_note must not index into FTS (atomicity)"
8033        );
8034        // Vector-store row: only written when an embedding model is configured; the rt()
8035        // harness has none, so no vector assertion is needed here.
8036    }
8037
8038    // ---- relation-aware endpoint contract ----
8039
8040    // Test #2: entity→entity with non-annotates rejects an edge UUID as target.
8041    #[tokio::test]
8042    async fn link_entity_to_edge_uuid_non_annotates_returns_invalid_input() {
8043        let rt = rt();
8044        let tok = NamespaceToken::local();
8045        let a = rt
8046            .create_entity(&tok, "concept", None, "A", None, None, vec![])
8047            .await
8048            .unwrap();
8049        let b = rt
8050            .create_entity(&tok, "concept", None, "B", None, None, vec![])
8051            .await
8052            .unwrap();
8053        // Create a real edge; capture its UUID as the bad target.
8054        let edge = rt
8055            .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
8056            .await
8057            .unwrap();
8058        let edge_uuid: Uuid = edge.id.into();
8059
8060        let result = rt
8061            .link(&tok, a.id, edge_uuid, EdgeRelation::Extends, 1.0, None)
8062            .await;
8063        match result {
8064            Err(RuntimeError::InvalidInput(msg)) => {
8065                assert!(
8066                    msg.contains("target"),
8067                    "error message must name 'target': {msg}"
8068                );
8069            }
8070            other => {
8071                panic!("expected InvalidInput for edge-uuid target with Extends, got {other:?}")
8072            }
8073        }
8074    }
8075
8076    // Test #3: non-annotates rejects a note UUID as source.
8077    #[tokio::test]
8078    async fn link_note_as_source_non_annotates_returns_invalid_input() {
8079        let rt = rt();
8080        let tok = NamespaceToken::local();
8081        let note = rt
8082            .create_note(&tok, "observation", None, "a note", Some(0.5), None, vec![])
8083            .await
8084            .unwrap();
8085        let entity = rt
8086            .create_entity(&tok, "concept", None, "E", None, None, vec![])
8087            .await
8088            .unwrap();
8089
8090        let result = rt
8091            .link(&tok, note.id, entity.id, EdgeRelation::DependsOn, 1.0, None)
8092            .await;
8093        match result {
8094            Err(RuntimeError::InvalidInput(msg)) => {
8095                assert!(
8096                    msg.contains("source"),
8097                    "error message must name 'source': {msg}"
8098                );
8099            }
8100            other => panic!("expected InvalidInput for note source with DependsOn, got {other:?}"),
8101        }
8102    }
8103
8104    // Test #4: annotates rejects entity as source (source must be a note).
8105    #[tokio::test]
8106    async fn link_entity_as_annotates_source_returns_invalid_input() {
8107        let rt = rt();
8108        let tok = NamespaceToken::local();
8109        let a = rt
8110            .create_entity(&tok, "concept", None, "A", None, None, vec![])
8111            .await
8112            .unwrap();
8113        let b = rt
8114            .create_entity(&tok, "concept", None, "B", None, None, vec![])
8115            .await
8116            .unwrap();
8117
8118        let result = rt
8119            .link(&tok, a.id, b.id, EdgeRelation::Annotates, 1.0, None)
8120            .await;
8121        match result {
8122            Err(RuntimeError::InvalidInput(msg)) => {
8123                assert!(
8124                    msg.contains("source") && msg.contains("note"),
8125                    "error must say source must be a note: {msg}"
8126                );
8127            }
8128            other => {
8129                panic!("expected InvalidInput for entity source with Annotates, got {other:?}")
8130            }
8131        }
8132    }
8133
8134    #[tokio::test]
8135    async fn link_edge_as_annotates_source_returns_invalid_input() {
8136        let rt = rt();
8137        let tok = NamespaceToken::local();
8138        let a = rt
8139            .create_entity(&tok, "concept", None, "A", None, None, vec![])
8140            .await
8141            .unwrap();
8142        let b = rt
8143            .create_entity(&tok, "concept", None, "B", None, None, vec![])
8144            .await
8145            .unwrap();
8146        let edge = rt
8147            .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
8148            .await
8149            .unwrap();
8150        let edge_uuid: Uuid = edge.id.into();
8151
8152        // An existing edge used as an annotates source: wrong kind, not absent.
8153        let result = rt
8154            .link(&tok, edge_uuid, a.id, EdgeRelation::Annotates, 1.0, None)
8155            .await;
8156        match result {
8157            Err(RuntimeError::InvalidInput(msg)) => {
8158                assert!(
8159                    msg.contains("source") && msg.contains("note"),
8160                    "edge-as-annotates-source must report wrong kind, not NotFound: {msg}"
8161                );
8162            }
8163            other => panic!("expected InvalidInput for edge source with Annotates, got {other:?}"),
8164        }
8165    }
8166
8167    // Test #5: note→event with annotates succeeds (event is a valid annotates target).
8168    #[tokio::test]
8169    async fn link_note_to_event_annotates_succeeds() {
8170        use khive_storage::Event;
8171        use khive_types::{EventKind, SubstrateKind};
8172
8173        let rt = rt();
8174        let tok = NamespaceToken::local();
8175        let note = rt
8176            .create_note(
8177                &tok,
8178                "observation",
8179                None,
8180                "observing an event",
8181                Some(0.6),
8182                None,
8183                vec![],
8184            )
8185            .await
8186            .unwrap();
8187
8188        // Build an event directly via the store (no runtime create_event exists).
8189        let ns = tok.namespace().as_str();
8190        let event = Event::new(
8191            ns,
8192            "test_verb",
8193            EventKind::Audit,
8194            SubstrateKind::Entity,
8195            "test_actor",
8196        );
8197        let event_id = event.id;
8198        rt.events(&tok).unwrap().append_event(event).await.unwrap();
8199
8200        let result = rt
8201            .link(&tok, note.id, event_id, EdgeRelation::Annotates, 1.0, None)
8202            .await;
8203        assert!(
8204            result.is_ok(),
8205            "note→event Annotates must succeed, got {result:?}"
8206        );
8207    }
8208
8209    // Test #6: create_note with event as annotates target succeeds.
8210    #[tokio::test]
8211    async fn create_note_annotates_event_succeeds() {
8212        use khive_storage::Event;
8213        use khive_types::{EventKind, SubstrateKind};
8214
8215        let rt = rt();
8216        let tok = NamespaceToken::local();
8217        let ns = tok.namespace().as_str();
8218        let event = Event::new(
8219            ns,
8220            "test_verb",
8221            EventKind::Audit,
8222            SubstrateKind::Entity,
8223            "test_actor",
8224        );
8225        let event_id = event.id;
8226        rt.events(&tok).unwrap().append_event(event).await.unwrap();
8227
8228        let result = rt
8229            .create_note(
8230                &tok,
8231                "observation",
8232                None,
8233                "note annotating an event",
8234                Some(0.5),
8235                None,
8236                vec![event_id],
8237            )
8238            .await;
8239        assert!(
8240            result.is_ok(),
8241            "create_note with event annotates target must succeed, got {result:?}"
8242        );
8243        // Verify the annotates edge was created.
8244        let note = result.unwrap();
8245        let neighbors = rt
8246            .neighbors(
8247                &tok,
8248                note.id,
8249                Direction::Out,
8250                None,
8251                Some(vec![EdgeRelation::Annotates]),
8252            )
8253            .await
8254            .unwrap();
8255        assert_eq!(neighbors.len(), 1);
8256        assert_eq!(neighbors[0].node_id, event_id);
8257    }
8258
8259    // ---- supersedes same-substrate contract ----
8260
8261    // Headline regression: note→note supersedes must succeed (was wrongly rejected before this fix).
8262    #[tokio::test]
8263    async fn link_supersedes_note_to_note_succeeds() {
8264        let rt = rt();
8265        let tok = NamespaceToken::local();
8266        let old_note = rt
8267            .create_note(
8268                &tok,
8269                "observation",
8270                None,
8271                "old observation",
8272                Some(0.7),
8273                None,
8274                vec![],
8275            )
8276            .await
8277            .unwrap();
8278        let new_note = rt
8279            .create_note(
8280                &tok,
8281                "observation",
8282                None,
8283                "revised observation superseding the old one",
8284                Some(0.9),
8285                None,
8286                vec![],
8287            )
8288            .await
8289            .unwrap();
8290
8291        let result = rt
8292            .link(
8293                &tok,
8294                new_note.id,
8295                old_note.id,
8296                EdgeRelation::Supersedes,
8297                1.0,
8298                None,
8299            )
8300            .await;
8301        assert!(
8302            result.is_ok(),
8303            "note→note Supersedes must succeed (note supersession), got {result:?}"
8304        );
8305    }
8306
8307    #[tokio::test]
8308    async fn link_supersedes_entity_to_entity_succeeds() {
8309        let rt = rt();
8310        let tok = NamespaceToken::local();
8311        let old_entity = rt
8312            .create_entity(&tok, "concept", None, "OldConcept", None, None, vec![])
8313            .await
8314            .unwrap();
8315        let new_entity = rt
8316            .create_entity(&tok, "concept", None, "NewConcept", None, None, vec![])
8317            .await
8318            .unwrap();
8319
8320        let result = rt
8321            .link(
8322                &tok,
8323                new_entity.id,
8324                old_entity.id,
8325                EdgeRelation::Supersedes,
8326                1.0,
8327                None,
8328            )
8329            .await;
8330        assert!(
8331            result.is_ok(),
8332            "entity→entity Supersedes must succeed, got {result:?}"
8333        );
8334    }
8335
8336    #[tokio::test]
8337    async fn link_supersedes_note_to_entity_returns_invalid_input() {
8338        let rt = rt();
8339        let tok = NamespaceToken::local();
8340        let note = rt
8341            .create_note(&tok, "observation", None, "a note", Some(0.5), None, vec![])
8342            .await
8343            .unwrap();
8344        let entity = rt
8345            .create_entity(&tok, "concept", None, "SomeEntity", None, None, vec![])
8346            .await
8347            .unwrap();
8348
8349        let result = rt
8350            .link(
8351                &tok,
8352                note.id,
8353                entity.id,
8354                EdgeRelation::Supersedes,
8355                1.0,
8356                None,
8357            )
8358            .await;
8359        match result {
8360            Err(RuntimeError::InvalidInput(msg)) => {
8361                assert!(
8362                    msg.contains("same substrate") || msg.contains("same-substrate"),
8363                    "error must name the same-substrate rule: {msg}"
8364                );
8365            }
8366            other => panic!(
8367                "expected InvalidInput for note→entity Supersedes (cross-substrate), got {other:?}"
8368            ),
8369        }
8370    }
8371
8372    #[tokio::test]
8373    async fn link_supersedes_entity_to_note_returns_invalid_input() {
8374        let rt = rt();
8375        let tok = NamespaceToken::local();
8376        let entity = rt
8377            .create_entity(&tok, "concept", None, "SomeEntity", None, None, vec![])
8378            .await
8379            .unwrap();
8380        let note = rt
8381            .create_note(&tok, "observation", None, "a note", Some(0.5), None, vec![])
8382            .await
8383            .unwrap();
8384
8385        let result = rt
8386            .link(
8387                &tok,
8388                entity.id,
8389                note.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 entity→note Supersedes (cross-substrate), got {other:?}"
8404            ),
8405        }
8406    }
8407
8408    #[tokio::test]
8409    async fn link_supersedes_event_source_returns_invalid_input() {
8410        use khive_storage::Event;
8411        use khive_types::{EventKind, SubstrateKind};
8412
8413        let rt = rt();
8414        let tok = NamespaceToken::local();
8415        let ns = tok.namespace().as_str();
8416        let event = Event::new(
8417            ns,
8418            "test_verb",
8419            EventKind::Audit,
8420            SubstrateKind::Entity,
8421            "test_actor",
8422        );
8423        let event_id = event.id;
8424        rt.events(&tok).unwrap().append_event(event).await.unwrap();
8425
8426        let entity = rt
8427            .create_entity(&tok, "concept", None, "SomeEntity", None, None, vec![])
8428            .await
8429            .unwrap();
8430
8431        let result = rt
8432            .link(
8433                &tok,
8434                event_id,
8435                entity.id,
8436                EdgeRelation::Supersedes,
8437                1.0,
8438                None,
8439            )
8440            .await;
8441        match result {
8442            Err(RuntimeError::InvalidInput(msg)) => {
8443                assert!(msg.contains("event"), "error must mention 'event': {msg}");
8444            }
8445            other => {
8446                panic!("expected InvalidInput for event source with Supersedes, got {other:?}")
8447            }
8448        }
8449    }
8450
8451    #[tokio::test]
8452    async fn link_supersedes_event_target_returns_invalid_input() {
8453        use khive_storage::Event;
8454        use khive_types::{EventKind, SubstrateKind};
8455
8456        let rt = rt();
8457        let tok = NamespaceToken::local();
8458        let ns = tok.namespace().as_str();
8459        let event = Event::new(
8460            ns,
8461            "test_verb",
8462            EventKind::Audit,
8463            SubstrateKind::Entity,
8464            "test_actor",
8465        );
8466        let event_id = event.id;
8467        rt.events(&tok).unwrap().append_event(event).await.unwrap();
8468
8469        let entity = rt
8470            .create_entity(&tok, "concept", None, "SomeEntity", None, None, vec![])
8471            .await
8472            .unwrap();
8473
8474        let result = rt
8475            .link(
8476                &tok,
8477                entity.id,
8478                event_id,
8479                EdgeRelation::Supersedes,
8480                1.0,
8481                None,
8482            )
8483            .await;
8484        match result {
8485            Err(RuntimeError::InvalidInput(msg)) => {
8486                assert!(msg.contains("event"), "error must mention 'event': {msg}");
8487            }
8488            other => {
8489                panic!("expected InvalidInput for event target with Supersedes, got {other:?}")
8490            }
8491        }
8492    }
8493
8494    #[tokio::test]
8495    async fn link_supersedes_edge_source_returns_invalid_input() {
8496        let rt = rt();
8497        let tok = NamespaceToken::local();
8498        let a = rt
8499            .create_entity(&tok, "concept", None, "A", None, None, vec![])
8500            .await
8501            .unwrap();
8502        let b = rt
8503            .create_entity(&tok, "concept", None, "B", None, None, vec![])
8504            .await
8505            .unwrap();
8506        let edge = rt
8507            .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
8508            .await
8509            .unwrap();
8510        let edge_uuid: Uuid = edge.id.into();
8511
8512        let result = rt
8513            .link(&tok, edge_uuid, a.id, EdgeRelation::Supersedes, 1.0, None)
8514            .await;
8515        match result {
8516            Err(RuntimeError::InvalidInput(msg)) => {
8517                assert!(msg.contains("source"), "error must name 'source': {msg}");
8518            }
8519            other => {
8520                panic!("expected InvalidInput for edge-uuid source with Supersedes, got {other:?}")
8521            }
8522        }
8523    }
8524
8525    #[tokio::test]
8526    async fn link_supersedes_edge_target_returns_invalid_input() {
8527        let rt = rt();
8528        let tok = NamespaceToken::local();
8529        let a = rt
8530            .create_entity(&tok, "concept", None, "A", None, None, vec![])
8531            .await
8532            .unwrap();
8533        let b = rt
8534            .create_entity(&tok, "concept", None, "B", None, None, vec![])
8535            .await
8536            .unwrap();
8537        let edge = rt
8538            .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
8539            .await
8540            .unwrap();
8541        let edge_uuid: Uuid = edge.id.into();
8542
8543        let result = rt
8544            .link(&tok, a.id, edge_uuid, EdgeRelation::Supersedes, 1.0, None)
8545            .await;
8546        match result {
8547            Err(RuntimeError::InvalidInput(msg)) => {
8548                assert!(msg.contains("target"), "error must name 'target': {msg}");
8549            }
8550            other => {
8551                panic!("expected InvalidInput for edge-uuid target with Supersedes, got {other:?}")
8552            }
8553        }
8554    }
8555
8556    #[tokio::test]
8557    async fn link_supersedes_phantom_source_returns_not_found() {
8558        let rt = rt();
8559        let tok = NamespaceToken::local();
8560        let note = rt
8561            .create_note(
8562                &tok,
8563                "observation",
8564                None,
8565                "existing note",
8566                Some(0.5),
8567                None,
8568                vec![],
8569            )
8570            .await
8571            .unwrap();
8572        let phantom = Uuid::new_v4();
8573
8574        let result = rt
8575            .link(&tok, phantom, note.id, EdgeRelation::Supersedes, 1.0, None)
8576            .await;
8577        match result {
8578            Err(RuntimeError::NotFound(msg)) => {
8579                assert!(msg.contains("source"), "error must name 'source': {msg}");
8580            }
8581            other => panic!("expected NotFound for phantom source with Supersedes, got {other:?}"),
8582        }
8583    }
8584
8585    #[tokio::test]
8586    async fn link_supersedes_phantom_target_returns_not_found() {
8587        let rt = rt();
8588        let tok = NamespaceToken::local();
8589        let note = rt
8590            .create_note(
8591                &tok,
8592                "observation",
8593                None,
8594                "existing note",
8595                Some(0.5),
8596                None,
8597                vec![],
8598            )
8599            .await
8600            .unwrap();
8601        let phantom = Uuid::new_v4();
8602
8603        let result = rt
8604            .link(&tok, note.id, phantom, EdgeRelation::Supersedes, 1.0, None)
8605            .await;
8606        match result {
8607            Err(RuntimeError::NotFound(msg)) => {
8608                assert!(msg.contains("target"), "error must name 'target': {msg}");
8609            }
8610            other => panic!("expected NotFound for phantom target with Supersedes, got {other:?}"),
8611        }
8612    }
8613
8614    /// The canonical `remember | supersedes` chain: a `supersedes` source note living
8615    /// in a different namespace than the caller must still resolve as a by-ID endpoint.
8616    #[tokio::test]
8617    async fn link_supersedes_cross_namespace_source_succeeds() {
8618        let rt = rt();
8619        let ns_a = NamespaceToken::for_namespace(Namespace::parse("ns-a").unwrap());
8620        let ns_b = NamespaceToken::for_namespace(Namespace::parse("ns-b").unwrap());
8621        let note_a = rt
8622            .create_note(
8623                &ns_a,
8624                "observation",
8625                None,
8626                "note in ns-a",
8627                Some(0.5),
8628                None,
8629                vec![],
8630            )
8631            .await
8632            .unwrap();
8633        let note_b = rt
8634            .create_note(
8635                &ns_b,
8636                "observation",
8637                None,
8638                "note in ns-b",
8639                Some(0.5),
8640                None,
8641                vec![],
8642            )
8643            .await
8644            .unwrap();
8645
8646        // From ns-a perspective, note_b is in a different namespace — by-ID resolution
8647        // finds it anyway.
8648        let result = rt
8649            .link(
8650                &ns_a,
8651                note_b.id,
8652                note_a.id,
8653                EdgeRelation::Supersedes,
8654                1.0,
8655                None,
8656            )
8657            .await;
8658        assert!(
8659            result.is_ok(),
8660            "cross-namespace supersedes source must resolve (#631), got {result:?}"
8661        );
8662    }
8663
8664    // Sanity: extends (non-annotates, non-supersedes) still requires entity→entity.
8665    #[tokio::test]
8666    async fn link_extends_note_source_still_returns_invalid_input() {
8667        let rt = rt();
8668        let tok = NamespaceToken::local();
8669        let note = rt
8670            .create_note(
8671                &tok,
8672                "observation",
8673                None,
8674                "a note that cannot be an extends source",
8675                Some(0.5),
8676                None,
8677                vec![],
8678            )
8679            .await
8680            .unwrap();
8681        let entity = rt
8682            .create_entity(&tok, "concept", None, "E", None, None, vec![])
8683            .await
8684            .unwrap();
8685
8686        let result = rt
8687            .link(&tok, note.id, entity.id, EdgeRelation::Extends, 1.0, None)
8688            .await;
8689        assert!(
8690            matches!(result, Err(RuntimeError::InvalidInput(_))),
8691            "note source with Extends must still return InvalidInput after this fix, got {result:?}"
8692        );
8693    }
8694
8695    // Sanity: annotates note→edge still succeeds (unchanged path not broken by this fix).
8696    #[tokio::test]
8697    async fn link_annotates_note_to_edge_still_succeeds_after_fix() {
8698        let rt = rt();
8699        let tok = NamespaceToken::local();
8700        let a = rt
8701            .create_entity(&tok, "concept", None, "A", None, None, vec![])
8702            .await
8703            .unwrap();
8704        let b = rt
8705            .create_entity(&tok, "concept", None, "B", None, None, vec![])
8706            .await
8707            .unwrap();
8708        let edge = rt
8709            .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
8710            .await
8711            .unwrap();
8712        let edge_uuid: Uuid = edge.id.into();
8713
8714        let note = rt
8715            .create_note(
8716                &tok,
8717                "observation",
8718                None,
8719                "annotating an edge",
8720                Some(0.5),
8721                None,
8722                vec![],
8723            )
8724            .await
8725            .unwrap();
8726
8727        let result = rt
8728            .link(&tok, note.id, edge_uuid, EdgeRelation::Annotates, 1.0, None)
8729            .await;
8730        assert!(
8731            result.is_ok(),
8732            "note→edge Annotates must still succeed after supersedes fix, got {result:?}"
8733        );
8734    }
8735
8736    // ---- Compensation-path rollback (fix/annotates) ----
8737
8738    // The compensation branch in `create_note_inner` (operations.rs) rolls back
8739    // a partial write — note row + first edge + FTS + vector — when a subsequent
8740    // link call fails. The failure trigger is a storage error (e.g. I/O failure)
8741    // that cannot occur in the in-memory runtime; this test instead exercises the
8742    // exact cleanup operations that the compensation branch performs, starting from
8743    // a manually-constructed partial state, and verifies the post-cleanup invariants.
8744    //
8745    // What this covers: the cleanup sequence (delete_edge, delete_note hard, FTS
8746    // index clean) is correct and leaves the DB in a pristine state. What it does
8747    // not cover: the trigger condition (second link failure). Storage-error injection
8748    // would require a mock GraphStore, which is beyond the current test infrastructure.
8749    #[tokio::test]
8750    async fn create_note_multi_annotates_compensation_cleanup_restores_pristine_state() {
8751        let rt = rt();
8752        let tok = NamespaceToken::local();
8753        let t1 = rt
8754            .create_entity(&tok, "concept", None, "T1", None, None, vec![])
8755            .await
8756            .unwrap();
8757
8758        // Construct the partial state that the compensation branch would encounter:
8759        // note persisted + first annotates edge created.
8760        let note = rt
8761            .create_note(
8762                &tok,
8763                "observation",
8764                None,
8765                "partial note",
8766                Some(0.5),
8767                None,
8768                vec![t1.id],
8769            )
8770            .await
8771            .unwrap();
8772
8773        // Confirm the partial state exists before compensation.
8774        let before_notes = rt.list_notes(&tok, None, 1000, 0).await.unwrap();
8775        assert_eq!(before_notes.len(), 1, "note must be present before cleanup");
8776        let before_edges = rt
8777            .neighbors(
8778                &tok,
8779                note.id,
8780                Direction::Out,
8781                None,
8782                Some(vec![EdgeRelation::Annotates]),
8783            )
8784            .await
8785            .unwrap();
8786        assert_eq!(
8787            before_edges.len(),
8788            1,
8789            "one annotates edge must exist before cleanup"
8790        );
8791        let edge_id: Uuid = before_edges[0].edge_id;
8792
8793        // Execute the same cleanup sequence that `create_note_inner`'s Err branch runs.
8794        rt.delete_edge(&tok, edge_id, true).await.unwrap();
8795        rt.delete_note(&tok, note.id, true /* hard */)
8796            .await
8797            .unwrap();
8798
8799        // Post-compensation invariants:
8800        let after_notes = rt.list_notes(&tok, None, 1000, 0).await.unwrap();
8801        assert!(
8802            after_notes.is_empty(),
8803            "compensation must remove the note row; got {after_notes:?}"
8804        );
8805        let search_hits = rt
8806            .search_notes(&tok, "partial note", None, 10, None, false, &[], None)
8807            .await
8808            .unwrap();
8809        assert!(
8810            search_hits.is_empty(),
8811            "compensation must clean the FTS index; got {search_hits:?}"
8812        );
8813        let after_edges = rt
8814            .neighbors(&tok, note.id, Direction::Out, None, None)
8815            .await
8816            .unwrap();
8817        assert!(
8818            after_edges.is_empty(),
8819            "compensation must remove all partial edges; got {after_edges:?}"
8820        );
8821    }
8822
8823    // ---- Hard-delete cascade for note and edge annotation targets (fix/annotates) ----
8824
8825    // annotates is note → ANYTHING (entity, note, edge, event);
8826    // targets may be entity, edge, event, or note.
8827    // Hard-deleting any of those targets must cascade incident annotates edges.
8828    // Soft deletes leave edges (data-vs-view rule).
8829
8830    #[tokio::test]
8831    async fn annotated_entity_hard_delete_cascades_annotate_edge() {
8832        let rt = rt();
8833        let tok = NamespaceToken::local();
8834        let entity = rt
8835            .create_entity(&tok, "concept", None, "E", None, None, vec![])
8836            .await
8837            .unwrap();
8838        let note = rt
8839            .create_note(
8840                &tok,
8841                "observation",
8842                None,
8843                "note about entity",
8844                Some(0.5),
8845                None,
8846                vec![entity.id],
8847            )
8848            .await
8849            .unwrap();
8850
8851        // Confirm edge exists before delete.
8852        let before = rt
8853            .neighbors(
8854                &tok,
8855                note.id,
8856                Direction::Out,
8857                None,
8858                Some(vec![EdgeRelation::Annotates]),
8859            )
8860            .await
8861            .unwrap();
8862        assert_eq!(
8863            before.len(),
8864            1,
8865            "annotates edge must exist before entity delete"
8866        );
8867
8868        // Hard delete the entity.
8869        let deleted = rt.delete_entity(&tok, entity.id, true).await.unwrap();
8870        assert!(deleted, "entity hard delete must return true");
8871
8872        // Annotates edge must be gone.
8873        let after = rt
8874            .neighbors(
8875                &tok,
8876                note.id,
8877                Direction::Out,
8878                None,
8879                Some(vec![EdgeRelation::Annotates]),
8880            )
8881            .await
8882            .unwrap();
8883        assert!(
8884            after.is_empty(),
8885            "annotates edge must be cascaded on entity hard delete; got {after:?}"
8886        );
8887    }
8888
8889    #[tokio::test]
8890    async fn annotated_note_hard_delete_cascades_annotate_edge() {
8891        let rt = rt();
8892        let tok = NamespaceToken::local();
8893        // note_target is the thing being annotated (a note itself).
8894        let note_target = rt
8895            .create_note(
8896                &tok,
8897                "observation",
8898                None,
8899                "target note",
8900                Some(0.5),
8901                None,
8902                vec![],
8903            )
8904            .await
8905            .unwrap();
8906        // note_source annotates note_target.
8907        let note_source = rt
8908            .create_note(
8909                &tok,
8910                "insight",
8911                None,
8912                "annotation",
8913                Some(0.5),
8914                None,
8915                vec![note_target.id],
8916            )
8917            .await
8918            .unwrap();
8919
8920        let before = rt
8921            .neighbors(
8922                &tok,
8923                note_source.id,
8924                Direction::Out,
8925                None,
8926                Some(vec![EdgeRelation::Annotates]),
8927            )
8928            .await
8929            .unwrap();
8930        assert_eq!(
8931            before.len(),
8932            1,
8933            "annotates edge must exist before note delete"
8934        );
8935
8936        // Hard delete the annotation TARGET note.
8937        let deleted = rt.delete_note(&tok, note_target.id, true).await.unwrap();
8938        assert!(deleted, "note hard delete must return true");
8939
8940        // The annotates edge targeting note_target must be gone.
8941        let after = rt
8942            .neighbors(
8943                &tok,
8944                note_source.id,
8945                Direction::Out,
8946                None,
8947                Some(vec![EdgeRelation::Annotates]),
8948            )
8949            .await
8950            .unwrap();
8951        assert!(
8952            after.is_empty(),
8953            "annotates edge must be cascaded on note-target hard delete; got {after:?}"
8954        );
8955    }
8956
8957    #[tokio::test]
8958    async fn annotated_edge_delete_cascades_annotate_edge() {
8959        let rt = rt();
8960        let tok = NamespaceToken::local();
8961        let a = rt
8962            .create_entity(&tok, "concept", None, "A", None, None, vec![])
8963            .await
8964            .unwrap();
8965        let b = rt
8966            .create_entity(&tok, "concept", None, "B", None, None, vec![])
8967            .await
8968            .unwrap();
8969        // Create an edge to annotate.
8970        let base_edge = rt
8971            .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
8972            .await
8973            .unwrap();
8974        let base_edge_uuid: Uuid = base_edge.id.into();
8975
8976        // Create a note that annotates the edge.
8977        let note = rt
8978            .create_note(
8979                &tok,
8980                "observation",
8981                None,
8982                "note about edge",
8983                Some(0.5),
8984                None,
8985                vec![base_edge_uuid],
8986            )
8987            .await
8988            .unwrap();
8989
8990        let before = rt
8991            .neighbors(
8992                &tok,
8993                note.id,
8994                Direction::Out,
8995                None,
8996                Some(vec![EdgeRelation::Annotates]),
8997            )
8998            .await
8999            .unwrap();
9000        assert_eq!(
9001            before.len(),
9002            1,
9003            "annotates edge must exist before base edge delete"
9004        );
9005
9006        // Delete the base edge.
9007        let deleted = rt.delete_edge(&tok, base_edge_uuid, true).await.unwrap();
9008        assert!(deleted, "edge delete must return true");
9009
9010        // The annotates edge targeting base_edge must be gone.
9011        let after = rt
9012            .neighbors(
9013                &tok,
9014                note.id,
9015                Direction::Out,
9016                None,
9017                Some(vec![EdgeRelation::Annotates]),
9018            )
9019            .await
9020            .unwrap();
9021        assert!(
9022            after.is_empty(),
9023            "annotates edge must be cascaded on base edge delete; got {after:?}"
9024        );
9025    }
9026
9027    #[tokio::test]
9028    async fn mixed_multi_annotates_partial_target_hard_delete_leaves_remaining_edges() {
9029        let rt = rt();
9030        let tok = NamespaceToken::local();
9031        let t1 = rt
9032            .create_entity(&tok, "concept", None, "T1", None, None, vec![])
9033            .await
9034            .unwrap();
9035        let t2 = rt
9036            .create_entity(&tok, "concept", None, "T2", None, None, vec![])
9037            .await
9038            .unwrap();
9039
9040        // Note annotates both t1 and t2.
9041        let note = rt
9042            .create_note(
9043                &tok,
9044                "observation",
9045                None,
9046                "multi-target note",
9047                Some(0.5),
9048                None,
9049                vec![t1.id, t2.id],
9050            )
9051            .await
9052            .unwrap();
9053
9054        let before = rt
9055            .neighbors(
9056                &tok,
9057                note.id,
9058                Direction::Out,
9059                None,
9060                Some(vec![EdgeRelation::Annotates]),
9061            )
9062            .await
9063            .unwrap();
9064        assert_eq!(
9065            before.len(),
9066            2,
9067            "must have 2 annotates edges before any delete"
9068        );
9069
9070        // Hard delete only t1.
9071        rt.delete_entity(&tok, t1.id, true).await.unwrap();
9072
9073        // Edge to t1 must be gone, edge to t2 must remain.
9074        let after = rt
9075            .neighbors(
9076                &tok,
9077                note.id,
9078                Direction::Out,
9079                None,
9080                Some(vec![EdgeRelation::Annotates]),
9081            )
9082            .await
9083            .unwrap();
9084        assert_eq!(
9085            after.len(),
9086            1,
9087            "only the edge to t1 must be cascaded; t2 edge must remain"
9088        );
9089        assert_eq!(
9090            after[0].node_id, t2.id,
9091            "remaining annotates edge must point to t2"
9092        );
9093    }
9094
9095    #[tokio::test]
9096    async fn annotated_note_soft_delete_preserves_annotate_edge() {
9097        let rt = rt();
9098        let tok = NamespaceToken::local();
9099        let note_target = rt
9100            .create_note(&tok, "observation", None, "target", Some(0.5), None, vec![])
9101            .await
9102            .unwrap();
9103        let note_source = rt
9104            .create_note(
9105                &tok,
9106                "insight",
9107                None,
9108                "annotation",
9109                Some(0.5),
9110                None,
9111                vec![note_target.id],
9112            )
9113            .await
9114            .unwrap();
9115
9116        let before = rt
9117            .neighbors(
9118                &tok,
9119                note_source.id,
9120                Direction::Out,
9121                None,
9122                Some(vec![EdgeRelation::Annotates]),
9123            )
9124            .await
9125            .unwrap();
9126        assert_eq!(before.len(), 1);
9127        let edge_id = before[0].edge_id;
9128
9129        // Soft delete must NOT cascade edges (data-vs-view principle).
9130        let deleted = rt.delete_note(&tok, note_target.id, false).await.unwrap();
9131        assert!(deleted, "soft delete must return true");
9132
9133        // The edge itself must survive the soft delete — checked at the
9134        // storage/edge layer directly (`get_edge`), not through `neighbors()`.
9135        // `neighbors()` is a VIEW query and correctly screens
9136        // out soft-deleted note targets — so it no longer surfaces this edge
9137        // once note_target is soft-deleted, even though the edge row itself
9138        // is untouched (data-vs-view principle: the edge is data, what
9139        // `neighbors()` shows is a view decision).
9140        let edge_after = rt.get_edge(&tok, edge_id).await.unwrap();
9141        assert!(
9142            edge_after.is_some(),
9143            "soft delete must NOT cascade edges; get_edge returned None"
9144        );
9145
9146        let after = rt
9147            .neighbors(
9148                &tok,
9149                note_source.id,
9150                Direction::Out,
9151                None,
9152                Some(vec![EdgeRelation::Annotates]),
9153            )
9154            .await
9155            .unwrap();
9156        assert_eq!(
9157            after.len(),
9158            0,
9159            "#748: neighbors() must screen out the soft-deleted note target; got {after:?}"
9160        );
9161    }
9162
9163    // ---- delete_edge public-API safety ----
9164
9165    // Passing an entity/note UUID to `delete_edge` must return Ok(false) with no
9166    // side effects — it must NOT delete inbound annotates edges targeting that record.
9167    // Without the get_edge guard, the old code would cascade inbound edges before
9168    // returning false.
9169    #[tokio::test]
9170    async fn delete_edge_non_edge_uuid_has_no_side_effects() {
9171        let rt = rt();
9172        let tok = NamespaceToken::local();
9173
9174        // Create an entity that has an inbound annotates edge.
9175        let entity = rt
9176            .create_entity(&tok, "concept", None, "Target", None, None, vec![])
9177            .await
9178            .unwrap();
9179        let note = rt
9180            .create_note(
9181                &tok,
9182                "observation",
9183                None,
9184                "annotates the entity",
9185                Some(0.5),
9186                None,
9187                vec![entity.id],
9188            )
9189            .await
9190            .unwrap();
9191
9192        // Confirm the annotates edge exists.
9193        let before = rt
9194            .neighbors(
9195                &tok,
9196                note.id,
9197                Direction::Out,
9198                None,
9199                Some(vec![EdgeRelation::Annotates]),
9200            )
9201            .await
9202            .unwrap();
9203        assert_eq!(before.len(), 1, "annotates edge must exist before test");
9204        let annotates_edge_id: Uuid = before[0].edge_id;
9205
9206        // Call delete_edge with the entity UUID (NOT an edge UUID).
9207        let result = rt.delete_edge(&tok, entity.id, true).await;
9208        assert!(
9209            result.is_ok(),
9210            "delete_edge must not error on a non-edge UUID"
9211        );
9212        assert!(
9213            !result.unwrap(),
9214            "delete_edge must return false for a non-edge UUID"
9215        );
9216
9217        // The inbound annotates edge to the entity must still exist — no side effects.
9218        let after = rt
9219            .neighbors(
9220                &tok,
9221                note.id,
9222                Direction::Out,
9223                None,
9224                Some(vec![EdgeRelation::Annotates]),
9225            )
9226            .await
9227            .unwrap();
9228        assert_eq!(
9229            after.len(),
9230            1,
9231            "delete_edge with a non-edge UUID must not touch inbound annotates edges"
9232        );
9233        assert_eq!(
9234            after[0].edge_id, annotates_edge_id,
9235            "the original annotates edge must be unchanged"
9236        );
9237    }
9238
9239    // ---- create_note compensation branch ----
9240
9241    // This test injects a deterministic failure on the second `link` call inside
9242    // `create_note_inner` (the one that would create the second annotates edge).
9243    // It verifies that the compensation branch is wired — i.e. this test would
9244    // fail if the `Err(e)` rollback arm at operations.rs were deleted.
9245    //
9246    // Injection mechanism: LINK_FAIL_AFTER thread-local (ops.rs, cfg(test) only).
9247    // Setting it to 2 forces the 2nd link call to return an error.  The counter is
9248    // reset to 0 once triggered, so no other test is affected.
9249    #[tokio::test]
9250    async fn create_note_multi_annotates_second_link_failure_rolls_back_partial_write() {
9251        let rt = rt();
9252        let tok = NamespaceToken::local();
9253        let t1 = rt
9254            .create_entity(&tok, "concept", None, "T1", None, None, vec![])
9255            .await
9256            .unwrap();
9257        let t2 = rt
9258            .create_entity(&tok, "concept", None, "T2", None, None, vec![])
9259            .await
9260            .unwrap();
9261
9262        // Arm the injection: fail on the 2nd link (link_idx+1 == 2).
9263        LINK_FAIL_AFTER.with(|cell| cell.set(2));
9264
9265        let result = rt
9266            .create_note(
9267                &tok,
9268                "observation",
9269                None,
9270                "rollback target",
9271                Some(0.5),
9272                None,
9273                vec![t1.id, t2.id],
9274            )
9275            .await;
9276
9277        // The call must fail with the injected error.
9278        assert!(
9279            result.is_err(),
9280            "create_note must propagate the injected link failure"
9281        );
9282        let err_msg = result.unwrap_err().to_string();
9283        assert!(
9284            err_msg.contains("injected link failure"),
9285            "error must carry injection message; got: {err_msg}"
9286        );
9287
9288        // Compensation must have removed the note row.
9289        let notes = rt.list_notes(&tok, None, 1000, 0).await.unwrap();
9290        assert!(
9291            notes.is_empty(),
9292            "compensation must remove the note row; got {notes:?}"
9293        );
9294
9295        // FTS must have no hit for the content.
9296        let hits = rt
9297            .search_notes(&tok, "rollback target", None, 10, None, false, &[], None)
9298            .await
9299            .unwrap();
9300        assert!(
9301            hits.is_empty(),
9302            "compensation must clean FTS index; got {hits:?}"
9303        );
9304
9305        // No partial annotates edges must remain (first edge must have been deleted).
9306        let edges_from_t1 = rt
9307            .neighbors(
9308                &tok,
9309                t1.id,
9310                Direction::In,
9311                None,
9312                Some(vec![EdgeRelation::Annotates]),
9313            )
9314            .await
9315            .unwrap();
9316        let edges_from_t2 = rt
9317            .neighbors(
9318                &tok,
9319                t2.id,
9320                Direction::In,
9321                None,
9322                Some(vec![EdgeRelation::Annotates]),
9323            )
9324            .await
9325            .unwrap();
9326        assert!(
9327            edges_from_t1.is_empty(),
9328            "compensation must delete the first annotates edge; got {edges_from_t1:?}"
9329        );
9330        assert!(
9331            edges_from_t2.is_empty(),
9332            "no second annotates edge must exist; got {edges_from_t2:?}"
9333        );
9334    }
9335
9336    // Inject an FTS failure after the note row is committed and assert the note
9337    // row is removed (no stranded row).  arm_fts_fail() arms the flag before
9338    // the call and it resets automatically after one trigger.
9339    #[tokio::test]
9340    async fn create_note_fts_failure_rolls_back_note_row() {
9341        let rt = rt();
9342        // Unique namespace: the process-global FTS_FAIL_NS one-shot flag armed
9343        // below must be consumable only by THIS test's create_note. Sharing the
9344        // "local" namespace let a concurrent "local" create_note consume the
9345        // armed flag, flaking this test and its victim under parallel
9346        // `cargo test`.
9347        let ns = Namespace::parse("fault-fts-rollback").unwrap();
9348        let tok = NamespaceToken::for_namespace(ns.clone());
9349
9350        arm_fts_fail(ns.as_str());
9351
9352        let result = rt
9353            .create_note(
9354                &tok,
9355                "observation",
9356                None,
9357                "fts-fail rollback target",
9358                None,
9359                None,
9360                vec![],
9361            )
9362            .await;
9363
9364        assert!(
9365            result.is_err(),
9366            "create_note must propagate the injected FTS failure"
9367        );
9368        let err_msg = result.unwrap_err().to_string();
9369        assert!(
9370            err_msg.contains("injected FTS failure"),
9371            "error must carry injection message; got: {err_msg}"
9372        );
9373
9374        // Compensation must have removed the note row.
9375        let notes = rt.list_notes(&tok, None, 1000, 0).await.unwrap();
9376        assert!(
9377            notes.is_empty(),
9378            "compensation must remove the note row after FTS failure; got {notes:?}"
9379        );
9380    }
9381
9382    // Inject a vector insertion failure after note row + FTS commit and assert
9383    // both the note row and the FTS document are removed (no stranded rows).
9384    // Uses a unique namespace (see create_note_fts_failure_rolls_back_note_row)
9385    // so the process-global VECTOR_FAIL_NS flag is consumed only by this test.
9386    // Since the single registered provider fires embed_document before the
9387    // injection check, the injection converts the successful embedding into an
9388    // error just before the VectorStore insert, then disarms.
9389    #[tokio::test]
9390    async fn create_note_vector_failure_rolls_back_note_row_and_fts() {
9391        const MODEL: &str = "test-vec-inject";
9392        const DIMS: usize = 4;
9393
9394        let rt = KhiveRuntime::memory().unwrap();
9395        let (provider, _counter) = ConstVecProvider::new(MODEL, DIMS);
9396        rt.register_embedder(provider);
9397
9398        let ns = Namespace::parse("fault-vec-rollback").unwrap();
9399        let tok = NamespaceToken::for_namespace(ns.clone());
9400
9401        arm_vector_fail(ns.as_str());
9402
9403        let result = rt
9404            .create_note(
9405                &tok,
9406                "observation",
9407                None,
9408                "vec-fail rollback target",
9409                None,
9410                None,
9411                vec![],
9412            )
9413            .await;
9414
9415        assert!(
9416            result.is_err(),
9417            "create_note must propagate the injected vector failure"
9418        );
9419        let err_msg = result.unwrap_err().to_string();
9420        assert!(
9421            err_msg.contains("injected vector failure"),
9422            "error must carry injection message; got: {err_msg}"
9423        );
9424
9425        // Compensation must have removed the note row.
9426        let notes = rt.list_notes(&tok, None, 1000, 0).await.unwrap();
9427        assert!(
9428            notes.is_empty(),
9429            "compensation must remove note row after vector failure; got {notes:?}"
9430        );
9431    }
9432
9433    // The `embedding_content` override must not bypass the same
9434    // FTS/vector compensation the plain `create_note` path already has —
9435    // both use `create_note_inner` underneath, but these tests exercise it
9436    // through `create_note_with_embedding_content` with a real Some(head)
9437    // override to prove the override path shares the identical rollback.
9438    #[tokio::test]
9439    async fn create_note_with_embedding_content_fts_failure_rolls_back_note_row() {
9440        let rt = rt();
9441        let ns = Namespace::parse("fault-fts-rollback-embedding-content").unwrap();
9442        let tok = NamespaceToken::for_namespace(ns.clone());
9443
9444        arm_fts_fail(ns.as_str());
9445
9446        let full = "fts-fail rollback target with an embedding-content override";
9447        let head = &full[.."fts-fail rollback target".len()];
9448        let result = rt
9449            .create_note_with_embedding_content(
9450                &tok,
9451                "observation",
9452                None,
9453                full,
9454                Some(head),
9455                None,
9456                None,
9457                vec![],
9458            )
9459            .await;
9460
9461        assert!(
9462            result.is_err(),
9463            "create_note_with_embedding_content must propagate the injected FTS failure"
9464        );
9465        let err_msg = result.unwrap_err().to_string();
9466        assert!(
9467            err_msg.contains("injected FTS failure"),
9468            "error must carry injection message; got: {err_msg}"
9469        );
9470
9471        // Compensation must have removed the note row; a failed create must
9472        // never leave a stranded row behind just because it carried an
9473        // embedding_content override.
9474        let notes = rt.list_notes(&tok, None, 1000, 0).await.unwrap();
9475        assert!(
9476            notes.is_empty(),
9477            "compensation must remove the note row after FTS failure; got {notes:?}"
9478        );
9479    }
9480
9481    #[tokio::test]
9482    async fn create_note_with_embedding_content_vector_failure_rolls_back_note_row_and_fts() {
9483        const MODEL: &str = "test-vec-inject-embedding-content";
9484        const DIMS: usize = 4;
9485
9486        let rt = KhiveRuntime::memory().unwrap();
9487        let (provider, _counter) = ConstVecProvider::new(MODEL, DIMS);
9488        rt.register_embedder(provider);
9489
9490        let ns = Namespace::parse("fault-vec-rollback-embedding-content").unwrap();
9491        let tok = NamespaceToken::for_namespace(ns.clone());
9492
9493        arm_vector_fail(ns.as_str());
9494
9495        let full = "vec-fail rollback target with an embedding-content override";
9496        let head = &full[.."vec-fail rollback target".len()];
9497        let result = rt
9498            .create_note_with_embedding_content(
9499                &tok,
9500                "observation",
9501                None,
9502                full,
9503                Some(head),
9504                None,
9505                None,
9506                vec![],
9507            )
9508            .await;
9509
9510        assert!(
9511            result.is_err(),
9512            "create_note_with_embedding_content must propagate the injected vector failure"
9513        );
9514        let err_msg = result.unwrap_err().to_string();
9515        assert!(
9516            err_msg.contains("injected vector failure"),
9517            "error must carry injection message; got: {err_msg}"
9518        );
9519
9520        // Compensation must have removed the note row: the ingest-layer
9521        // truncation counter only increments in the successful-create arm,
9522        // so a failed create — with or without an embedding_content override
9523        // — can never cause a spurious truncation count on the caller side.
9524        let notes = rt.list_notes(&tok, None, 1000, 0).await.unwrap();
9525        assert!(
9526            notes.is_empty(),
9527            "compensation must remove note row after vector failure; got {notes:?}"
9528        );
9529    }
9530
9531    // ---- soft-delete index cleanup tests ----
9532
9533    #[tokio::test]
9534    async fn soft_delete_entity_removes_indexes() {
9535        let rt = rt();
9536        let tok = NamespaceToken::local();
9537        let entity = rt
9538            .create_entity(
9539                &tok,
9540                "concept",
9541                None,
9542                "QuantumEntanglement",
9543                Some("unique FTS term xzqjwv for soft delete test"),
9544                None,
9545                vec![],
9546            )
9547            .await
9548            .unwrap();
9549
9550        let ns = tok.namespace().as_str().to_string();
9551
9552        let before = rt
9553            .text(&tok)
9554            .unwrap()
9555            .search(TextSearchRequest {
9556                query: "xzqjwv".to_string(),
9557                mode: TextQueryMode::Plain,
9558                filter: Some(TextFilter {
9559                    namespaces: vec![ns.clone()],
9560                    ..Default::default()
9561                }),
9562                top_k: 10,
9563                snippet_chars: 100,
9564            })
9565            .await
9566            .unwrap();
9567        assert!(
9568            before.iter().any(|h| h.subject_id == entity.id),
9569            "entity must be in FTS before soft-delete"
9570        );
9571
9572        let deleted = rt.delete_entity(&tok, entity.id, false).await.unwrap();
9573        assert!(deleted, "soft delete must return true");
9574
9575        let after = rt
9576            .text(&tok)
9577            .unwrap()
9578            .search(TextSearchRequest {
9579                query: "xzqjwv".to_string(),
9580                mode: TextQueryMode::Plain,
9581                filter: Some(TextFilter {
9582                    namespaces: vec![ns],
9583                    ..Default::default()
9584                }),
9585                top_k: 10,
9586                snippet_chars: 100,
9587            })
9588            .await
9589            .unwrap();
9590        assert!(
9591            after.iter().all(|h| h.subject_id != entity.id),
9592            "soft-deleted entity must be removed from FTS index"
9593        );
9594    }
9595
9596    #[tokio::test]
9597    async fn soft_delete_note_removes_indexes() {
9598        let rt = rt();
9599        let tok = NamespaceToken::local();
9600        let note = rt
9601            .create_note(
9602                &tok,
9603                "observation",
9604                None,
9605                "SpectralDecomposition unique term yvwkqz for soft delete test",
9606                Some(0.7),
9607                None,
9608                vec![],
9609            )
9610            .await
9611            .unwrap();
9612
9613        let before = rt
9614            .search_notes(&tok, "yvwkqz", None, 10, None, false, &[], None)
9615            .await
9616            .unwrap();
9617        assert!(
9618            before.iter().any(|h| h.note_id == note.id),
9619            "note must be in FTS before soft-delete"
9620        );
9621
9622        let deleted = rt.delete_note(&tok, note.id, false).await.unwrap();
9623        assert!(deleted, "soft delete must return true");
9624
9625        let after = rt
9626            .search_notes(&tok, "yvwkqz", None, 10, None, false, &[], None)
9627            .await
9628            .unwrap();
9629        assert!(
9630            after.iter().all(|h| h.note_id != note.id),
9631            "soft-deleted note must be removed from FTS index"
9632        );
9633    }
9634
9635    // Base endpoint allowlist: unlisted triples must fail closed.
9636    // Document->Document Extends is not in the allowlist.
9637    #[tokio::test]
9638    async fn link_extends_document_to_document_returns_invalid_input() {
9639        let rt = rt();
9640        let tok = NamespaceToken::local();
9641        let d1 = rt
9642            .create_entity(&tok, "document", None, "DocA", None, None, vec![])
9643            .await
9644            .unwrap();
9645        let d2 = rt
9646            .create_entity(&tok, "document", None, "DocB", None, None, vec![])
9647            .await
9648            .unwrap();
9649        let result = rt
9650            .link(&tok, d1.id, d2.id, EdgeRelation::Extends, 1.0, None)
9651            .await;
9652        assert!(
9653            result.is_err(),
9654            "F010: document->document Extends must be rejected by the base allowlist; \
9655             current generic entity fallthrough incorrectly accepts it"
9656        );
9657    }
9658
9659    // Happy path: Concept->Concept Extends is in the base allowlist and must succeed.
9660    #[tokio::test]
9661    async fn link_extends_concept_to_concept_succeeds() {
9662        let rt = rt();
9663        let tok = NamespaceToken::local();
9664        let a = rt
9665            .create_entity(&tok, "concept", None, "CA", None, None, vec![])
9666            .await
9667            .unwrap();
9668        let b = rt
9669            .create_entity(&tok, "concept", None, "CB", None, None, vec![])
9670            .await
9671            .unwrap();
9672        let result = rt
9673            .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
9674            .await;
9675        assert!(
9676            result.is_ok(),
9677            "F010: concept->concept Extends must be allowed (base allowlist)"
9678        );
9679    }
9680
9681    // CompetesWith is symmetric; reversed pair must deduplicate to one canonical row.
9682    #[tokio::test]
9683    async fn link_symmetric_relation_canonicalizes_endpoint_order() {
9684        use khive_storage::EdgeFilter;
9685        let rt = rt();
9686        let tok = NamespaceToken::local();
9687        let a = rt
9688            .create_entity(&tok, "concept", None, "ConceptP", None, None, vec![])
9689            .await
9690            .unwrap();
9691        let b = rt
9692            .create_entity(&tok, "concept", None, "ConceptQ", None, None, vec![])
9693            .await
9694            .unwrap();
9695        // Link A->B then B->A with the same symmetric relation.
9696        rt.link(&tok, a.id, b.id, EdgeRelation::CompetesWith, 1.0, None)
9697            .await
9698            .unwrap();
9699        rt.link(&tok, b.id, a.id, EdgeRelation::CompetesWith, 1.0, None)
9700            .await
9701            .unwrap();
9702        let count = rt
9703            .graph(&tok)
9704            .unwrap()
9705            .count_edges(EdgeFilter::default())
9706            .await
9707            .unwrap();
9708        assert_eq!(
9709            count,
9710            1,
9711            "F012: CompetesWith is symmetric; A->B and B->A must deduplicate to one canonical row; \
9712             found {count} rows (canonicalization not yet implemented)"
9713        );
9714    }
9715
9716    // Supersedes: positive tests for all 5 allowed entity kinds.
9717    #[tokio::test]
9718    async fn f010_supersedes_document_to_document_allowed() {
9719        let rt = rt();
9720        let tok = NamespaceToken::local();
9721        let a = rt
9722            .create_entity(&tok, "document", None, "DocA", None, None, vec![])
9723            .await
9724            .unwrap();
9725        let b = rt
9726            .create_entity(&tok, "document", None, "DocB", None, None, vec![])
9727            .await
9728            .unwrap();
9729        let result = rt
9730            .link(&tok, b.id, a.id, EdgeRelation::Supersedes, 1.0, None)
9731            .await;
9732        assert!(
9733            result.is_ok(),
9734            "document->document Supersedes must be allowed (allowlist), got {result:?}"
9735        );
9736    }
9737
9738    #[tokio::test]
9739    async fn f010_supersedes_artifact_to_artifact_allowed() {
9740        let rt = rt();
9741        let tok = NamespaceToken::local();
9742        let a = rt
9743            .create_entity(&tok, "artifact", None, "ArtA", None, None, vec![])
9744            .await
9745            .unwrap();
9746        let b = rt
9747            .create_entity(&tok, "artifact", None, "ArtB", None, None, vec![])
9748            .await
9749            .unwrap();
9750        let result = rt
9751            .link(&tok, b.id, a.id, EdgeRelation::Supersedes, 1.0, None)
9752            .await;
9753        assert!(
9754            result.is_ok(),
9755            "artifact->artifact Supersedes must be allowed (allowlist), got {result:?}"
9756        );
9757    }
9758
9759    #[tokio::test]
9760    async fn f010_supersedes_service_to_service_allowed() {
9761        let rt = rt();
9762        let tok = NamespaceToken::local();
9763        let a = rt
9764            .create_entity(&tok, "service", None, "SvcA", None, None, vec![])
9765            .await
9766            .unwrap();
9767        let b = rt
9768            .create_entity(&tok, "service", None, "SvcB", None, None, vec![])
9769            .await
9770            .unwrap();
9771        let result = rt
9772            .link(&tok, b.id, a.id, EdgeRelation::Supersedes, 1.0, None)
9773            .await;
9774        assert!(
9775            result.is_ok(),
9776            "service->service Supersedes must be allowed (allowlist), got {result:?}"
9777        );
9778    }
9779
9780    #[tokio::test]
9781    async fn f010_supersedes_dataset_to_dataset_allowed() {
9782        let rt = rt();
9783        let tok = NamespaceToken::local();
9784        let a = rt
9785            .create_entity(&tok, "dataset", None, "DataA", None, None, vec![])
9786            .await
9787            .unwrap();
9788        let b = rt
9789            .create_entity(&tok, "dataset", None, "DataB", None, None, vec![])
9790            .await
9791            .unwrap();
9792        let result = rt
9793            .link(&tok, b.id, a.id, EdgeRelation::Supersedes, 1.0, None)
9794            .await;
9795        assert!(
9796            result.is_ok(),
9797            "dataset->dataset Supersedes must be allowed (allowlist), got {result:?}"
9798        );
9799    }
9800
9801    // Supersedes: negative tests for rejected entity kinds.
9802    #[tokio::test]
9803    async fn f010_supersedes_project_to_project_rejected() {
9804        let rt = rt();
9805        let tok = NamespaceToken::local();
9806        let a = rt
9807            .create_entity(&tok, "project", None, "ProjA", None, None, vec![])
9808            .await
9809            .unwrap();
9810        let b = rt
9811            .create_entity(&tok, "project", None, "ProjB", None, None, vec![])
9812            .await
9813            .unwrap();
9814        let result = rt
9815            .link(&tok, b.id, a.id, EdgeRelation::Supersedes, 1.0, None)
9816            .await;
9817        assert!(
9818            matches!(result, Err(RuntimeError::InvalidInput(_))),
9819            "project->project Supersedes must be rejected (not in allowlist), got {result:?}"
9820        );
9821    }
9822
9823    #[tokio::test]
9824    async fn f010_supersedes_person_to_person_rejected() {
9825        let rt = rt();
9826        let tok = NamespaceToken::local();
9827        let a = rt
9828            .create_entity(&tok, "person", None, "Alice", None, None, vec![])
9829            .await
9830            .unwrap();
9831        let b = rt
9832            .create_entity(&tok, "person", None, "Bob", None, None, vec![])
9833            .await
9834            .unwrap();
9835        let result = rt
9836            .link(&tok, b.id, a.id, EdgeRelation::Supersedes, 1.0, None)
9837            .await;
9838        assert!(
9839            matches!(result, Err(RuntimeError::InvalidInput(_))),
9840            "person->person Supersedes must be rejected (not in allowlist), got {result:?}"
9841        );
9842    }
9843
9844    #[tokio::test]
9845    async fn f010_supersedes_org_to_org_rejected() {
9846        let rt = rt();
9847        let tok = NamespaceToken::local();
9848        let a = rt
9849            .create_entity(&tok, "org", None, "OrgA", None, None, vec![])
9850            .await
9851            .unwrap();
9852        let b = rt
9853            .create_entity(&tok, "org", None, "OrgB", None, None, vec![])
9854            .await
9855            .unwrap();
9856        let result = rt
9857            .link(&tok, b.id, a.id, EdgeRelation::Supersedes, 1.0, None)
9858            .await;
9859        assert!(
9860            matches!(result, Err(RuntimeError::InvalidInput(_))),
9861            "org->org Supersedes must be rejected (not in allowlist), got {result:?}"
9862        );
9863    }
9864
9865    // Supersedes entity→entity: same kind (concept→concept) must be allowed.
9866    #[tokio::test]
9867    async fn f010_supersedes_same_kind_entity_allowed() {
9868        let rt = rt();
9869        let tok = NamespaceToken::local();
9870        let a = rt
9871            .create_entity(&tok, "concept", None, "OldV", None, None, vec![])
9872            .await
9873            .unwrap();
9874        let b = rt
9875            .create_entity(&tok, "concept", None, "NewV", None, None, vec![])
9876            .await
9877            .unwrap();
9878        let result = rt
9879            .link(&tok, b.id, a.id, EdgeRelation::Supersedes, 1.0, None)
9880            .await;
9881        assert!(
9882            result.is_ok(),
9883            "concept->concept Supersedes must be allowed by the base allowlist, got {result:?}"
9884        );
9885    }
9886
9887    // target_backend invariant: all edges written through link() must have
9888    // target_backend = None because validate_edge_relation_endpoints already ensured the
9889    // target exists locally.
9890    #[tokio::test]
9891    async fn f161_link_always_writes_null_target_backend() {
9892        let rt = rt();
9893        let tok = NamespaceToken::local();
9894        let a = rt
9895            .create_entity(&tok, "concept", None, "A", None, None, vec![])
9896            .await
9897            .unwrap();
9898        let b = rt
9899            .create_entity(&tok, "concept", None, "B", None, None, vec![])
9900            .await
9901            .unwrap();
9902        let edge = rt
9903            .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
9904            .await
9905            .unwrap();
9906        assert!(
9907            edge.target_backend.is_none(),
9908            "F161: target_backend must be None for locally-routed edges; got {:?}",
9909            edge.target_backend
9910        );
9911    }
9912
9913    // link_many must also write null target_backend for all local edges.
9914    #[tokio::test]
9915    async fn f161_link_many_always_writes_null_target_backend() {
9916        let rt = rt();
9917        let tok = NamespaceToken::local();
9918        let a = rt
9919            .create_entity(&tok, "concept", None, "A", None, None, vec![])
9920            .await
9921            .unwrap();
9922        let b = rt
9923            .create_entity(&tok, "concept", None, "B", None, None, vec![])
9924            .await
9925            .unwrap();
9926        let c = rt
9927            .create_entity(&tok, "concept", None, "C", None, None, vec![])
9928            .await
9929            .unwrap();
9930        let specs = vec![
9931            LinkSpec {
9932                namespace: None,
9933                source_id: a.id,
9934                target_id: b.id,
9935                relation: EdgeRelation::Extends,
9936                weight: 1.0,
9937                metadata: None,
9938            },
9939            LinkSpec {
9940                namespace: None,
9941                source_id: a.id,
9942                target_id: c.id,
9943                relation: EdgeRelation::Enables,
9944                weight: 1.0,
9945                metadata: None,
9946            },
9947        ];
9948        let edges = rt.link_many(&tok, specs).await.unwrap();
9949        for edge in &edges {
9950            assert!(
9951                edge.target_backend.is_none(),
9952                "F161: target_backend must be None for locally-routed edges in link_many; got {:?}",
9953                edge.target_backend
9954            );
9955        }
9956    }
9957
9958    // Symmetric relation neighbors: competes_with queried from the non-canonical
9959    // endpoint must still return results when direction=Out is requested.
9960    #[tokio::test]
9961    async fn f012_symmetric_neighbors_visible_from_both_endpoints() {
9962        let rt = rt();
9963        let tok = NamespaceToken::local();
9964        let a = rt
9965            .create_entity(&tok, "concept", None, "A", None, None, vec![])
9966            .await
9967            .unwrap();
9968        let b = rt
9969            .create_entity(&tok, "concept", None, "B", None, None, vec![])
9970            .await
9971            .unwrap();
9972        // Link A→B competes_with; if A.id > B.id the edge is stored as B→A (canonical).
9973        rt.link(&tok, a.id, b.id, EdgeRelation::CompetesWith, 1.0, None)
9974            .await
9975            .unwrap();
9976        // Both endpoints should see the edge regardless of direction=Out.
9977        let from_a = rt
9978            .neighbors(
9979                &tok,
9980                a.id,
9981                Direction::Out,
9982                None,
9983                Some(vec![EdgeRelation::CompetesWith]),
9984            )
9985            .await
9986            .unwrap();
9987        let from_b = rt
9988            .neighbors(
9989                &tok,
9990                b.id,
9991                Direction::Out,
9992                None,
9993                Some(vec![EdgeRelation::CompetesWith]),
9994            )
9995            .await
9996            .unwrap();
9997        assert_eq!(
9998            from_a.len(),
9999            1,
10000            "node A must see competes_with neighbor from Direction::Out (F012); got {from_a:?}"
10001        );
10002        assert_eq!(
10003            from_b.len(),
10004            1,
10005            "node B must see competes_with neighbor from Direction::Out (F012); got {from_b:?}"
10006        );
10007    }
10008
10009    // Fix 1: Supersedes entity→entity — cross-kind (concept→document) must be rejected.
10010    #[tokio::test]
10011    async fn f010_supersedes_cross_kind_entity_rejected() {
10012        let rt = rt();
10013        let tok = NamespaceToken::local();
10014        let concept = rt
10015            .create_entity(&tok, "concept", None, "MyConcept", None, None, vec![])
10016            .await
10017            .unwrap();
10018        let doc = rt
10019            .create_entity(&tok, "document", None, "MyDoc", None, None, vec![])
10020            .await
10021            .unwrap();
10022        let result = rt
10023            .link(
10024                &tok,
10025                concept.id,
10026                doc.id,
10027                EdgeRelation::Supersedes,
10028                1.0,
10029                None,
10030            )
10031            .await;
10032        assert!(
10033            matches!(result, Err(RuntimeError::InvalidInput(_))),
10034            "concept->document Supersedes must be rejected by the base allowlist, got {result:?}"
10035        );
10036    }
10037
10038    // Cross-namespace delete_note now succeeds (UUID v4 is globally unique,
10039    // no namespace isolation on by-ID ops).
10040    #[tokio::test]
10041    async fn delete_note_cross_namespace_succeeds() {
10042        let rt = rt();
10043        let ns_a = NamespaceToken::for_namespace(Namespace::parse("ns-a").unwrap());
10044        let ns_b = NamespaceToken::for_namespace(Namespace::parse("ns-b").unwrap());
10045        let note = rt
10046            .create_note(
10047                &ns_a,
10048                "observation",
10049                None,
10050                "note in ns-a",
10051                Some(0.8),
10052                None,
10053                vec![],
10054            )
10055            .await
10056            .unwrap();
10057
10058        // Delete from a different namespace must now SUCCEED.
10059        let result = rt.delete_note(&ns_b, note.id, false).await;
10060        assert!(
10061            result.unwrap(),
10062            "cross-namespace delete_note (soft) must return Ok(true)"
10063        );
10064
10065        // Note must be gone from ns-a storage after the cross-ns soft delete.
10066        let note_store = rt.notes(&ns_a).unwrap();
10067        let gone = note_store.get_note(note.id).await.unwrap();
10068        assert!(
10069            gone.is_none(),
10070            "note must be soft-deleted in its home namespace after cross-ns delete"
10071        );
10072
10073        // Hard-delete path: create a fresh note and hard-delete from foreign token.
10074        let note2 = rt
10075            .create_note(
10076                &ns_a,
10077                "observation",
10078                None,
10079                "note2 in ns-a",
10080                Some(0.5),
10081                None,
10082                vec![],
10083            )
10084            .await
10085            .unwrap();
10086        let hard_result = rt.delete_note(&ns_b, note2.id, true).await;
10087        assert!(
10088            hard_result.unwrap(),
10089            "cross-namespace hard delete_note must return Ok(true)"
10090        );
10091        let gone2 = rt
10092            .get_note_including_deleted(&ns_a, note2.id)
10093            .await
10094            .unwrap();
10095        assert!(
10096            gone2.is_none(),
10097            "hard-deleted note must not appear even in including_deleted query"
10098        );
10099    }
10100
10101    // Regression: parallel link_many calls with overlapping triples must
10102    // return the identical persisted edge ID, not locally-generated phantom IDs.
10103    //
10104    // Sequence:
10105    //   1. First link_many creates the A→B Extends edge (persisted with ID₁).
10106    //   2. Second link_many upserts the same triple (ON CONFLICT DO UPDATE keeps ID₁).
10107    //   3. Both callers must see ID₁ in their returned Vec<Edge>.
10108    #[tokio::test]
10109    async fn link_many_overlapping_triple_returns_persisted_ids() {
10110        let rt = rt();
10111        let tok = NamespaceToken::local();
10112        let a = rt
10113            .create_entity(&tok, "concept", None, "A", None, None, vec![])
10114            .await
10115            .unwrap();
10116        let b = rt
10117            .create_entity(&tok, "concept", None, "B", None, None, vec![])
10118            .await
10119            .unwrap();
10120
10121        let spec = || LinkSpec {
10122            namespace: None,
10123            source_id: a.id,
10124            target_id: b.id,
10125            relation: EdgeRelation::Extends,
10126            weight: 1.0,
10127            metadata: None,
10128        };
10129
10130        // First call — creates the edge.
10131        let first = rt.link_many(&tok, vec![spec()]).await.unwrap();
10132        assert_eq!(first.len(), 1);
10133        let persisted_id: Uuid = first[0].id.into();
10134
10135        // Second call — same natural-key triple; ON CONFLICT updates, preserving the
10136        // existing row ID. link_many must read back the row and return that same ID.
10137        let second = rt.link_many(&tok, vec![spec()]).await.unwrap();
10138        assert_eq!(second.len(), 1);
10139        let second_id: Uuid = second[0].id.into();
10140
10141        assert_eq!(
10142            persisted_id, second_id,
10143            "link_many with an existing triple must return the persisted row ID ({persisted_id}), \
10144             not a new phantom ID ({second_id})"
10145        );
10146
10147        // Confirm only one edge row exists in the graph store.
10148        let count = rt
10149            .count_edges(&tok, crate::curation::EdgeListFilter::default())
10150            .await
10151            .unwrap();
10152        assert_eq!(count, 1, "upsert must not duplicate the edge row");
10153    }
10154
10155    // ── create_many: batch entity creation ───────────────────────────────────
10156
10157    #[tokio::test]
10158    async fn create_many_persists_all_entities() {
10159        let rt = rt();
10160        let tok = NamespaceToken::local();
10161
10162        let specs: Vec<EntityCreateSpec> = (0..5)
10163            .map(|i| EntityCreateSpec {
10164                kind: "concept".into(),
10165                entity_type: None,
10166                name: format!("BulkConcept-{i}"),
10167                description: Some(format!("desc {i}")),
10168                properties: None,
10169                tags: vec!["bulk-test".into()],
10170            })
10171            .collect();
10172
10173        let entities = rt.create_many(&tok, specs).await.unwrap();
10174        assert_eq!(entities.len(), 5, "all 5 entities must be returned");
10175
10176        // Verify each one is retrievable from storage.
10177        for entity in &entities {
10178            let fetched = rt.get_entity(&tok, entity.id).await.unwrap();
10179            assert_eq!(fetched.id, entity.id);
10180        }
10181    }
10182
10183    #[tokio::test]
10184    async fn create_many_empty_name_rejects_atomically() {
10185        let rt = rt();
10186        let tok = NamespaceToken::local();
10187
10188        let specs = vec![
10189            EntityCreateSpec {
10190                kind: "concept".into(),
10191                entity_type: None,
10192                name: "ValidEntity".into(),
10193                description: None,
10194                properties: None,
10195                tags: vec![],
10196            },
10197            EntityCreateSpec {
10198                kind: "concept".into(),
10199                entity_type: None,
10200                name: "".into(), // invalid — triggers atomic rejection
10201                description: None,
10202                properties: None,
10203                tags: vec![],
10204            },
10205        ];
10206
10207        let result = rt.create_many(&tok, specs).await;
10208        assert!(
10209            matches!(result, Err(RuntimeError::InvalidInput(_))),
10210            "empty name must produce InvalidInput error"
10211        );
10212
10213        // Nothing must have been written — list_entities returns 0 items.
10214        let rows = rt.list_entities(&tok, None, None, 100, 0).await.unwrap();
10215        assert_eq!(
10216            rows.len(),
10217            0,
10218            "atomic rejection must leave storage unchanged"
10219        );
10220    }
10221
10222    // entity_type validated at runtime layer when validator is installed.
10223    #[tokio::test]
10224    async fn create_many_rejects_unknown_entity_type_when_validator_installed() {
10225        let rt = rt();
10226        let tok = NamespaceToken::local();
10227
10228        // Install a mock validator that only accepts "algorithm" for "concept".
10229        rt.install_entity_type_validator(Arc::new(|kind, entity_type| {
10230            let Some(raw) = entity_type else {
10231                return Ok(None);
10232            };
10233            if kind == "concept" && raw == "algorithm" {
10234                return Ok(Some("algorithm".to_string()));
10235            }
10236            Err(RuntimeError::InvalidInput(format!(
10237                "unknown entity_type {raw:?} for {kind:?}; valid: algorithm"
10238            )))
10239        }));
10240
10241        let bad_spec = vec![EntityCreateSpec {
10242            kind: "concept".into(),
10243            entity_type: Some("not_a_registered_type".into()),
10244            name: "ShouldNotLand".into(),
10245            description: None,
10246            properties: None,
10247            tags: vec![],
10248        }];
10249
10250        let result = rt.create_many(&tok, bad_spec).await;
10251        assert!(
10252            matches!(result, Err(RuntimeError::InvalidInput(_))),
10253            "unknown entity_type must be rejected by the runtime-layer validator; got {result:?}"
10254        );
10255
10256        // Zero rows written — validator fires before any storage call.
10257        let rows = rt.list_entities(&tok, None, None, 100, 0).await.unwrap();
10258        assert_eq!(
10259            rows.len(),
10260            0,
10261            "validator rejection must leave storage empty"
10262        );
10263    }
10264
10265    // Valid entity_type passes through and is normalised by the validator.
10266    #[tokio::test]
10267    async fn create_many_accepts_valid_entity_type_via_validator() {
10268        let rt = rt();
10269        let tok = NamespaceToken::local();
10270
10271        rt.install_entity_type_validator(Arc::new(|kind, entity_type| {
10272            let Some(raw) = entity_type else {
10273                return Ok(None);
10274            };
10275            if kind == "concept" && raw == "algorithm" {
10276                return Ok(Some("algorithm".to_string()));
10277            }
10278            Err(RuntimeError::InvalidInput(format!(
10279                "unknown entity_type {raw:?} for {kind:?}"
10280            )))
10281        }));
10282
10283        let specs = vec![EntityCreateSpec {
10284            kind: "concept".into(),
10285            entity_type: Some("algorithm".into()),
10286            name: "BubbleSort".into(),
10287            description: None,
10288            properties: None,
10289            tags: vec![],
10290        }];
10291
10292        let entities = rt.create_many(&tok, specs).await.unwrap();
10293        assert_eq!(entities.len(), 1, "valid entity_type must be accepted");
10294        assert_eq!(
10295            entities[0].entity_type.as_deref(),
10296            Some("algorithm"),
10297            "entity_type must be stored as returned by the validator"
10298        );
10299    }
10300
10301    // FTS failure in create_many rolls back both substrates.
10302    //
10303    // Arm `arm_fts_fail_many` before the call; the FTS phase returns an injected
10304    // error; the test asserts zero rows in both `entities` and `fts_entities`.
10305    #[tokio::test]
10306    async fn create_many_fts_failure_rolls_back_both_substrates() {
10307        // Use a unique namespace so the process-global one-shot is unaffected by
10308        // other concurrent tests.
10309        let ns = format!("fts-fail-many-{}", uuid::Uuid::new_v4().as_simple());
10310        let rt = rt();
10311        let tok = NamespaceToken::for_namespace(Namespace::parse(&ns).unwrap());
10312
10313        let specs = vec![
10314            EntityCreateSpec {
10315                kind: "concept".into(),
10316                entity_type: None,
10317                name: "FtsRollbackA".into(),
10318                description: None,
10319                properties: None,
10320                tags: vec![],
10321            },
10322            EntityCreateSpec {
10323                kind: "concept".into(),
10324                entity_type: None,
10325                name: "FtsRollbackB".into(),
10326                description: None,
10327                properties: None,
10328                tags: vec![],
10329            },
10330        ];
10331
10332        arm_fts_fail_many(&ns);
10333        let result = rt.create_many(&tok, specs).await;
10334
10335        assert!(
10336            result.is_err(),
10337            "create_many must return Err when FTS write fails"
10338        );
10339
10340        // Entity substrate must be empty — entity rows must have been rolled back.
10341        let entity_rows = rt.list_entities(&tok, None, None, 100, 0).await.unwrap();
10342        assert_eq!(
10343            entity_rows.len(),
10344            0,
10345            "entity rows must be rolled back on FTS failure; found {entity_rows:?}"
10346        );
10347
10348        // FTS substrate must be empty — no stale fts_entities rows.
10349        let fts = rt.text(&tok).unwrap();
10350        let fts_count = fts
10351            .count(TextFilter {
10352                ids: vec![],
10353                kinds: vec![],
10354                namespaces: vec![ns.clone()],
10355            })
10356            .await
10357            .unwrap();
10358        assert_eq!(
10359            fts_count, 0,
10360            "fts_entities must be empty after FTS-failure rollback; found {fts_count}"
10361        );
10362    }
10363
10364    // FTS partial-failure (Ok(summary) with summary.failed > 0) rolls back
10365    // both substrates.
10366    //
10367    // The production code has a distinct arm:
10368    //   Ok(summary) if summary.failed > 0 => return Err(...)
10369    // This test exercises that arm by arming `arm_fts_fail_many_partial`, which
10370    // returns Ok(BatchWriteSummary { failed: 1, ... }) instead of a hard Err.
10371    // Both entity rows and FTS rows must be empty after rollback.
10372    #[tokio::test]
10373    async fn create_many_fts_partial_failure_rolls_back_both_substrates() {
10374        let ns = format!("fts-fail-partial-{}", uuid::Uuid::new_v4().as_simple());
10375        let rt = rt();
10376        let tok = NamespaceToken::for_namespace(Namespace::parse(&ns).unwrap());
10377
10378        let specs = vec![
10379            EntityCreateSpec {
10380                kind: "concept".into(),
10381                entity_type: None,
10382                name: "PartialRollbackA".into(),
10383                description: None,
10384                properties: None,
10385                tags: vec![],
10386            },
10387            EntityCreateSpec {
10388                kind: "concept".into(),
10389                entity_type: None,
10390                name: "PartialRollbackB".into(),
10391                description: None,
10392                properties: None,
10393                tags: vec![],
10394            },
10395        ];
10396
10397        arm_fts_fail_many_partial(&ns);
10398        let result = rt.create_many(&tok, specs).await;
10399
10400        assert!(
10401            result.is_err(),
10402            "create_many must return Err when FTS summary.failed > 0"
10403        );
10404
10405        // Entity substrate must be empty — entity rows must have been rolled back.
10406        let entity_rows = rt.list_entities(&tok, None, None, 100, 0).await.unwrap();
10407        assert_eq!(
10408            entity_rows.len(),
10409            0,
10410            "entity rows must be rolled back when FTS summary.failed > 0; found {entity_rows:?}"
10411        );
10412
10413        // FTS substrate must be empty — no stale fts_entities rows.
10414        let fts = rt.text(&tok).unwrap();
10415        let fts_count = fts
10416            .count(TextFilter {
10417                ids: vec![],
10418                kinds: vec![],
10419                namespaces: vec![ns.clone()],
10420            })
10421            .await
10422            .unwrap();
10423        assert_eq!(
10424            fts_count, 0,
10425            "fts_entities must be empty after partial-FTS-failure rollback; found {fts_count}"
10426        );
10427    }
10428
10429    // ── Cross-namespace get_edge now succeeds (UUID v4 is globally unique) ──
10430
10431    #[tokio::test]
10432    async fn get_edge_cross_namespace_succeeds() {
10433        let rt = rt();
10434        let ns_a = NamespaceToken::for_namespace(Namespace::parse("ns-a").unwrap());
10435        let ns_b = NamespaceToken::for_namespace(Namespace::parse("ns-b").unwrap());
10436
10437        let src = rt
10438            .create_entity(&ns_a, "concept", None, "Src", None, None, vec![])
10439            .await
10440            .unwrap();
10441        let tgt = rt
10442            .create_entity(&ns_a, "concept", None, "Tgt", None, None, vec![])
10443            .await
10444            .unwrap();
10445        let edge = rt
10446            .link(&ns_a, src.id, tgt.id, EdgeRelation::Extends, 1.0, None)
10447            .await
10448            .unwrap();
10449
10450        // Visible from own namespace.
10451        let own_ns = rt.get_edge(&ns_a, Uuid::from(edge.id)).await;
10452        assert!(
10453            own_ns.is_ok() && own_ns.unwrap().is_some(),
10454            "edge must be visible in its own namespace"
10455        );
10456
10457        // Foreign namespace must now SUCCEED: by-ID get is namespace-agnostic.
10458        let cross_ns = rt.get_edge(&ns_b, Uuid::from(edge.id)).await;
10459        assert!(
10460            matches!(cross_ns, Ok(Some(_))),
10461            "cross-namespace get_edge must return Ok(Some(_)) after PR-A1, got {cross_ns:?}"
10462        );
10463
10464        // Absent edge UUID still returns None regardless of token namespace.
10465        let absent = rt.get_edge(&ns_b, Uuid::new_v4()).await;
10466        assert!(
10467            matches!(absent, Ok(None)),
10468            "absent edge must return Ok(None), got {absent:?}"
10469        );
10470    }
10471
10472    // ── Traversal across namespace labels now succeeds ────────────────────────
10473    //
10474    // Previously, traverse with ns_b token + ns_a root was silently empty
10475    // because substrate_exists_in_ns → get_entity rejected cross-namespace lookups.
10476    // Now: get_entity finds any entity by UUID; traverse finds the root and
10477    // returns paths scoped to the graph store's namespace filter for ns_b.
10478    #[tokio::test]
10479    async fn traverse_cross_namespace_root_is_accepted() {
10480        use khive_storage::types::TraversalOptions;
10481
10482        let rt = rt();
10483        let ns_a = NamespaceToken::for_namespace(Namespace::parse("ns-a").unwrap());
10484        let ns_b = NamespaceToken::for_namespace(Namespace::parse("ns-b").unwrap());
10485
10486        let a = rt
10487            .create_entity(&ns_a, "concept", None, "A", None, None, vec![])
10488            .await
10489            .unwrap();
10490        rt.create_entity(&ns_a, "concept", None, "B", None, None, vec![])
10491            .await
10492            .unwrap();
10493        rt.link(&ns_a, a.id, a.id, EdgeRelation::Extends, 1.0, None)
10494            .await
10495            .ok(); // may conflict with self-loop check; we just need an entity
10496
10497        // substrate_exists_in_ns finds the ns_a root via get_entity
10498        // (UUID-global lookup). The traverse proceeds; no panic.
10499        let result = rt
10500            .traverse(
10501                &ns_b,
10502                TraversalRequest {
10503                    roots: vec![a.id],
10504                    options: TraversalOptions {
10505                        max_depth: 1,
10506                        direction: Direction::Out,
10507                        ..Default::default()
10508                    },
10509                    include_roots: true,
10510                    include_properties: false,
10511                },
10512            )
10513            .await;
10514        assert!(result.is_ok(), "traverse must not error; got {:?}", result);
10515    }
10516
10517    // ---- purge cascade must include already-soft-deleted edges ----
10518    //
10519    // Hard delete must cascade ALL incident edges synchronously. A cascade driven
10520    // through `neighbors()`, which filters `deleted_at IS NULL`, would let incident
10521    // edges that were already soft-deleted survive endpoint purge as dangling rows.
10522    // `purge_incident_edges` issues a single DELETE without a `deleted_at` guard.
10523
10524    /// Count ALL `graph_edges` rows for a given UUID (source OR target), including soft-deleted.
10525    async fn count_all_incident_edges(rt: &KhiveRuntime, node_id: Uuid, ns: &str) -> u64 {
10526        let mut reader = rt.sql().reader().await.expect("sql reader must open");
10527        let row = reader
10528            .query_scalar(SqlStatement {
10529                sql: "SELECT COUNT(*) FROM graph_edges \
10530                      WHERE namespace = ?1 AND (source_id = ?2 OR target_id = ?2)"
10531                    .into(),
10532                params: vec![
10533                    SqlValue::Text(ns.to_string()),
10534                    SqlValue::Text(node_id.to_string()),
10535                ],
10536                label: Some("count_all_incident_edges".into()),
10537            })
10538            .await
10539            .expect("count query must succeed");
10540        match row {
10541            Some(SqlValue::Integer(n)) => n as u64,
10542            _ => panic!("count must return an integer"),
10543        }
10544    }
10545
10546    #[tokio::test]
10547    async fn hard_delete_entity_purges_already_soft_deleted_incident_edge() {
10548        let rt = rt();
10549        let tok = NamespaceToken::local();
10550        let ns = tok.namespace().to_string();
10551
10552        let a = rt
10553            .create_entity(&tok, "concept", None, "SrcA", None, None, vec![])
10554            .await
10555            .unwrap();
10556        let b = rt
10557            .create_entity(&tok, "concept", None, "TgtB", None, None, vec![])
10558            .await
10559            .unwrap();
10560
10561        rt.link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
10562            .await
10563            .unwrap();
10564
10565        // Soft-delete the edge — it is now invisible to `neighbors` but still in storage.
10566        let edge_hit = rt
10567            .neighbors(&tok, a.id, Direction::Out, None, None)
10568            .await
10569            .unwrap();
10570        assert_eq!(edge_hit.len(), 1, "edge must exist before soft-delete");
10571        let edge_uuid = edge_hit[0].edge_id;
10572        rt.delete_edge(&tok, edge_uuid, false).await.unwrap();
10573
10574        // Confirm the edge is invisible to normal read paths but present in raw storage.
10575        let visible = rt
10576            .neighbors(&tok, a.id, Direction::Out, None, None)
10577            .await
10578            .unwrap();
10579        assert!(visible.is_empty(), "soft-deleted edge must be invisible");
10580        let raw_before = count_all_incident_edges(&rt, a.id, &ns).await;
10581        assert_eq!(
10582            raw_before, 1,
10583            "soft-deleted edge must still be a physical row"
10584        );
10585
10586        // Hard-delete (purge) the source entity — cascade must also remove the soft-deleted edge.
10587        rt.delete_entity(&tok, a.id, true).await.unwrap();
10588
10589        let raw_after = count_all_incident_edges(&rt, a.id, &ns).await;
10590        assert_eq!(
10591            raw_after, 0,
10592            "purge_incident_edges must physically remove soft-deleted edge rows (ADR-002)"
10593        );
10594    }
10595
10596    #[tokio::test]
10597    async fn hard_delete_note_purges_already_soft_deleted_incident_edge() {
10598        let rt = rt();
10599        let tok = NamespaceToken::local();
10600        let ns = tok.namespace().to_string();
10601
10602        let target = rt
10603            .create_note(
10604                &tok,
10605                "observation",
10606                None,
10607                "purge-cascade target note",
10608                Some(0.5),
10609                None,
10610                vec![],
10611            )
10612            .await
10613            .unwrap();
10614        let annotating = rt
10615            .create_note(
10616                &tok,
10617                "insight",
10618                None,
10619                "annotator note",
10620                Some(0.5),
10621                None,
10622                vec![target.id],
10623            )
10624            .await
10625            .unwrap();
10626
10627        // Soft-delete the annotates edge.
10628        let edge_hit = rt
10629            .neighbors(
10630                &tok,
10631                annotating.id,
10632                Direction::Out,
10633                None,
10634                Some(vec![EdgeRelation::Annotates]),
10635            )
10636            .await
10637            .unwrap();
10638        assert_eq!(edge_hit.len(), 1, "annotates edge must exist");
10639        let edge_uuid = edge_hit[0].edge_id;
10640        rt.delete_edge(&tok, edge_uuid, false).await.unwrap();
10641
10642        let raw_before = count_all_incident_edges(&rt, target.id, &ns).await;
10643        assert_eq!(
10644            raw_before, 1,
10645            "soft-deleted edge must still be a physical row before note purge"
10646        );
10647
10648        // Hard-delete the target note — cascade must remove the soft-deleted edge row.
10649        rt.delete_note(&tok, target.id, true).await.unwrap();
10650
10651        let raw_after = count_all_incident_edges(&rt, target.id, &ns).await;
10652        assert_eq!(
10653            raw_after, 0,
10654            "purge_incident_edges must physically remove soft-deleted edge rows on note purge (ADR-002)"
10655        );
10656    }
10657
10658    // ---- cross-namespace entity hard-delete purges ALL incident edges ----
10659    //
10660    // `purge_incident_edges` must not scope its DELETE by `WHERE namespace = caller_ns`,
10661    // or a foreign-namespace entity's incident edges in ITS namespace would survive
10662    // the cascade as dangling rows.
10663
10664    /// Count ALL `graph_edges` rows for a given node UUID, across every namespace.
10665    async fn count_all_incident_edges_global(rt: &KhiveRuntime, node_id: Uuid) -> u64 {
10666        let mut reader = rt.sql().reader().await.expect("sql reader must open");
10667        let row = reader
10668            .query_scalar(SqlStatement {
10669                sql: "SELECT COUNT(*) FROM graph_edges WHERE source_id = ?1 OR target_id = ?1"
10670                    .into(),
10671                params: vec![SqlValue::Text(node_id.to_string())],
10672                label: Some("count_all_incident_edges_global".into()),
10673            })
10674            .await
10675            .expect("count query must succeed");
10676        match row {
10677            Some(SqlValue::Integer(n)) => n as u64,
10678            _ => panic!("count must return an integer"),
10679        }
10680    }
10681
10682    #[tokio::test]
10683    async fn cross_namespace_hard_delete_entity_purges_all_incident_edges() {
10684        // Entity lives in ns-owner. Edges live in ns-owner.
10685        // Delete is driven from ns-caller (a different namespace).
10686        // Assertion: after hard delete, no incident edges remain in ANY namespace.
10687        let rt = rt();
10688        let ns_owner = NamespaceToken::for_namespace(Namespace::parse("ns-owner").unwrap());
10689        let ns_caller = NamespaceToken::for_namespace(Namespace::parse("ns-caller").unwrap());
10690
10691        let entity = rt
10692            .create_entity(
10693                &ns_owner,
10694                "concept",
10695                None,
10696                "ForeignEntity",
10697                None,
10698                None,
10699                vec![],
10700            )
10701            .await
10702            .unwrap();
10703        let peer = rt
10704            .create_entity(&ns_owner, "concept", None, "Peer", None, None, vec![])
10705            .await
10706            .unwrap();
10707        // Create two incident edges in ns_owner. concept->Extends->concept is in the allowlist.
10708        rt.link(
10709            &ns_owner,
10710            entity.id,
10711            peer.id,
10712            EdgeRelation::Extends,
10713            1.0,
10714            None,
10715        )
10716        .await
10717        .unwrap();
10718        rt.link(
10719            &ns_owner,
10720            peer.id,
10721            entity.id,
10722            EdgeRelation::Extends,
10723            1.0,
10724            None,
10725        )
10726        .await
10727        .unwrap();
10728
10729        let before = count_all_incident_edges_global(&rt, entity.id).await;
10730        assert_eq!(before, 2, "two incident edges must exist before delete");
10731
10732        // Hard-delete entity from a DIFFERENT namespace token.
10733        let deleted = rt.delete_entity(&ns_caller, entity.id, true).await.unwrap();
10734        assert!(deleted, "cross-ns hard delete must return true");
10735
10736        // All incident edges must be gone regardless of namespace.
10737        let after = count_all_incident_edges_global(&rt, entity.id).await;
10738        assert_eq!(
10739            after, 0,
10740            "purge_incident_edges must remove all incident edges across namespaces (ADR-002, ADR-007)"
10741        );
10742    }
10743
10744    // ---- edge-ID hard-delete path ----
10745    //
10746    // Bug class: delete_edge drove the primary-edge guard through get_edge()
10747    // (live-only) and the cascade through neighbors() (live-only). Two reachable holes:
10748    // (a) soft-deleted primary edge cannot be hard-purged via its own ID;
10749    // (b) an already-soft-deleted annotates edge targeting a base edge survives that
10750    //     edge's hard delete as a dangling row with target_id = physically-gone edge id.
10751
10752    /// Count graph_edges rows matching the given edge ID, including soft-deleted rows.
10753    async fn count_edge_rows_by_id(rt: &KhiveRuntime, edge_id: Uuid, ns: &str) -> u64 {
10754        let mut reader = rt.sql().reader().await.expect("sql reader must open");
10755        let row = reader
10756            .query_scalar(SqlStatement {
10757                sql: "SELECT COUNT(*) FROM graph_edges WHERE namespace = ?1 AND id = ?2".into(),
10758                params: vec![
10759                    SqlValue::Text(ns.to_string()),
10760                    SqlValue::Text(edge_id.to_string()),
10761                ],
10762                label: Some("count_edge_rows_by_id".into()),
10763            })
10764            .await
10765            .expect("count query must succeed");
10766        match row {
10767            Some(SqlValue::Integer(n)) => n as u64,
10768            _ => panic!("count must return an integer"),
10769        }
10770    }
10771
10772    #[tokio::test]
10773    async fn hard_delete_edge_purges_already_soft_deleted_primary_edge() {
10774        let rt = rt();
10775        let tok = NamespaceToken::local();
10776        let ns = tok.namespace().to_string();
10777
10778        let a = rt
10779            .create_entity(&tok, "concept", None, "EA", None, None, vec![])
10780            .await
10781            .unwrap();
10782        let b = rt
10783            .create_entity(&tok, "concept", None, "EB", None, None, vec![])
10784            .await
10785            .unwrap();
10786
10787        let edge = rt
10788            .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
10789            .await
10790            .unwrap();
10791        let edge_uuid: Uuid = edge.id.into();
10792
10793        // Soft-delete the edge first.
10794        let soft = rt.delete_edge(&tok, edge_uuid, false).await.unwrap();
10795        assert!(soft, "soft delete must succeed");
10796
10797        // Edge is now invisible to normal reads but still a physical row.
10798        assert!(
10799            rt.get_edge(&tok, edge_uuid).await.unwrap().is_none(),
10800            "soft-deleted edge must be invisible to get_edge"
10801        );
10802        assert_eq!(
10803            count_edge_rows_by_id(&rt, edge_uuid, &ns).await,
10804            1,
10805            "soft-deleted edge must still be a physical row"
10806        );
10807
10808        // Hard-delete (purge) via the edge ID — must succeed and remove the row.
10809        let purged = rt.delete_edge(&tok, edge_uuid, true).await.unwrap();
10810        assert!(
10811            purged,
10812            "hard delete of a soft-deleted edge must return true"
10813        );
10814
10815        assert_eq!(
10816            count_edge_rows_by_id(&rt, edge_uuid, &ns).await,
10817            0,
10818            "hard-delete must physically remove the soft-deleted edge row (ADR-002)"
10819        );
10820    }
10821
10822    #[tokio::test]
10823    async fn hard_delete_base_edge_purges_already_soft_deleted_annotates_edge() {
10824        let rt = rt();
10825        let tok = NamespaceToken::local();
10826        let ns = tok.namespace().to_string();
10827
10828        let a = rt
10829            .create_entity(&tok, "concept", None, "CA", None, None, vec![])
10830            .await
10831            .unwrap();
10832        let b = rt
10833            .create_entity(&tok, "concept", None, "CB", None, None, vec![])
10834            .await
10835            .unwrap();
10836
10837        // Create the base edge to be annotated.
10838        let base_edge = rt
10839            .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
10840            .await
10841            .unwrap();
10842        let base_edge_uuid: Uuid = base_edge.id.into();
10843
10844        // Create a note that annotates the base edge.
10845        let note = rt
10846            .create_note(
10847                &tok,
10848                "observation",
10849                None,
10850                "note about base edge",
10851                Some(0.5),
10852                None,
10853                vec![base_edge_uuid],
10854            )
10855            .await
10856            .unwrap();
10857
10858        // Find the annotates edge.
10859        let ann_hits = rt
10860            .neighbors(
10861                &tok,
10862                note.id,
10863                Direction::Out,
10864                None,
10865                Some(vec![EdgeRelation::Annotates]),
10866            )
10867            .await
10868            .unwrap();
10869        assert_eq!(ann_hits.len(), 1, "annotates edge must exist");
10870        let ann_edge_uuid = ann_hits[0].edge_id;
10871
10872        // Soft-delete the annotates edge — now invisible but still a physical row.
10873        rt.delete_edge(&tok, ann_edge_uuid, false).await.unwrap();
10874        assert_eq!(
10875            count_edge_rows_by_id(&rt, ann_edge_uuid, &ns).await,
10876            1,
10877            "soft-deleted annotates edge must still be a physical row"
10878        );
10879
10880        // Hard-delete the base edge — cascade must also remove the soft-deleted annotates row.
10881        let purged = rt.delete_edge(&tok, base_edge_uuid, true).await.unwrap();
10882        assert!(purged, "hard delete of base edge must return true");
10883
10884        assert_eq!(
10885            count_edge_rows_by_id(&rt, ann_edge_uuid, &ns).await,
10886            0,
10887            "hard-delete of base edge must purge already-soft-deleted annotates edge row (ADR-002)"
10888        );
10889        assert_eq!(
10890            count_edge_rows_by_id(&rt, base_edge_uuid, &ns).await,
10891            0,
10892            "hard-delete must physically remove the base edge row"
10893        );
10894    }
10895
10896    // ---- entity create/update multi-model embed fan-out tests ----
10897
10898    // FTS failure after entity row commit rolls back the entity row.
10899    // Mirrors create_note_fts_failure_rolls_back_note_row but for entities.
10900    // Uses a unique namespace so the process-global FTS_FAIL_NS one-shot is
10901    // consumed only by this test's create_entity call.
10902    #[tokio::test]
10903    async fn create_entity_fts_failure_rolls_back_entity_row() {
10904        let rt = KhiveRuntime::memory().unwrap();
10905        let ns = Namespace::parse("fault-entity-fts").unwrap();
10906        let tok = NamespaceToken::for_namespace(ns.clone());
10907
10908        arm_fts_fail(ns.as_str());
10909
10910        let result = rt
10911            .create_entity(
10912                &tok,
10913                "concept",
10914                None,
10915                "fts-fail rollback target",
10916                None,
10917                None,
10918                vec![],
10919            )
10920            .await;
10921
10922        assert!(
10923            result.is_err(),
10924            "create_entity must propagate the injected FTS failure"
10925        );
10926        let err_msg = result.unwrap_err().to_string();
10927        assert!(
10928            err_msg.contains("injected FTS failure"),
10929            "error must carry injection message; got: {err_msg}"
10930        );
10931
10932        let entities = rt.list_entities(&tok, None, None, 1000, 0).await.unwrap();
10933        assert!(
10934            entities.is_empty(),
10935            "compensation must remove the entity row after FTS failure; got {entities:?}"
10936        );
10937    }
10938
10939    // Vector insert failure after entity row + FTS commit rolls back both.
10940    // Uses a unique namespace to avoid consuming the VECTOR_FAIL_NS flag from
10941    // a concurrent test's create_entity or create_note.
10942    #[tokio::test]
10943    async fn create_entity_vector_failure_rolls_back_entity_row_and_fts() {
10944        const MODEL: &str = "test-entity-vec-inject";
10945        const DIMS: usize = 4;
10946
10947        let rt = KhiveRuntime::memory().unwrap();
10948        let (provider, _counter) = ConstVecProvider::new(MODEL, DIMS);
10949        rt.register_embedder(provider);
10950
10951        let ns = Namespace::parse("fault-entity-vec").unwrap();
10952        let tok = NamespaceToken::for_namespace(ns.clone());
10953
10954        arm_vector_fail(ns.as_str());
10955
10956        let result = rt
10957            .create_entity(
10958                &tok,
10959                "concept",
10960                None,
10961                "vec-fail rollback target",
10962                Some("description so embed body is non-empty"),
10963                None,
10964                vec![],
10965            )
10966            .await;
10967
10968        assert!(
10969            result.is_err(),
10970            "create_entity must propagate the injected vector failure"
10971        );
10972        let err_msg = result.unwrap_err().to_string();
10973        assert!(
10974            err_msg.contains("injected vector failure"),
10975            "error must carry injection message; got: {err_msg}"
10976        );
10977
10978        let entities = rt.list_entities(&tok, None, None, 1000, 0).await.unwrap();
10979        assert!(
10980            entities.is_empty(),
10981            "compensation must remove entity row after vector failure; got {entities:?}"
10982        );
10983
10984        // FTS document must also be removed.
10985        use khive_storage::types::{TextFilter, TextQueryMode, TextSearchRequest};
10986        let fts_hits = rt
10987            .text(&tok)
10988            .unwrap()
10989            .search(TextSearchRequest {
10990                query: "vec-fail rollback target".to_string(),
10991                mode: TextQueryMode::Plain,
10992                filter: Some(TextFilter {
10993                    namespaces: vec![ns.as_str().to_string()],
10994                    ..Default::default()
10995                }),
10996                top_k: 10,
10997                snippet_chars: 100,
10998            })
10999            .await
11000            .unwrap();
11001        assert!(
11002            fts_hits.is_empty(),
11003            "compensation must remove FTS document after vector failure; got {fts_hits:?}"
11004        );
11005    }
11006
11007    // Multi-model create_entity: second model's vector INSERT fails after the
11008    // first model's insert succeeds, triggering inserted_models rollback.
11009    // Uses arm_vector_fail_after(1) so the first insert passes and the second fails,
11010    // exercising the inserted_models compensation path in create_entity.
11011    // Thread-local VECTOR_FAIL_AFTER is per-thread isolated (current-thread tokio runtime),
11012    // so this test does not race with namespace-targeted VECTOR_FAIL_NS tests.
11013    #[tokio::test]
11014    async fn create_entity_multi_model_second_vector_failure_rolls_back_all() {
11015        const DIMS: usize = 4;
11016
11017        let rt = KhiveRuntime::memory().unwrap();
11018        let (provider_a, _ca) = ConstVecProvider::new("model-a", DIMS);
11019        let (provider_b, _cb) = ConstVecProvider::new("model-b", DIMS);
11020        rt.register_embedder(provider_a);
11021        rt.register_embedder(provider_b);
11022
11023        let ns = Namespace::parse("fault-entity-multi").unwrap();
11024        let tok = NamespaceToken::for_namespace(ns.clone());
11025
11026        // Let the first vector insert succeed, fail on the second.
11027        arm_vector_fail_after(1);
11028
11029        let result = rt
11030            .create_entity(
11031                &tok,
11032                "concept",
11033                None,
11034                "multi-model rollback target",
11035                Some("description for embedding"),
11036                None,
11037                vec![],
11038            )
11039            .await;
11040
11041        assert!(
11042            result.is_err(),
11043            "create_entity must propagate the injected multi-model vector failure"
11044        );
11045
11046        let entities = rt.list_entities(&tok, None, None, 1000, 0).await.unwrap();
11047        assert!(
11048            entities.is_empty(),
11049            "compensation must remove entity row; got {entities:?}"
11050        );
11051
11052        // Both model-a and model-b vector stores must be empty for the entity id.
11053        // (The entity was never returned so we can't get its id from the result;
11054        // list_entities returning empty is the primary assertion. Additionally confirm
11055        // both stores have zero rows via a broad vector search.)
11056        use khive_storage::types::VectorSearchRequest;
11057        let query_vec = vec![1.0_f32; DIMS];
11058        let hits_a = rt
11059            .vectors_for_model(&tok, "model-a")
11060            .unwrap()
11061            .search(VectorSearchRequest {
11062                query_vectors: vec![query_vec.clone()],
11063                top_k: 100,
11064                namespace: Some(ns.as_str().to_string()),
11065                kind: Some(khive_types::SubstrateKind::Entity),
11066                embedding_model: Some("model-a".to_string()),
11067                filter: None,
11068                backend_hints: None,
11069            })
11070            .await
11071            .unwrap();
11072        assert!(
11073            hits_a.is_empty(),
11074            "model-a vector store must be empty after rollback; got {hits_a:?}"
11075        );
11076        let hits_b = rt
11077            .vectors_for_model(&tok, "model-b")
11078            .unwrap()
11079            .search(VectorSearchRequest {
11080                query_vectors: vec![query_vec],
11081                top_k: 100,
11082                namespace: Some(ns.as_str().to_string()),
11083                kind: Some(khive_types::SubstrateKind::Entity),
11084                embedding_model: Some("model-b".to_string()),
11085                filter: None,
11086                backend_hints: None,
11087            })
11088            .await
11089            .unwrap();
11090        assert!(
11091            hits_b.is_empty(),
11092            "model-b vector store must be empty after rollback; got {hits_b:?}"
11093        );
11094    }
11095
11096    // update_entity fans out to ALL registered models.
11097    // After create + update with a changed description, both model-a and model-b
11098    // vector stores hold a row for the entity id.
11099    #[tokio::test]
11100    async fn update_entity_fans_out_to_all_registered_models() {
11101        const DIMS: usize = 4;
11102
11103        let rt = KhiveRuntime::memory().unwrap();
11104        let (provider_a, _ca) = ConstVecProvider::new("embed-a", DIMS);
11105        let (provider_b, _cb) = ConstVecProvider::new("embed-b", DIMS);
11106        rt.register_embedder(provider_a);
11107        rt.register_embedder(provider_b);
11108
11109        let ns = Namespace::parse("update-entity-fanout").unwrap();
11110        let tok = NamespaceToken::for_namespace(ns.clone());
11111
11112        let entity = rt
11113            .create_entity(
11114                &tok,
11115                "concept",
11116                None,
11117                "FanOutEntity",
11118                Some("initial description"),
11119                None,
11120                vec![],
11121            )
11122            .await
11123            .expect("create_entity must succeed");
11124
11125        use crate::curation::EntityPatch;
11126        let patch = EntityPatch {
11127            description: Some(Some("updated description after fan-out fix".to_string())),
11128            ..Default::default()
11129        };
11130        rt.update_entity(&tok, entity.id, patch)
11131            .await
11132            .expect("update_entity must succeed");
11133
11134        use khive_storage::types::VectorSearchRequest;
11135        let query_vec = vec![1.0_f32; DIMS];
11136
11137        let hits_a = rt
11138            .vectors_for_model(&tok, "embed-a")
11139            .unwrap()
11140            .search(VectorSearchRequest {
11141                query_vectors: vec![query_vec.clone()],
11142                top_k: 10,
11143                namespace: Some(ns.as_str().to_string()),
11144                kind: Some(khive_types::SubstrateKind::Entity),
11145                embedding_model: Some("embed-a".to_string()),
11146                filter: None,
11147                backend_hints: None,
11148            })
11149            .await
11150            .unwrap();
11151        assert!(
11152            hits_a.iter().any(|h| h.subject_id == entity.id),
11153            "embed-a must hold a vector for the entity after update; got {hits_a:?}"
11154        );
11155
11156        let hits_b = rt
11157            .vectors_for_model(&tok, "embed-b")
11158            .unwrap()
11159            .search(VectorSearchRequest {
11160                query_vectors: vec![query_vec],
11161                top_k: 10,
11162                namespace: Some(ns.as_str().to_string()),
11163                kind: Some(khive_types::SubstrateKind::Entity),
11164                embedding_model: Some("embed-b".to_string()),
11165                filter: None,
11166                backend_hints: None,
11167            })
11168            .await
11169            .unwrap();
11170        assert!(
11171            hits_b.iter().any(|h| h.subject_id == entity.id),
11172            "embed-b must hold a vector for the entity after update; got {hits_b:?}"
11173        );
11174    }
11175
11176    // update_note fans out to ALL registered models.
11177    // After create + update with changed content, both embed-a and embed-b
11178    // vector stores hold a row for the note id.
11179    #[tokio::test]
11180    async fn update_note_fans_out_to_all_registered_models() {
11181        const DIMS: usize = 4;
11182
11183        let rt = KhiveRuntime::memory().unwrap();
11184        let (provider_a, _ca) = ConstVecProvider::new("embed-a", DIMS);
11185        let (provider_b, _cb) = ConstVecProvider::new("embed-b", DIMS);
11186        rt.register_embedder(provider_a);
11187        rt.register_embedder(provider_b);
11188
11189        let ns = Namespace::parse("update-note-fanout").unwrap();
11190        let tok = NamespaceToken::for_namespace(ns.clone());
11191
11192        let note = rt
11193            .create_note(
11194                &tok,
11195                "observation",
11196                None,
11197                "initial note content for fan-out test",
11198                None,
11199                None,
11200                vec![],
11201            )
11202            .await
11203            .expect("create_note must succeed");
11204
11205        use crate::curation::NotePatch;
11206        let patch = NotePatch {
11207            content: Some("updated content after fan-out fix".to_string()),
11208            ..Default::default()
11209        };
11210        rt.update_note(&tok, note.id, patch)
11211            .await
11212            .expect("update_note must succeed");
11213
11214        use khive_storage::types::VectorSearchRequest;
11215        let query_vec = vec![1.0_f32; DIMS];
11216
11217        let hits_a = rt
11218            .vectors_for_model(&tok, "embed-a")
11219            .unwrap()
11220            .search(VectorSearchRequest {
11221                query_vectors: vec![query_vec.clone()],
11222                top_k: 10,
11223                namespace: Some(ns.as_str().to_string()),
11224                kind: Some(khive_types::SubstrateKind::Note),
11225                embedding_model: Some("embed-a".to_string()),
11226                filter: None,
11227                backend_hints: None,
11228            })
11229            .await
11230            .unwrap();
11231        assert!(
11232            hits_a.iter().any(|h| h.subject_id == note.id),
11233            "embed-a must hold a vector for the note after update; got {hits_a:?}"
11234        );
11235
11236        let hits_b = rt
11237            .vectors_for_model(&tok, "embed-b")
11238            .unwrap()
11239            .search(VectorSearchRequest {
11240                query_vectors: vec![query_vec],
11241                top_k: 10,
11242                namespace: Some(ns.as_str().to_string()),
11243                kind: Some(khive_types::SubstrateKind::Note),
11244                embedding_model: Some("embed-b".to_string()),
11245                filter: None,
11246                backend_hints: None,
11247            })
11248            .await
11249            .unwrap();
11250        assert!(
11251            hits_b.iter().any(|h| h.subject_id == note.id),
11252            "embed-b must hold a vector for the note after update; got {hits_b:?}"
11253        );
11254    }
11255
11256    // ── By-ID ops must not filter by namespace ──────────────────────────────
11257    //
11258    // A namespace-gated by-ID op on an entity stamped "foreign" from a "local"
11259    // token would return NotFound, causing gtd.complete / update blindness.
11260    // UUID is globally unique; by-ID ops find the record regardless of
11261    // which namespace the caller's token carries.
11262
11263    #[tokio::test]
11264    async fn get_entity_cross_namespace_succeeds() {
11265        let rt = rt();
11266        // Create under "lambda:leo".
11267        let leo_tok = NamespaceToken::for_namespace(Namespace::parse("lambda:leo").unwrap());
11268        let entity = rt
11269            .create_entity(&leo_tok, "concept", None, "Peer-Entity", None, None, vec![])
11270            .await
11271            .unwrap();
11272        assert_eq!(entity.namespace, "lambda:leo");
11273
11274        // Read from "local" — must succeed (no namespace gate on by-ID get).
11275        let local_tok = NamespaceToken::local();
11276        let fetched = rt.get_entity(&local_tok, entity.id).await;
11277        assert!(
11278            fetched.is_ok(),
11279            "get_entity from local token must find lambda:leo entity; got {:?}",
11280            fetched
11281        );
11282        assert_eq!(fetched.unwrap().id, entity.id);
11283    }
11284
11285    #[tokio::test]
11286    async fn update_entity_cross_namespace_succeeds() {
11287        let rt = rt();
11288        let leo_tok = NamespaceToken::for_namespace(Namespace::parse("lambda:leo").unwrap());
11289        let entity = rt
11290            .create_entity(
11291                &leo_tok,
11292                "concept",
11293                None,
11294                "Peer-Entity-Update",
11295                None,
11296                None,
11297                vec![],
11298            )
11299            .await
11300            .unwrap();
11301
11302        // Update from "local" token — must not error with NotFound.
11303        let local_tok = NamespaceToken::local();
11304        let patch = crate::curation::EntityPatch {
11305            name: Some("Peer-Entity-Updated".to_string()),
11306            ..Default::default()
11307        };
11308        let result = rt.update_entity(&local_tok, entity.id, patch).await;
11309        assert!(
11310            result.is_ok(),
11311            "update_entity from local token must succeed on lambda:leo entity; got {:?}",
11312            result
11313        );
11314        assert_eq!(result.unwrap().name, "Peer-Entity-Updated");
11315    }
11316
11317    #[tokio::test]
11318    async fn delete_entity_cross_namespace_succeeds() {
11319        let rt = rt();
11320        let leo_tok = NamespaceToken::for_namespace(Namespace::parse("lambda:leo").unwrap());
11321        let entity = rt
11322            .create_entity(
11323                &leo_tok,
11324                "concept",
11325                None,
11326                "Peer-Entity-Delete",
11327                None,
11328                None,
11329                vec![],
11330            )
11331            .await
11332            .unwrap();
11333
11334        // Delete from "local" token — must succeed.
11335        let local_tok = NamespaceToken::local();
11336        let deleted = rt.delete_entity(&local_tok, entity.id, false).await;
11337        assert!(
11338            deleted.is_ok(),
11339            "delete_entity from local token must succeed on lambda:leo entity; got {:?}",
11340            deleted
11341        );
11342        assert!(
11343            deleted.unwrap(),
11344            "delete must return true when entity existed"
11345        );
11346    }
11347
11348    #[tokio::test]
11349    async fn namespace_preserved_on_entity_after_cross_namespace_get() {
11350        let rt = rt();
11351        let leo_tok = NamespaceToken::for_namespace(Namespace::parse("lambda:leo").unwrap());
11352        let entity = rt
11353            .create_entity(
11354                &leo_tok,
11355                "concept",
11356                None,
11357                "NS-Preserved",
11358                None,
11359                None,
11360                vec![],
11361            )
11362            .await
11363            .unwrap();
11364
11365        // The namespace column on the fetched record must still say "lambda:leo".
11366        let local_tok = NamespaceToken::local();
11367        let fetched = rt.get_entity(&local_tok, entity.id).await.unwrap();
11368        assert_eq!(
11369            fetched.namespace, "lambda:leo",
11370            "namespace column must be preserved; not overwritten with caller's namespace"
11371        );
11372    }
11373
11374    // ── PackByIdResolver unit tests ──────────────────────────────────────────
11375
11376    use crate::pack::PackByIdResolver;
11377    use tokio::sync::Mutex as TokioMutex;
11378
11379    #[derive(Debug, Default)]
11380    struct MockResolverState {
11381        owned: Vec<Uuid>,
11382        deleted: Vec<Uuid>,
11383        delete_calls: Vec<(Uuid, bool)>,
11384    }
11385
11386    struct MockPackResolver(TokioMutex<MockResolverState>);
11387
11388    impl MockPackResolver {
11389        fn new() -> Self {
11390            Self(TokioMutex::new(MockResolverState::default()))
11391        }
11392    }
11393
11394    #[async_trait::async_trait]
11395    impl crate::pack::PackByIdResolver for MockPackResolver {
11396        async fn resolve_by_id(&self, id: Uuid) -> Result<Option<Resolved>, RuntimeError> {
11397            let state = self.0.lock().await;
11398            if state.owned.contains(&id) && !state.deleted.contains(&id) {
11399                Ok(Some(Resolved::PackRecord {
11400                    pack: "mock".into(),
11401                    kind: "widget".into(),
11402                    data: serde_json::json!({ "id": id.to_string(), "name": "test-widget" }),
11403                }))
11404            } else {
11405                Ok(None)
11406            }
11407        }
11408
11409        async fn resolve_by_id_including_deleted(
11410            &self,
11411            id: Uuid,
11412        ) -> Result<Option<Resolved>, RuntimeError> {
11413            let state = self.0.lock().await;
11414            if state.owned.contains(&id) {
11415                Ok(Some(Resolved::PackRecord {
11416                    pack: "mock".into(),
11417                    kind: "widget".into(),
11418                    data: serde_json::json!({ "id": id.to_string(), "name": "test-widget" }),
11419                }))
11420            } else {
11421                Ok(None)
11422            }
11423        }
11424
11425        async fn delete_by_id(
11426            &self,
11427            id: Uuid,
11428            hard: bool,
11429        ) -> Result<serde_json::Value, RuntimeError> {
11430            let mut state = self.0.lock().await;
11431            if !state.owned.contains(&id) {
11432                return Err(RuntimeError::NotFound(format!(
11433                    "mock widget not found: {id}"
11434                )));
11435            }
11436            state.delete_calls.push((id, hard));
11437            if hard {
11438                state.owned.retain(|&x| x != id);
11439                state.deleted.retain(|&x| x != id);
11440            } else {
11441                state.deleted.push(id);
11442            }
11443            Ok(
11444                serde_json::json!({ "deleted": true, "id": id.to_string(), "kind": "widget", "hard": hard }),
11445            )
11446        }
11447    }
11448
11449    fn registry_with_mock_resolver(
11450        rt: KhiveRuntime,
11451        resolver: Box<dyn crate::pack::PackByIdResolver>,
11452    ) -> crate::VerbRegistry {
11453        use crate::pack::{PackRuntime, VerbRegistryBuilder};
11454        use khive_types::{HandlerDef, VerbCategory, Visibility};
11455
11456        static MINIMAL_HANDLERS: &[HandlerDef] = &[HandlerDef {
11457            name: "minimal.noop",
11458            description: "noop",
11459            visibility: Visibility::Verb,
11460            category: VerbCategory::Commissive,
11461            params: &[],
11462        }];
11463
11464        struct MinimalPack;
11465        impl khive_types::Pack for MinimalPack {
11466            const NAME: &'static str = "minimal";
11467            const NOTE_KINDS: &'static [&'static str] = &[];
11468            const ENTITY_KINDS: &'static [&'static str] = &[];
11469            const HANDLERS: &'static [HandlerDef] = MINIMAL_HANDLERS;
11470        }
11471        #[async_trait::async_trait]
11472        impl PackRuntime for MinimalPack {
11473            fn name(&self) -> &str {
11474                "minimal"
11475            }
11476            fn note_kinds(&self) -> &'static [&'static str] {
11477                &[]
11478            }
11479            fn entity_kinds(&self) -> &'static [&'static str] {
11480                &[]
11481            }
11482            fn handlers(&self) -> &'static [HandlerDef] {
11483                MINIMAL_HANDLERS
11484            }
11485            async fn dispatch(
11486                &self,
11487                _verb: &str,
11488                _params: serde_json::Value,
11489                _registry: &crate::VerbRegistry,
11490                _token: &NamespaceToken,
11491            ) -> Result<serde_json::Value, RuntimeError> {
11492                Err(RuntimeError::InvalidInput("stub".into()))
11493            }
11494        }
11495
11496        let _ = rt;
11497        let mut builder = VerbRegistryBuilder::new();
11498        builder.register(MinimalPack);
11499        builder.register_resolver("mock", resolver);
11500        builder.build().expect("registry build failed")
11501    }
11502
11503    #[tokio::test]
11504    async fn pack_record_resolved_pair_returns_none() {
11505        let pr = Resolved::PackRecord {
11506            pack: "knowledge".into(),
11507            kind: "atom".into(),
11508            data: serde_json::json!({}),
11509        };
11510        assert!(
11511            resolved_pair(Some(&pr)).is_none(),
11512            "PackRecord must not be a valid edge endpoint"
11513        );
11514    }
11515
11516    #[test]
11517    fn resolved_pair_surfaces_entity_type() {
11518        let e = Resolved::Entity(
11519            Entity::new("mathlib", "concept", "Nat.add_comm").with_entity_type(Some("theorem")),
11520        );
11521        assert_eq!(
11522            resolved_pair(Some(&e)),
11523            Some(("entity", "concept", Some("theorem"))),
11524            "entity_type subtype must be surfaced alongside base kind"
11525        );
11526    }
11527
11528    #[test]
11529    fn endpoint_of_type_matches_subtype_not_base_kind() {
11530        // An entity whose base kind is "concept" and subtype is "theorem".
11531        let kind = "concept";
11532        let et = Some("theorem");
11533
11534        // EntityOfType matches only when BOTH base kind and subtype match.
11535        assert!(endpoint_matches(
11536            &EndpointKind::EntityOfType {
11537                kind: "concept",
11538                entity_type: "theorem",
11539            },
11540            "entity",
11541            kind,
11542            et
11543        ));
11544        assert!(!endpoint_matches(
11545            &EndpointKind::EntityOfType {
11546                kind: "concept",
11547                entity_type: "definition",
11548            },
11549            "entity",
11550            kind,
11551            et
11552        ));
11553
11554        // The silently-inert trap: EntityOfKind sees only the BASE
11555        // kind, so EntityOfKind("theorem") never matches a concept/theorem.
11556        assert!(!endpoint_matches(
11557            &EndpointKind::EntityOfKind("theorem"),
11558            "entity",
11559            kind,
11560            et
11561        ));
11562        // EntityOfKind still matches the base kind.
11563        assert!(endpoint_matches(
11564            &EndpointKind::EntityOfKind("concept"),
11565            "entity",
11566            kind,
11567            et
11568        ));
11569
11570        // EntityOfType rejects non-entity substrates and entities with no subtype.
11571        assert!(!endpoint_matches(
11572            &EndpointKind::EntityOfType {
11573                kind: "concept",
11574                entity_type: "theorem",
11575            },
11576            "note",
11577            "task",
11578            None
11579        ));
11580        assert!(!endpoint_matches(
11581            &EndpointKind::EntityOfType {
11582                kind: "concept",
11583                entity_type: "theorem",
11584            },
11585            "entity",
11586            kind,
11587            None
11588        ));
11589    }
11590
11591    #[test]
11592    fn endpoint_of_type_requires_base_kind_match() {
11593        // Regression: an entity with entity_type="theorem" but base kind != "concept"
11594        // must NOT match a formal concept rule. This was the exact bypass:
11595        // before the fix, EntityOfType("theorem") ignored the base kind entirely.
11596        let wrong_base_kind = "project"; // not "concept"
11597        let et = Some("theorem");
11598
11599        // The formal rule requires kind="concept". A "project" entity with
11600        // entity_type="theorem" must not match — even though the subtype string
11601        // matches — because the base kind differs.
11602        assert!(
11603            !endpoint_matches(
11604                &EndpointKind::EntityOfType {
11605                    kind: "concept",
11606                    entity_type: "theorem",
11607                },
11608                "entity",
11609                wrong_base_kind,
11610                et
11611            ),
11612            "EntityOfType must reject an entity whose base kind != rule.kind \
11613             even when entity_type matches — the pre-fix bug admitted this"
11614        );
11615
11616        // The correct concept entity with the same subtype still matches.
11617        assert!(endpoint_matches(
11618            &EndpointKind::EntityOfType {
11619                kind: "concept",
11620                entity_type: "theorem",
11621            },
11622            "entity",
11623            "concept",
11624            et
11625        ));
11626    }
11627
11628    #[tokio::test]
11629    async fn registry_resolvers_accessor_returns_registered() {
11630        let resolver = Box::new(MockPackResolver::new());
11631        let registry = registry_with_mock_resolver(rt(), resolver);
11632        assert_eq!(registry.resolvers().len(), 1);
11633        assert_eq!(registry.resolvers()[0].0, "mock");
11634    }
11635
11636    #[tokio::test]
11637    async fn mock_resolver_resolve_by_id_returns_pack_record() {
11638        let id = Uuid::new_v4();
11639        let resolver: Box<dyn PackByIdResolver> = Box::new(MockPackResolver::new());
11640        // We need interior access — downcast first, then use via trait.
11641        let inner = MockPackResolver::new();
11642        inner.0.lock().await.owned.push(id);
11643        let result: Result<Option<Resolved>, RuntimeError> = inner.resolve_by_id(id).await;
11644        match result.unwrap() {
11645            Some(Resolved::PackRecord { pack, kind, data }) => {
11646                assert_eq!(pack, "mock");
11647                assert_eq!(kind, "widget");
11648                assert_eq!(data["id"].as_str().unwrap(), id.to_string());
11649            }
11650            other => panic!("expected PackRecord, got {:?}", other),
11651        }
11652        let _ = resolver;
11653    }
11654
11655    #[tokio::test]
11656    async fn mock_resolver_resolve_unknown_uuid_returns_none() {
11657        let inner = MockPackResolver::new();
11658        let id = Uuid::new_v4();
11659        let result: Result<Option<Resolved>, RuntimeError> = inner.resolve_by_id(id).await;
11660        assert!(result.unwrap().is_none());
11661    }
11662
11663    #[tokio::test]
11664    async fn mock_resolver_delete_soft_records_call() {
11665        let id = Uuid::new_v4();
11666        let inner = MockPackResolver::new();
11667        inner.0.lock().await.owned.push(id);
11668
11669        let result: Result<serde_json::Value, RuntimeError> = inner.delete_by_id(id, false).await;
11670        let result = result.unwrap();
11671        assert_eq!(result["deleted"], serde_json::json!(true));
11672        assert_eq!(result["hard"], serde_json::json!(false));
11673
11674        // After soft-delete: resolve_by_id returns None, but including_deleted returns Some.
11675        let live: Result<Option<Resolved>, RuntimeError> = inner.resolve_by_id(id).await;
11676        assert!(live.unwrap().is_none());
11677        let incl: Result<Option<Resolved>, RuntimeError> =
11678            inner.resolve_by_id_including_deleted(id).await;
11679        assert!(incl.unwrap().is_some());
11680    }
11681
11682    #[tokio::test]
11683    async fn mock_resolver_delete_hard_removes_record() {
11684        let id = Uuid::new_v4();
11685        let inner = MockPackResolver::new();
11686        inner.0.lock().await.owned.push(id);
11687
11688        let result: Result<serde_json::Value, RuntimeError> = inner.delete_by_id(id, true).await;
11689        assert_eq!(result.unwrap()["hard"], serde_json::json!(true));
11690
11691        // After hard-delete: neither probe finds the record.
11692        let incl: Result<Option<Resolved>, RuntimeError> =
11693            inner.resolve_by_id_including_deleted(id).await;
11694        assert!(incl.unwrap().is_none());
11695    }
11696
11697    #[tokio::test]
11698    async fn pack_record_not_valid_context_entity() {
11699        // Validates the GTD handler arm compiles and returns InvalidInput.
11700        // We exercise the match logic directly by constructing a PackRecord Resolved.
11701        let pr = Resolved::PackRecord {
11702            pack: "knowledge".into(),
11703            kind: "atom".into(),
11704            data: serde_json::json!({}),
11705        };
11706        // The match in GTD handlers.rs now handles PackRecord → InvalidInput.
11707        // We can verify the enum variant is reachable.
11708        assert!(matches!(pr, Resolved::PackRecord { .. }));
11709    }
11710
11711    // ── Batched enrich_neighbor_hits / enrich_path_nodes ────────────────────
11712
11713    fn neighbor_hit(node_id: Uuid) -> NeighborHit {
11714        NeighborHit {
11715            node_id,
11716            edge_id: Uuid::new_v4(),
11717            relation: EdgeRelation::Extends,
11718            weight: 1.0,
11719            name: None,
11720            kind: None,
11721            entity_type: None,
11722        }
11723    }
11724
11725    fn path_node(node_id: Uuid, depth: usize) -> PathNode {
11726        PathNode {
11727            node_id,
11728            via_edge: None,
11729            depth,
11730            name: None,
11731            kind: None,
11732            properties: None,
11733        }
11734    }
11735
11736    /// enrich_neighbor_hits: entity hit resolved, note hit resolved with
11737    /// name-fallback to "[kind]", bogus UUID left as None.  Order preserved.
11738    #[tokio::test]
11739    async fn enrich_neighbor_hits_batch_entity_note_and_bogus() {
11740        let rt = rt();
11741        let tok = NamespaceToken::local();
11742
11743        // Create an entity neighbor.
11744        let entity = rt
11745            .create_entity(&tok, "concept", None, "MyEntity", None, None, vec![])
11746            .await
11747            .unwrap();
11748
11749        // Nameless note — name falls back to "[observation]".
11750        let note = rt
11751            .create_note(&tok, "observation", None, "body", Some(0.5), None, vec![])
11752            .await
11753            .unwrap();
11754
11755        let bogus_id = Uuid::new_v4();
11756
11757        let mut hits = vec![
11758            neighbor_hit(entity.id),
11759            neighbor_hit(note.id),
11760            neighbor_hit(bogus_id),
11761        ];
11762
11763        rt.enrich_neighbor_hits(&tok, &mut hits).await;
11764
11765        assert_eq!(hits[0].name.as_deref(), Some("MyEntity"));
11766        assert_eq!(hits[0].kind.as_deref(), Some("concept"));
11767
11768        assert_eq!(hits[1].name.as_deref(), Some("[observation]"));
11769        assert_eq!(hits[1].kind.as_deref(), Some("observation"));
11770
11771        assert!(hits[2].name.is_none());
11772        assert!(hits[2].kind.is_none());
11773    }
11774
11775    /// enrich_neighbor_hits: note with a non-empty name uses the actual name.
11776    #[tokio::test]
11777    async fn enrich_neighbor_hits_note_with_name_uses_name() {
11778        let rt = rt();
11779        let tok = NamespaceToken::local();
11780
11781        let note = rt
11782            .create_note(
11783                &tok,
11784                "insight",
11785                Some("NoteTitle"),
11786                "body",
11787                Some(0.5),
11788                None,
11789                vec![],
11790            )
11791            .await
11792            .unwrap();
11793
11794        let mut hits = vec![neighbor_hit(note.id)];
11795        rt.enrich_neighbor_hits(&tok, &mut hits).await;
11796
11797        assert_eq!(hits[0].name.as_deref(), Some("NoteTitle"));
11798        assert_eq!(hits[0].kind.as_deref(), Some("insight"));
11799    }
11800
11801    /// enrich_path_nodes: two paths sharing a repeated node_id; each node
11802    /// enriched from a single batch; unresolved node stays None.
11803    #[tokio::test]
11804    async fn enrich_path_nodes_batch_dedup_and_unresolved() {
11805        let rt = rt();
11806        let tok = NamespaceToken::local();
11807
11808        let ea = rt
11809            .create_entity(&tok, "concept", None, "Alpha", None, None, vec![])
11810            .await
11811            .unwrap();
11812        let eb = rt
11813            .create_entity(&tok, "document", None, "Beta", None, None, vec![])
11814            .await
11815            .unwrap();
11816        let bogus_id = Uuid::new_v4();
11817
11818        // Path 1: ea → eb → bogus  |  Path 2: eb → ea  (shared nodes, reversed)
11819        let mut paths = vec![
11820            GraphPath {
11821                root_id: ea.id,
11822                nodes: vec![
11823                    path_node(ea.id, 0),
11824                    path_node(eb.id, 1),
11825                    path_node(bogus_id, 2),
11826                ],
11827                total_weight: 1.0,
11828            },
11829            GraphPath {
11830                root_id: eb.id,
11831                nodes: vec![path_node(eb.id, 0), path_node(ea.id, 1)],
11832                total_weight: 1.0,
11833            },
11834        ];
11835
11836        rt.enrich_path_nodes(&tok, &mut paths, false).await;
11837
11838        assert_eq!(paths[0].nodes[0].name.as_deref(), Some("Alpha"));
11839        assert_eq!(paths[0].nodes[0].kind.as_deref(), Some("concept"));
11840        assert_eq!(paths[0].nodes[1].name.as_deref(), Some("Beta"));
11841        assert_eq!(paths[0].nodes[1].kind.as_deref(), Some("document"));
11842        assert!(paths[0].nodes[2].name.is_none());
11843        assert!(paths[0].nodes[2].kind.is_none());
11844
11845        // Shared nodes resolve from the same HashMap — order within each path is preserved.
11846        assert_eq!(paths[1].nodes[0].name.as_deref(), Some("Beta"));
11847        assert_eq!(paths[1].nodes[0].kind.as_deref(), Some("document"));
11848        assert_eq!(paths[1].nodes[1].name.as_deref(), Some("Alpha"));
11849        assert_eq!(paths[1].nodes[1].kind.as_deref(), Some("concept"));
11850    }
11851
11852    /// enrich_neighbor_hits and enrich_path_nodes must resolve entities whose
11853    /// namespace is in the token's extra-visible set (not only the primary).
11854    ///
11855    /// Regression: the old `get_entities_by_ids`
11856    /// call left `filter.namespaces` unset, which collapses to
11857    /// `namespace = primary` in `build_entity_where`.  Graph expansion already
11858    /// crosses visible namespaces, so enrichment must match that scope.
11859    #[tokio::test]
11860    async fn enrich_resolves_entities_in_extra_visible_namespace() {
11861        let rt = KhiveRuntime::memory().unwrap();
11862
11863        let ns_a = Namespace::parse("enrich-ns-a").unwrap();
11864        let ns_b = Namespace::parse("enrich-ns-b").unwrap();
11865
11866        let tok_b = rt.authorize(ns_b.clone()).unwrap();
11867
11868        // Entity lives in ns-b.
11869        let entity_b = rt
11870            .create_entity(&tok_b, "concept", None, "EntityInB", None, None, vec![])
11871            .await
11872            .unwrap();
11873        assert_eq!(entity_b.namespace, "enrich-ns-b");
11874
11875        // Token whose primary is ns-a but ns-b is in the visible set.
11876        let vis_tok = rt
11877            .authorize_with_visibility(ns_a.clone(), vec![ns_b.clone()])
11878            .unwrap();
11879
11880        // ── neighbor hits ──────────────────────────────────────────────────
11881        let mut hits = vec![neighbor_hit(entity_b.id)];
11882        rt.enrich_neighbor_hits(&vis_tok, &mut hits).await;
11883
11884        assert_eq!(
11885            hits[0].name.as_deref(),
11886            Some("EntityInB"),
11887            "entity in extra-visible ns must be enriched by enrich_neighbor_hits"
11888        );
11889        assert_eq!(hits[0].kind.as_deref(), Some("concept"));
11890
11891        // ── path nodes ─────────────────────────────────────────────────────
11892        let mut paths = vec![GraphPath {
11893            root_id: entity_b.id,
11894            nodes: vec![path_node(entity_b.id, 0)],
11895            total_weight: 1.0,
11896        }];
11897        rt.enrich_path_nodes(&vis_tok, &mut paths, false).await;
11898
11899        assert_eq!(
11900            paths[0].nodes[0].name.as_deref(),
11901            Some("EntityInB"),
11902            "entity in extra-visible ns must be enriched by enrich_path_nodes"
11903        );
11904        assert_eq!(paths[0].nodes[0].kind.as_deref(), Some("concept"));
11905    }
11906
11907    /// enrich_neighbor_hits populates entity_type from the already-fetched entity
11908    /// batch when the entity has a non-null entity_type.  Entities without one and
11909    /// note nodes leave entity_type as None.
11910    #[tokio::test]
11911    async fn enrich_neighbor_hits_populates_entity_type() {
11912        let rt = rt();
11913        let tok = NamespaceToken::local();
11914
11915        let props = serde_json::json!({"domain": "attention"});
11916        let entity = rt
11917            .create_entity(
11918                &tok,
11919                "concept",
11920                Some("algorithm"),
11921                "FlashAttn",
11922                None,
11923                Some(props),
11924                vec![],
11925            )
11926            .await
11927            .unwrap();
11928
11929        let entity_no_type = rt
11930            .create_entity(&tok, "concept", None, "PlainConcept", None, None, vec![])
11931            .await
11932            .unwrap();
11933
11934        let mut hits = vec![neighbor_hit(entity.id), neighbor_hit(entity_no_type.id)];
11935        rt.enrich_neighbor_hits(&tok, &mut hits).await;
11936
11937        assert_eq!(hits[0].entity_type.as_deref(), Some("algorithm"));
11938        assert!(
11939            hits[1].entity_type.is_none(),
11940            "entity without entity_type must leave the field as None"
11941        );
11942    }
11943
11944    /// enrich_path_nodes populates properties from the already-fetched entity
11945    /// batch when the entity has a non-null properties blob.  Entities without
11946    /// properties leave the field as None.
11947    #[tokio::test]
11948    async fn enrich_path_nodes_populates_properties() {
11949        let rt = rt();
11950        let tok = NamespaceToken::local();
11951
11952        let props = serde_json::json!({"year": 2024, "venue": "NeurIPS"});
11953        let entity_with_props = rt
11954            .create_entity(
11955                &tok,
11956                "document",
11957                None,
11958                "AttentionPaper",
11959                None,
11960                Some(props.clone()),
11961                vec![],
11962            )
11963            .await
11964            .unwrap();
11965
11966        let entity_no_props = rt
11967            .create_entity(&tok, "concept", None, "BareConceptNode", None, None, vec![])
11968            .await
11969            .unwrap();
11970
11971        let mut paths = vec![GraphPath {
11972            root_id: entity_with_props.id,
11973            nodes: vec![
11974                path_node(entity_with_props.id, 0),
11975                path_node(entity_no_props.id, 1),
11976            ],
11977            total_weight: 1.0,
11978        }];
11979
11980        rt.enrich_path_nodes(&tok, &mut paths, true).await;
11981
11982        assert_eq!(
11983            paths[0].nodes[0].properties.as_ref(),
11984            Some(&props),
11985            "properties must be filled when entity has a non-null properties blob"
11986        );
11987        assert!(
11988            paths[0].nodes[1].properties.is_none(),
11989            "entity without properties must leave the field as None"
11990        );
11991    }
11992
11993    /// Regression: GraphStore::traverse must not fail with "too many SQL variables"
11994    /// or "too many terms in compound SELECT" when the root set exceeds the chunk
11995    /// boundary (400 roots per CTE VALUES clause after the fix).
11996    ///
11997    /// Graph: 1 000 roots, each with one distinct outgoing edge to a unique child.
11998    /// The graph store's `traverse` is exercised directly (bypassing the runtime-level
11999    /// entity-existence filter) to keep the test fast and targeted.
12000    ///
12001    /// Correctness: every root must appear in the result with exactly one reachable node.
12002    #[tokio::test]
12003    async fn traverse_chunks_root_binds_over_host_param_limit() {
12004        use khive_storage::types::TraversalOptions;
12005
12006        let rt = rt();
12007        let tok = NamespaceToken::local();
12008        let graph = rt.graph(&tok).unwrap();
12009
12010        const N: usize = 1_000;
12011        let now = chrono::Utc::now();
12012
12013        let mut roots: Vec<uuid::Uuid> = Vec::with_capacity(N);
12014        let mut expected_children: std::collections::HashMap<uuid::Uuid, uuid::Uuid> =
12015            std::collections::HashMap::with_capacity(N);
12016
12017        for _ in 0..N {
12018            let root = uuid::Uuid::new_v4();
12019            let child = uuid::Uuid::new_v4();
12020            graph
12021                .upsert_edge(Edge {
12022                    id: LinkId::from(uuid::Uuid::new_v4()),
12023                    namespace: "local".to_string(),
12024                    source_id: root,
12025                    target_id: child,
12026                    relation: EdgeRelation::Extends,
12027                    weight: 1.0,
12028                    created_at: now,
12029                    updated_at: now,
12030                    deleted_at: None,
12031                    metadata: None,
12032                    target_backend: None,
12033                })
12034                .await
12035                .unwrap();
12036            roots.push(root);
12037            expected_children.insert(root, child);
12038        }
12039
12040        // Must return Ok: no "too many SQL variables" or "too many terms in compound SELECT".
12041        let paths = graph
12042            .traverse(TraversalRequest {
12043                roots: roots.clone(),
12044                options: TraversalOptions {
12045                    max_depth: 1,
12046                    direction: Direction::Out,
12047                    relations: None,
12048                    min_weight: None,
12049                    limit: None,
12050                },
12051                include_roots: false,
12052                include_properties: false,
12053            })
12054            .await
12055            .unwrap();
12056
12057        assert_eq!(
12058            paths.len(),
12059            N,
12060            "traverse over {N} roots must return one GraphPath per root"
12061        );
12062
12063        for path in &paths {
12064            let expected_child = expected_children[&path.root_id];
12065            assert_eq!(
12066                path.nodes.len(),
12067                1,
12068                "root {:?} must reach exactly 1 node",
12069                path.root_id
12070            );
12071            assert_eq!(
12072                path.nodes[0].node_id, expected_child,
12073                "root {:?} must reach its direct child",
12074                path.root_id
12075            );
12076        }
12077    }
12078
12079    // ── Additive EDGE_RULES composition: pack EntityOfType rules must not shadow
12080    // the base EntityOfKind contract for the same relation. ──────────────────────
12081    //
12082    // When a pack contributes EntityOfType rules for a relation (e.g. variant_of:
12083    // goal -> theorem and goal -> definition), the base contract's EntityOfKind
12084    // rule for the same relation (concept -> concept) must still fire for entities
12085    // whose base kind is "concept" but whose EntityOfType pair is not in any pack rule.
12086    //
12087    // Specifically: a goal entity resolves to base kind "concept". A goal -> goal
12088    // variant_of edge has no matching pack rule (goal -> goal is not declared), so
12089    // pack_rule_allows returns false. The validator then extracts the base kind
12090    // ("concept") and checks base_entity_rule_allows, which returns true.
12091    // The edge is therefore allowed: additive composition holds.
12092
12093    #[test]
12094    fn pack_entity_of_type_rules_do_not_shadow_base_entity_of_kind_rule() {
12095        // Formal-style EntityOfType rules for variant_of: goal -> theorem, goal -> definition.
12096        // goal -> goal is deliberately absent — that case must fall through to the base rule.
12097        let pack_rules: Vec<EdgeEndpointRule> = vec![
12098            EdgeEndpointRule {
12099                relation: EdgeRelation::VariantOf,
12100                source: EndpointKind::EntityOfType {
12101                    kind: "concept",
12102                    entity_type: "goal",
12103                },
12104                target: EndpointKind::EntityOfType {
12105                    kind: "concept",
12106                    entity_type: "theorem",
12107                },
12108            },
12109            EdgeEndpointRule {
12110                relation: EdgeRelation::VariantOf,
12111                source: EndpointKind::EntityOfType {
12112                    kind: "concept",
12113                    entity_type: "goal",
12114                },
12115                target: EndpointKind::EntityOfType {
12116                    kind: "concept",
12117                    entity_type: "definition",
12118                },
12119            },
12120        ];
12121
12122        let goal_a =
12123            Resolved::Entity(Entity::new("local", "concept", "G-a").with_entity_type(Some("goal")));
12124        let goal_b =
12125            Resolved::Entity(Entity::new("local", "concept", "G-b").with_entity_type(Some("goal")));
12126
12127        // Pack rules do not cover goal -> goal, so pack_rule_allows must return false.
12128        assert!(
12129            !pack_rule_allows(
12130                &pack_rules,
12131                EdgeRelation::VariantOf,
12132                Some(&goal_a),
12133                Some(&goal_b)
12134            ),
12135            "pack rules must not cover goal->goal variant_of (no such rule declared)"
12136        );
12137
12138        // The base contract allows concept -> concept for variant_of.
12139        // A goal entity's base kind is "concept", so this must return true.
12140        assert!(
12141            base_entity_rule_allows("concept", EdgeRelation::VariantOf, "concept"),
12142            "base contract must allow concept->concept variant_of regardless of pack EntityOfType rules"
12143        );
12144    }
12145
12146    // Integration path: pack EntityOfType rules installed on the runtime must not
12147    // block a goal->goal variant_of link that the base contract already permits.
12148    // Exercises validate_edge_relation_endpoints lines 1173-1223:
12149    //   pack miss -> extract e.kind ("concept") -> base_entity_rule_allows -> Ok.
12150    #[tokio::test]
12151    async fn link_variant_of_goal_to_goal_allowed_when_pack_has_entity_of_type_rules() {
12152        let rt = rt();
12153        let tok = NamespaceToken::local();
12154
12155        // Install formal-style EntityOfType rules for variant_of (goal -> theorem/definition).
12156        // goal -> goal is absent so the base concept->concept rule must carry this case.
12157        rt.install_edge_rules(vec![
12158            EdgeEndpointRule {
12159                relation: EdgeRelation::VariantOf,
12160                source: EndpointKind::EntityOfType {
12161                    kind: "concept",
12162                    entity_type: "goal",
12163                },
12164                target: EndpointKind::EntityOfType {
12165                    kind: "concept",
12166                    entity_type: "theorem",
12167                },
12168            },
12169            EdgeEndpointRule {
12170                relation: EdgeRelation::VariantOf,
12171                source: EndpointKind::EntityOfType {
12172                    kind: "concept",
12173                    entity_type: "goal",
12174                },
12175                target: EndpointKind::EntityOfType {
12176                    kind: "concept",
12177                    entity_type: "definition",
12178                },
12179            },
12180        ]);
12181
12182        let a = rt
12183            .create_entity(
12184                &tok,
12185                "concept",
12186                Some("goal"),
12187                "Goal Alpha",
12188                None,
12189                None,
12190                vec![],
12191            )
12192            .await
12193            .unwrap();
12194        let b = rt
12195            .create_entity(
12196                &tok,
12197                "concept",
12198                Some("goal"),
12199                "Goal Beta",
12200                None,
12201                None,
12202                vec![],
12203            )
12204            .await
12205            .unwrap();
12206
12207        // Neither endpoint matches any pack rule (no goal->goal rule).
12208        // The base concept->concept rule must fire and allow the edge.
12209        let result = rt
12210            .link(&tok, a.id, b.id, EdgeRelation::VariantOf, 1.0, None)
12211            .await;
12212        assert!(
12213            result.is_ok(),
12214            "goal->goal variant_of must be allowed via the base concept->concept rule \
12215             even when EntityOfType rules for variant_of are installed; got {result:?}"
12216        );
12217
12218        // Fail-closed check: additive rules must not make validation fail-open.
12219        // A goal(concept) -> project variant_of edge has no matching pack rule
12220        // (project is not in the installed variant_of rules) and no matching base
12221        // rule (no (concept, VariantOf, project) row). It must be rejected.
12222        let p = rt
12223            .create_entity(&tok, "project", None, "Proj", None, None, vec![])
12224            .await
12225            .unwrap();
12226        let bad = rt
12227            .link(&tok, a.id, p.id, EdgeRelation::VariantOf, 1.0, None)
12228            .await;
12229        assert!(
12230            bad.is_err(),
12231            "additive pack rules must not make validation fail-open; \
12232             goal(concept)->project variant_of must be rejected (pack miss + base miss); \
12233             got {bad:?}"
12234        );
12235    }
12236
12237    // Load-bearing positive: a pack EntityOfType rule adds an endpoint the base contract
12238    // does not cover. The base contract has no (concept, DependsOn, concept) row (the
12239    // DependsOn rows are project/service/artifact only). A theorem->definition DependsOn
12240    // edge can therefore ONLY pass through the pack rule, proving the union is load-bearing.
12241    #[tokio::test]
12242    async fn link_depends_on_theorem_to_definition_allowed_only_via_pack_rule() {
12243        let rt = rt();
12244        let tok = NamespaceToken::local();
12245
12246        // Confirm the base contract does NOT allow concept->concept DependsOn.
12247        // (Documented here so the assertion below is not a tautology.)
12248        assert!(
12249            !base_entity_rule_allows("concept", EdgeRelation::DependsOn, "concept"),
12250            "base contract must not allow concept->concept DependsOn; \
12251             test would be vacuous if this precondition fails"
12252        );
12253
12254        // Install a single EntityOfType rule: theorem depends_on definition.
12255        // With no rules, the link would be rejected by the base contract.
12256        // With this rule, it must be accepted via the pack path (lines 1173-1179).
12257        rt.install_edge_rules(vec![EdgeEndpointRule {
12258            relation: EdgeRelation::DependsOn,
12259            source: EndpointKind::EntityOfType {
12260                kind: "concept",
12261                entity_type: "theorem",
12262            },
12263            target: EndpointKind::EntityOfType {
12264                kind: "concept",
12265                entity_type: "definition",
12266            },
12267        }]);
12268
12269        let thm = rt
12270            .create_entity(&tok, "concept", Some("theorem"), "T1", None, None, vec![])
12271            .await
12272            .unwrap();
12273        let def = rt
12274            .create_entity(
12275                &tok,
12276                "concept",
12277                Some("definition"),
12278                "D1",
12279                None,
12280                None,
12281                vec![],
12282            )
12283            .await
12284            .unwrap();
12285
12286        // This can only pass through the pack rule — the base contract rejects it.
12287        let result = rt
12288            .link(&tok, thm.id, def.id, EdgeRelation::DependsOn, 1.0, None)
12289            .await;
12290        assert!(
12291            result.is_ok(),
12292            "theorem->definition DependsOn must be allowed by the installed pack rule; \
12293             the base contract has no concept->concept DependsOn row; got {result:?}"
12294        );
12295    }
12296
12297    // ── Provenance endpoint pairs ────────────────────────────────────────────
12298    // Four base endpoint pairs: document->person and document->org (document
12299    // authorship), concept->org (concept origination by an org), and
12300    // document->document (normative document dependency). Positive links for
12301    // each pair, plus a direction-matters negative guard.
12302
12303    #[tokio::test]
12304    async fn link_document_introduced_by_person_allowed() {
12305        let rt = rt();
12306        let tok = NamespaceToken::local();
12307
12308        let doc = rt
12309            .create_entity(&tok, "document", None, "Paper", None, None, vec![])
12310            .await
12311            .unwrap();
12312        let author = rt
12313            .create_entity(&tok, "person", None, "Author", None, None, vec![])
12314            .await
12315            .unwrap();
12316
12317        let result = rt
12318            .link(
12319                &tok,
12320                doc.id,
12321                author.id,
12322                EdgeRelation::IntroducedBy,
12323                1.0,
12324                None,
12325            )
12326            .await;
12327        assert!(
12328            result.is_ok(),
12329            "document->person introduced_by must be allowed by the ADR-002 \
12330             endpoint amendment; got {result:?}"
12331        );
12332    }
12333
12334    #[tokio::test]
12335    async fn link_document_introduced_by_org_allowed() {
12336        let rt = rt();
12337        let tok = NamespaceToken::local();
12338
12339        let doc = rt
12340            .create_entity(&tok, "document", None, "Whitepaper", None, None, vec![])
12341            .await
12342            .unwrap();
12343        let org = rt
12344            .create_entity(&tok, "org", None, "Publisher", None, None, vec![])
12345            .await
12346            .unwrap();
12347
12348        let result = rt
12349            .link(&tok, doc.id, org.id, EdgeRelation::IntroducedBy, 1.0, None)
12350            .await;
12351        assert!(
12352            result.is_ok(),
12353            "document->org introduced_by must be allowed by the ADR-002 \
12354             endpoint amendment; got {result:?}"
12355        );
12356    }
12357
12358    #[tokio::test]
12359    async fn link_concept_introduced_by_org_allowed() {
12360        let rt = rt();
12361        let tok = NamespaceToken::local();
12362
12363        let concept = rt
12364            .create_entity(&tok, "concept", None, "Architecture", None, None, vec![])
12365            .await
12366            .unwrap();
12367        let org = rt
12368            .create_entity(&tok, "org", None, "Originator", None, None, vec![])
12369            .await
12370            .unwrap();
12371
12372        let result = rt
12373            .link(
12374                &tok,
12375                concept.id,
12376                org.id,
12377                EdgeRelation::IntroducedBy,
12378                1.0,
12379                None,
12380            )
12381            .await;
12382        assert!(
12383            result.is_ok(),
12384            "concept->org introduced_by must be allowed by the ADR-002 \
12385             endpoint amendment; got {result:?}"
12386        );
12387    }
12388
12389    #[tokio::test]
12390    async fn link_document_depends_on_document_allowed() {
12391        let rt = rt();
12392        let tok = NamespaceToken::local();
12393
12394        let doc_a = rt
12395            .create_entity(&tok, "document", None, "Spec A", None, None, vec![])
12396            .await
12397            .unwrap();
12398        let doc_b = rt
12399            .create_entity(&tok, "document", None, "Spec B", None, None, vec![])
12400            .await
12401            .unwrap();
12402
12403        let result = rt
12404            .link(&tok, doc_a.id, doc_b.id, EdgeRelation::DependsOn, 1.0, None)
12405            .await;
12406        assert!(
12407            result.is_ok(),
12408            "document->document depends_on must be allowed by the ADR-002 \
12409             endpoint amendment; got {result:?}"
12410        );
12411        let edge = result.unwrap();
12412        let dk = edge
12413            .metadata
12414            .as_ref()
12415            .and_then(|m| m.get("dependency_kind"))
12416            .and_then(|v| v.as_str());
12417        assert_eq!(
12418            dk,
12419            Some("normative"),
12420            "document->document depends_on must infer dependency_kind=normative"
12421        );
12422    }
12423
12424    #[tokio::test]
12425    async fn link_org_introduced_by_document_rejected_direction_matters() {
12426        let rt = rt();
12427        let tok = NamespaceToken::local();
12428
12429        let org = rt
12430            .create_entity(&tok, "org", None, "Publisher", None, None, vec![])
12431            .await
12432            .unwrap();
12433        let doc = rt
12434            .create_entity(&tok, "document", None, "Paper", None, None, vec![])
12435            .await
12436            .unwrap();
12437
12438        // The amendment adds document->org, not org->document. Direction matters:
12439        // an org is not "introduced by" a document it published.
12440        let result = rt
12441            .link(&tok, org.id, doc.id, EdgeRelation::IntroducedBy, 1.0, None)
12442            .await;
12443        assert!(
12444            result.is_err(),
12445            "org->document introduced_by must remain rejected; only \
12446             document->org is permitted, not the reverse; got {result:?}"
12447        );
12448    }
12449
12450    // ── create_note_with_embedding_content ──────────────────────────────────
12451
12452    /// Like `ConstVecService`/`ConstVecProvider` above, but records every text
12453    /// it is asked to embed so a test can assert exactly what reached the
12454    /// "provider" — used to verify the effective embed text is the capped
12455    /// override, not the full note content.
12456    struct CapturingVecService {
12457        dims: usize,
12458        captured: Arc<std::sync::Mutex<Vec<String>>>,
12459    }
12460
12461    #[async_trait]
12462    impl EmbeddingService for CapturingVecService {
12463        async fn embed(
12464            &self,
12465            texts: &[String],
12466            _model: EmbeddingModel,
12467        ) -> std::result::Result<Vec<Vec<f32>>, EmbedError> {
12468            self.captured.lock().unwrap().extend(texts.iter().cloned());
12469            Ok(texts.iter().map(|_| vec![1.0_f32; self.dims]).collect())
12470        }
12471
12472        fn supports_model(&self, _model: EmbeddingModel) -> bool {
12473            true
12474        }
12475
12476        fn name(&self) -> &'static str {
12477            "capturing-vec"
12478        }
12479    }
12480
12481    struct CapturingVecProvider {
12482        provider_name: String,
12483        dims: usize,
12484        captured: Arc<std::sync::Mutex<Vec<String>>>,
12485    }
12486
12487    #[async_trait]
12488    impl EmbedderProvider for CapturingVecProvider {
12489        fn name(&self) -> &str {
12490            &self.provider_name
12491        }
12492
12493        fn dimensions(&self) -> usize {
12494            self.dims
12495        }
12496
12497        async fn build(&self) -> crate::error::RuntimeResult<Arc<dyn EmbeddingService>> {
12498            Ok(Arc::new(CapturingVecService {
12499                dims: self.dims,
12500                captured: Arc::clone(&self.captured),
12501            }))
12502        }
12503    }
12504
12505    #[tokio::test]
12506    async fn create_note_with_embedding_content_none_matches_create_note() {
12507        let rt = rt();
12508        let tok = NamespaceToken::local();
12509        let captured = Arc::new(std::sync::Mutex::new(Vec::new()));
12510        rt.register_embedder(CapturingVecProvider {
12511            provider_name: "capturing-vec".into(),
12512            dims: 4,
12513            captured: Arc::clone(&captured),
12514        });
12515
12516        let note = rt
12517            .create_note_with_embedding_content(
12518                &tok,
12519                "observation",
12520                None,
12521                "full content, no override",
12522                None,
12523                None,
12524                None,
12525                vec![],
12526            )
12527            .await
12528            .expect("create with None override must behave like create_note");
12529        assert_eq!(note.content, "full content, no override");
12530        let seen = captured.lock().unwrap().clone();
12531        assert_eq!(
12532            seen,
12533            vec!["full content, no override".to_string()],
12534            "with no override the embedder must see the full content"
12535        );
12536    }
12537
12538    #[tokio::test]
12539    async fn create_note_with_embedding_content_embeds_capped_override_and_stores_full_content() {
12540        let rt = rt();
12541        let tok = NamespaceToken::local();
12542        let captured = Arc::new(std::sync::Mutex::new(Vec::new()));
12543        rt.register_embedder(CapturingVecProvider {
12544            provider_name: "capturing-vec".into(),
12545            dims: 4,
12546            captured: Arc::clone(&captured),
12547        });
12548
12549        let full = "head-term and then a very long tail-term that exceeds any cap";
12550        let head = &full[.."head-term and then a very long".len()];
12551
12552        let note = rt
12553            .create_note_with_embedding_content(
12554                &tok,
12555                "observation",
12556                None,
12557                full,
12558                Some(head),
12559                None,
12560                None,
12561                vec![],
12562            )
12563            .await
12564            .expect("proper-prefix override must be accepted");
12565        assert_eq!(note.content, full, "stored content must be the full text");
12566
12567        let seen = captured.lock().unwrap().clone();
12568        assert_eq!(
12569            seen,
12570            vec![head.to_string()],
12571            "embedder must see only the capped override"
12572        );
12573    }
12574
12575    #[tokio::test]
12576    async fn create_note_with_embedding_content_fans_out_identical_override_to_multiple_models() {
12577        let rt = rt();
12578        let tok = NamespaceToken::local();
12579        let captured_a = Arc::new(std::sync::Mutex::new(Vec::new()));
12580        let captured_b = Arc::new(std::sync::Mutex::new(Vec::new()));
12581        rt.register_embedder(CapturingVecProvider {
12582            provider_name: "capturing-vec-a".into(),
12583            dims: 4,
12584            captured: Arc::clone(&captured_a),
12585        });
12586        rt.register_embedder(CapturingVecProvider {
12587            provider_name: "capturing-vec-b".into(),
12588            dims: 4,
12589            captured: Arc::clone(&captured_b),
12590        });
12591
12592        let full = "head-only-embedded plus a long discarded tail";
12593        let head = &full[.."head-only-embedded".len()];
12594
12595        rt.create_note_with_embedding_content(
12596            &tok,
12597            "observation",
12598            None,
12599            full,
12600            Some(head),
12601            None,
12602            None,
12603            vec![],
12604        )
12605        .await
12606        .expect("create ok");
12607
12608        assert_eq!(
12609            captured_a.lock().unwrap().clone(),
12610            vec![head.to_string()],
12611            "model A must receive the identical capped override"
12612        );
12613        assert_eq!(
12614            captured_b.lock().unwrap().clone(),
12615            vec![head.to_string()],
12616            "model B must receive the identical capped override"
12617        );
12618
12619        // Both models actually persisted a vector row for the note (not just
12620        // an embed call that was discarded before insertion).
12621        let vs_a = rt
12622            .vectors_for_model(&tok, "capturing-vec-a")
12623            .expect("vector store for model A");
12624        assert_eq!(
12625            vs_a.count().await.expect("vector count A"),
12626            1,
12627            "model A must have exactly one vector row for the note"
12628        );
12629        let vs_b = rt
12630            .vectors_for_model(&tok, "capturing-vec-b")
12631            .expect("vector store for model B");
12632        assert_eq!(
12633            vs_b.count().await.expect("vector count B"),
12634            1,
12635            "model B must have exactly one vector row for the note"
12636        );
12637    }
12638
12639    #[tokio::test]
12640    async fn create_note_with_embedding_content_rejects_empty_override() {
12641        let rt = rt();
12642        let tok = NamespaceToken::local();
12643
12644        let err = rt
12645            .create_note_with_embedding_content(
12646                &tok,
12647                "observation",
12648                None,
12649                "some content",
12650                Some(""),
12651                None,
12652                None,
12653                vec![],
12654            )
12655            .await
12656            .expect_err("empty override must be rejected");
12657        assert!(matches!(err, RuntimeError::InvalidInput(_)));
12658    }
12659
12660    #[tokio::test]
12661    async fn create_note_with_embedding_content_rejects_non_prefix_override() {
12662        let rt = rt();
12663        let tok = NamespaceToken::local();
12664
12665        let err = rt
12666            .create_note_with_embedding_content(
12667                &tok,
12668                "observation",
12669                None,
12670                "the actual content",
12671                Some("an unrelated string"),
12672                None,
12673                None,
12674                vec![],
12675            )
12676            .await
12677            .expect_err("non-prefix override must be rejected");
12678        assert!(matches!(err, RuntimeError::InvalidInput(ref m) if m.contains("prefix")));
12679    }
12680
12681    #[tokio::test]
12682    async fn create_note_with_embedding_content_rejects_equal_length_override() {
12683        let rt = rt();
12684        let tok = NamespaceToken::local();
12685
12686        // Same length and same text as `content` is not a *proper* prefix.
12687        let err = rt
12688            .create_note_with_embedding_content(
12689                &tok,
12690                "observation",
12691                None,
12692                "identical text",
12693                Some("identical text"),
12694                None,
12695                None,
12696                vec![],
12697            )
12698            .await
12699            .expect_err("an equal-length override must be rejected as not a proper prefix");
12700        assert!(matches!(err, RuntimeError::InvalidInput(ref m) if m.contains("prefix")));
12701    }
12702
12703    #[tokio::test]
12704    async fn create_note_with_embedding_content_rejects_secret_bearing_override() {
12705        let rt = rt();
12706        let tok = NamespaceToken::local();
12707
12708        let token_span = "ghp_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
12709        let content = format!("{token_span} plus extra trailing content beyond the override");
12710        let embedding_content = format!("{token_span} plus extra");
12711
12712        let err = rt
12713            .create_note_with_embedding_content(
12714                &tok,
12715                "observation",
12716                None,
12717                &content,
12718                Some(&embedding_content),
12719                None,
12720                None,
12721                vec![],
12722            )
12723            .await
12724            .expect_err("a credential-shaped override must fail the secret gate");
12725        assert!(
12726            matches!(err, RuntimeError::SecretDetected(_)),
12727            "expected SecretDetected, got {err:?}"
12728        );
12729
12730        // Fail-closed: no note survives the rejected create.
12731        let count = rt
12732            .notes(&tok)
12733            .unwrap()
12734            .count_notes(tok.namespace().as_str(), None)
12735            .await
12736            .unwrap();
12737        assert_eq!(count, 0, "a rejected create must leave no note behind");
12738    }
12739}