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    /// Get both-direction neighbors, each tagged with the direction (`Out`/
1941    /// `In`) it was found in, via a single storage query per visible
1942    /// namespace instead of two separate direction-scoped `neighbors_with_query`
1943    /// calls: halving the neighbor SELECT count for `context(direction="both")`
1944    /// expansion. `query.direction` is ignored: always both.
1945    ///
1946    /// Mirrors `neighbors_with_query`'s dedup/enrich/soft-delete-filter/order
1947    /// pipeline exactly, carrying the per-hit direction tag through unchanged.
1948    pub async fn neighbors_with_query_directed(
1949        &self,
1950        token: &NamespaceToken,
1951        node_id: Uuid,
1952        query: NeighborQuery,
1953    ) -> RuntimeResult<Vec<(NeighborHit, Direction)>> {
1954        if !self.substrate_exists_in_ns(token, node_id).await? {
1955            return Ok(Vec::new());
1956        }
1957
1958        let mut hits: Vec<DirectedNeighborHit> = Vec::new();
1959        for ns in token.visible_namespaces() {
1960            let temp = NamespaceToken::for_namespace(ns.clone());
1961            let mut ns_hits = self
1962                .graph(&temp)?
1963                .neighbors_both_directions(node_id, query.clone())
1964                .await?;
1965            hits.append(&mut ns_hits);
1966        }
1967        // Direction is part of the key (not just node_id/edge_id) so a
1968        // self-loop's Out row and In row — same node_id and edge_id, opposite
1969        // direction: sort adjacent but distinct and both survive dedup.
1970        hits.sort_by_key(|h| {
1971            (
1972                h.hit.node_id,
1973                h.hit.edge_id,
1974                direction_sort_rank(&h.direction),
1975            )
1976        });
1977        hits.dedup_by_key(|h| {
1978            (
1979                h.hit.node_id,
1980                h.hit.edge_id,
1981                direction_sort_rank(&h.direction),
1982            )
1983        });
1984
1985        let mut plain_hits: Vec<NeighborHit> = hits.iter().map(|h| h.hit.clone()).collect();
1986        self.enrich_neighbor_hits(token, &mut plain_hits).await;
1987        for (dh, enriched) in hits.iter_mut().zip(plain_hits) {
1988            dh.hit = enriched;
1989        }
1990
1991        // Filter out soft-deleted entity nodes.
1992        let candidate_ids: Vec<Uuid> = hits.iter().map(|h| h.hit.node_id).collect();
1993        let deleted = self.deleted_entity_ids(candidate_ids).await;
1994        if !deleted.is_empty() {
1995            hits.retain(|h| !deleted.contains(&h.hit.node_id));
1996        }
1997        // Same global weight-descending/node_id-ascending restore as
1998        // `neighbors_with_query`: the (node_id, edge_id, direction) sort above
1999        // exists only to make `dedup_by_key` adjacent-comparable.
2000        hits.sort_by(|a, b| {
2001            b.hit
2002                .weight
2003                .partial_cmp(&a.hit.weight)
2004                .unwrap_or(std::cmp::Ordering::Equal)
2005                .then(a.hit.node_id.cmp(&b.hit.node_id))
2006        });
2007        Ok(hits.into_iter().map(|h| (h.hit, h.direction)).collect())
2008    }
2009
2010    /// Traverse the graph from a set of root nodes.
2011    ///
2012    /// Roots in a foreign namespace are silently filtered before storage expansion.
2013    /// Soft-deleted entity nodes are excluded from results.
2014    pub async fn traverse(
2015        &self,
2016        token: &NamespaceToken,
2017        request: TraversalRequest,
2018    ) -> RuntimeResult<Vec<GraphPath>> {
2019        let mut request = request;
2020        let mut visible_roots = Vec::with_capacity(request.roots.len());
2021        for root in request.roots.drain(..) {
2022            if self.substrate_exists_in_ns(token, root).await? {
2023                visible_roots.push(root);
2024            }
2025        }
2026        request.roots = visible_roots;
2027        if request.roots.is_empty() {
2028            return Ok(Vec::new());
2029        }
2030
2031        let mut paths = Vec::new();
2032        for ns in token.visible_namespaces() {
2033            let temp = NamespaceToken::for_namespace(ns.clone());
2034            let mut ns_paths = self.graph(&temp)?.traverse(request.clone()).await?;
2035            paths.append(&mut ns_paths);
2036        }
2037        self.enrich_path_nodes(token, &mut paths, request.include_properties)
2038            .await;
2039        // Filter out soft-deleted entity nodes from all path nodes.
2040        let all_node_ids: Vec<Uuid> = paths
2041            .iter()
2042            .flat_map(|p| p.nodes.iter().map(|n| n.node_id))
2043            .collect();
2044        let deleted = self.deleted_entity_ids(all_node_ids).await;
2045        if !deleted.is_empty() {
2046            for path in paths.iter_mut() {
2047                path.nodes.retain(|n| !deleted.contains(&n.node_id));
2048            }
2049            paths.retain(|p| !p.nodes.is_empty());
2050        }
2051        Ok(paths)
2052    }
2053
2054    /// Batch-query for soft-deleted UUIDs in `ids`, across BOTH the entities
2055    /// and notes tables.
2056    ///
2057    /// Neighbor/traverse candidates can be note-kind nodes (e.g. reached via
2058    /// `annotates` edges) as well as entities; a screen that only consults
2059    /// `entities` lets soft-deleted note targets leak through and hydrate as
2060    /// blank/missing hits. This is a view-layer read-only screen: it does
2061    /// not touch edges or mutate any data.
2062    ///
2063    /// Returns the subset of `ids` that have `deleted_at IS NOT NULL` in
2064    /// either table. Takes `Vec<Uuid>` (not an iterator) so the async state
2065    /// machine holds only owned data — no iterator borrow across yields.
2066    async fn deleted_entity_ids(&self, ids: Vec<Uuid>) -> std::collections::HashSet<Uuid> {
2067        if ids.is_empty() {
2068            return std::collections::HashSet::new();
2069        }
2070        let id_strs: Vec<String> = ids.iter().map(|u| u.to_string()).collect();
2071        let n = id_strs.len();
2072        // Each UNION half gets its OWN numbered-placeholder block (?1..?n for
2073        // entities, ?(n+1)..?(2n) for notes) — numbered SQLite params bind by
2074        // index, so reusing the same numbers across halves would silently
2075        // collapse to a single shared block instead of binding the full list
2076        // twice (see khive-db/src/stores/graph.rs batch_neighbors: "each half
2077        // is a fully independent positional-parameter block").
2078        let entities_placeholders = (0..n)
2079            .map(|i| format!("?{}", i + 1))
2080            .collect::<Vec<_>>()
2081            .join(",");
2082        let notes_placeholders = (0..n)
2083            .map(|i| format!("?{}", n + i + 1))
2084            .collect::<Vec<_>>()
2085            .join(",");
2086        let sql_str = format!(
2087            "SELECT id FROM entities WHERE id IN ({entities_placeholders}) AND deleted_at IS NOT NULL \
2088             UNION \
2089             SELECT id FROM notes WHERE id IN ({notes_placeholders}) AND deleted_at IS NOT NULL"
2090        );
2091        // Same id list bound twice — once per UNION arm's independent placeholder block.
2092        let params: Vec<SqlValue> = id_strs
2093            .iter()
2094            .chain(id_strs.iter())
2095            .cloned()
2096            .map(SqlValue::Text)
2097            .collect();
2098        let stmt = SqlStatement {
2099            sql: sql_str,
2100            params,
2101            label: Some("deleted_entity_ids".into()),
2102        };
2103        let mut out = std::collections::HashSet::new();
2104        let sql = self.sql();
2105        if let Ok(mut reader) = sql.reader().await {
2106            if let Ok(rows) = reader.query_all(stmt).await {
2107                for row in rows {
2108                    if let Some(col) = row.columns.first() {
2109                        if let SqlValue::Text(s) = &col.value {
2110                            if let Ok(u) = s.parse::<Uuid>() {
2111                                out.insert(u);
2112                            }
2113                        }
2114                    }
2115                }
2116            }
2117            // best-effort: on reader or query error, treat none as deleted
2118        }
2119        out
2120    }
2121
2122    /// Populate `name` and `kind` on each `NeighborHit` from the corresponding
2123    /// entity or note record. Best-effort: unresolved IDs leave the fields `None`.
2124    ///
2125    /// Uses a single batched entity lookup via `get_entities_by_ids_visible`
2126    /// (scoped to the token's full visible-namespace set so that neighbors in
2127    /// extra-visible namespaces are enriched), then a batched note lookup
2128    /// (`get_notes_batch`) for the residual IDs not resolved as entities.
2129    /// Order and identity of hits is preserved via `HashMap` re-index.
2130    async fn enrich_neighbor_hits(&self, token: &NamespaceToken, hits: &mut [NeighborHit]) {
2131        if hits.is_empty() {
2132            return;
2133        }
2134
2135        // Deduplicated IDs for the batch call.
2136        let unique_ids: Vec<Uuid> = {
2137            let mut seen = std::collections::HashSet::new();
2138            hits.iter()
2139                .filter_map(|h| {
2140                    if seen.insert(h.node_id) {
2141                        Some(h.node_id)
2142                    } else {
2143                        None
2144                    }
2145                })
2146                .collect()
2147        };
2148
2149        let entity_map: HashMap<Uuid, Entity> = self
2150            .get_entities_by_ids_visible(token, &unique_ids)
2151            .await
2152            .unwrap_or_default()
2153            .into_iter()
2154            .map(|e| (e.id, e))
2155            .collect();
2156
2157        // Batch note lookup for IDs not found as entities.
2158        let residual_ids: Vec<Uuid> = unique_ids
2159            .iter()
2160            .filter(|id| !entity_map.contains_key(id))
2161            .copied()
2162            .collect();
2163
2164        let note_map: HashMap<Uuid, Note> = if !residual_ids.is_empty() {
2165            if let Ok(store) = self.notes(token) {
2166                store
2167                    .get_notes_batch(&residual_ids)
2168                    .await
2169                    .unwrap_or_default()
2170                    .into_iter()
2171                    .map(|n| (n.id, n))
2172                    .collect()
2173            } else {
2174                HashMap::new()
2175            }
2176        } else {
2177            HashMap::new()
2178        };
2179
2180        for hit in hits.iter_mut() {
2181            if let Some(entity) = entity_map.get(&hit.node_id) {
2182                hit.name = Some(entity.name.clone());
2183                hit.kind = Some(entity.kind.clone());
2184                hit.entity_type = entity.entity_type.clone();
2185            } else if let Some(note) = note_map.get(&hit.node_id) {
2186                let kind = note.kind.clone();
2187                let name = note
2188                    .name
2189                    .as_deref()
2190                    .filter(|s| !s.trim().is_empty())
2191                    .map(|s| s.to_owned())
2192                    .unwrap_or_else(|| format!("[{kind}]"));
2193                hit.name = Some(name);
2194                hit.kind = Some(kind);
2195            }
2196        }
2197    }
2198
2199    /// Populate `name` and `kind` on each `PathNode` from the corresponding
2200    /// entity record. Same best-effort policy as `enrich_neighbor_hits`.
2201    ///
2202    /// Uses `get_entities_by_ids_visible` so that path nodes whose entities
2203    /// live in extra-visible namespaces are enriched correctly. Node IDs that
2204    /// repeat across paths are fetched exactly once.
2205    ///
2206    /// `include_properties` gates whether `entity.properties` is cloned onto
2207    /// each node. When `false` (the default), the potentially large JSON blob
2208    /// is never read from the map, keeping the hot path allocation-free.
2209    async fn enrich_path_nodes(
2210        &self,
2211        token: &NamespaceToken,
2212        paths: &mut [GraphPath],
2213        include_properties: bool,
2214    ) {
2215        if paths.is_empty() {
2216            return;
2217        }
2218
2219        // Deduplicate node IDs across all paths before the batch call.
2220        let unique_ids: Vec<Uuid> = {
2221            let mut seen = std::collections::HashSet::new();
2222            paths
2223                .iter()
2224                .flat_map(|p| p.nodes.iter())
2225                .filter_map(|n| {
2226                    if seen.insert(n.node_id) {
2227                        Some(n.node_id)
2228                    } else {
2229                        None
2230                    }
2231                })
2232                .collect()
2233        };
2234
2235        let entity_map: HashMap<Uuid, Entity> = self
2236            .get_entities_by_ids_visible(token, &unique_ids)
2237            .await
2238            .unwrap_or_default()
2239            .into_iter()
2240            .map(|e| (e.id, e))
2241            .collect();
2242
2243        for path in paths.iter_mut() {
2244            for node in path.nodes.iter_mut() {
2245                if let Some(entity) = entity_map.get(&node.node_id) {
2246                    node.name = Some(entity.name.clone());
2247                    node.kind = Some(entity.kind.clone());
2248                    if include_properties {
2249                        node.properties = entity.properties.clone();
2250                    }
2251                }
2252            }
2253        }
2254    }
2255
2256    // ---- Note operations ----
2257
2258    /// Create and persist a note, optionally with properties and annotation targets.
2259    ///
2260    /// After creating the note:
2261    /// - Always indexes into FTS5 at the `notes_<namespace>` key.
2262    /// - If an embedding model is configured, indexes into the vector store with
2263    ///   `SubstrateKind::Note`.
2264    /// - For each UUID in `annotates`, creates an `EdgeRelation::Annotates` edge from
2265    ///   the note to that target.
2266    // REASON: note creation requires kind, name, content, salience, properties, annotates,
2267    // and namespace token — mirrors the MCP verb surface; a builder would not reduce
2268    // caller complexity for pack handler callers.
2269    #[allow(clippy::too_many_arguments)]
2270    pub async fn create_note(
2271        &self,
2272        token: &NamespaceToken,
2273        kind: &str,
2274        name: Option<&str>,
2275        content: &str,
2276        salience: Option<f64>,
2277        properties: Option<serde_json::Value>,
2278        annotates: Vec<Uuid>,
2279    ) -> RuntimeResult<Note> {
2280        self.create_note_inner(
2281            token, kind, name, content, None, salience, None, properties, annotates, None,
2282        )
2283        .await
2284    }
2285
2286    /// Like [`Self::create_note`], but lets the caller supply a smaller text
2287    /// to send to the vector embedder while the note's stored/FTS-indexed
2288    /// `content` remains the full text.
2289    ///
2290    /// `embedding_content`, when `Some`, must be non-empty and a proper
2291    /// prefix of `content` — anything else is rejected with `InvalidInput`
2292    /// before any write. `None` behaves exactly like [`Self::create_note`].
2293    /// Use this when `content` may exceed an embedder's input cap (e.g. a
2294    /// very long commit message) and only a capped head prefix should be
2295    /// embedded, while the full text is still stored and searchable via FTS.
2296    #[allow(clippy::too_many_arguments)]
2297    pub async fn create_note_with_embedding_content(
2298        &self,
2299        token: &NamespaceToken,
2300        kind: &str,
2301        name: Option<&str>,
2302        content: &str,
2303        embedding_content: Option<&str>,
2304        salience: Option<f64>,
2305        properties: Option<serde_json::Value>,
2306        annotates: Vec<Uuid>,
2307    ) -> RuntimeResult<Note> {
2308        self.create_note_inner(
2309            token,
2310            kind,
2311            name,
2312            content,
2313            embedding_content,
2314            salience,
2315            None,
2316            properties,
2317            annotates,
2318            None,
2319        )
2320        .await
2321    }
2322
2323    /// Like [`Self::create_note`] but also sets a non-zero decay factor on the note.
2324    // REASON: extends create_note with an additional decay_factor parameter; same
2325    // rationale — mirrors the MCP surface and reduces an extra builder layer.
2326    #[allow(clippy::too_many_arguments)]
2327    pub async fn create_note_with_decay(
2328        &self,
2329        token: &NamespaceToken,
2330        kind: &str,
2331        name: Option<&str>,
2332        content: &str,
2333        salience: Option<f64>,
2334        decay_factor: f64,
2335        properties: Option<serde_json::Value>,
2336        annotates: Vec<Uuid>,
2337    ) -> RuntimeResult<Note> {
2338        self.create_note_with_decay_for_embedding_model(
2339            token,
2340            kind,
2341            name,
2342            content,
2343            salience,
2344            decay_factor,
2345            properties,
2346            annotates,
2347            None,
2348        )
2349        .await
2350    }
2351
2352    /// Like [`Self::create_note_with_decay`] but targets a specific embedding model.
2353    // REASON: adds an embedding_model parameter to the decay variant; the full parameter
2354    // set is required for correct MCP verb routing and cannot be collapsed without
2355    // introducing a separate config struct that would obscure call sites.
2356    #[allow(clippy::too_many_arguments)]
2357    pub async fn create_note_with_decay_for_embedding_model(
2358        &self,
2359        token: &NamespaceToken,
2360        kind: &str,
2361        name: Option<&str>,
2362        content: &str,
2363        salience: Option<f64>,
2364        decay_factor: f64,
2365        properties: Option<serde_json::Value>,
2366        annotates: Vec<Uuid>,
2367        embedding_model: Option<&str>,
2368    ) -> RuntimeResult<Note> {
2369        self.create_note_inner(
2370            token,
2371            kind,
2372            name,
2373            content,
2374            None,
2375            salience,
2376            Some(decay_factor),
2377            properties,
2378            annotates,
2379            embedding_model,
2380        )
2381        .await
2382    }
2383
2384    /// Insert a note using `INSERT OR IGNORE` semantics for atomic deduplication.
2385    ///
2386    /// Returns `Ok(Some(note))` when the note was newly written.  Returns
2387    /// `Ok(None)` when a unique constraint (e.g. the `external_id` partial
2388    /// index on comm message notes) was already satisfied by an existing row,
2389    /// making this call a no-op.  FTS indexing and vector embedding are
2390    /// attempted on success but treated as best-effort: failures are logged
2391    /// and do not abort the write.
2392    ///
2393    /// This method is intentionally narrower than `create_note`: it skips
2394    /// salience/decay, annotates edges, and embedding-model selection, which
2395    /// are not needed for channel-ingest paths.
2396    pub async fn try_create_note(
2397        &self,
2398        token: &NamespaceToken,
2399        kind: &str,
2400        name: Option<&str>,
2401        content: &str,
2402        properties: Option<serde_json::Value>,
2403    ) -> RuntimeResult<Option<Note>> {
2404        self.validate_note_kind(kind)?;
2405        crate::secret_gate::check(content)?;
2406        if let Some(n) = name {
2407            crate::secret_gate::check(n)?;
2408        }
2409        if let Some(ref p) = properties {
2410            crate::secret_gate::check_json(p)?;
2411        }
2412
2413        let ns = token.namespace().as_str();
2414        let mut note = Note::new(ns, kind, content);
2415        if let Some(n) = name {
2416            note = note.with_name(n);
2417        }
2418        if let Some(p) = properties {
2419            note = note.with_properties(p);
2420        }
2421
2422        let inserted = self.notes(token)?.try_insert_note(note.clone()).await?;
2423        if !inserted {
2424            return Ok(None);
2425        }
2426
2427        // Best-effort FTS: log and continue on failure.
2428        if let Ok(fts) = self.text_for_notes(token) {
2429            if let Err(e) = fts.upsert_document(note_fts_document(&note)).await {
2430                tracing::warn!(
2431                    note_id = %note.id,
2432                    error = %e,
2433                    "try_create_note: FTS indexing failed (non-fatal)"
2434                );
2435            }
2436        }
2437
2438        // Best-effort vector embedding: log and continue on failure.
2439        let embed_model_names = self.registered_embedding_model_names();
2440        for model_name in &embed_model_names {
2441            match self
2442                .embed_document_with_model(model_name, &note.content)
2443                .await
2444            {
2445                Ok(vector) => {
2446                    if let Ok(vs) = self.vectors_for_model(token, model_name) {
2447                        if let Err(e) = vs
2448                            .insert(
2449                                note.id,
2450                                SubstrateKind::Note,
2451                                ns,
2452                                "note.content",
2453                                vec![vector],
2454                            )
2455                            .await
2456                        {
2457                            tracing::warn!(
2458                                note_id = %note.id,
2459                                model = %model_name,
2460                                error = %e,
2461                                "try_create_note: vector insert failed (non-fatal)"
2462                            );
2463                        }
2464                    }
2465                }
2466                Err(e) => {
2467                    tracing::warn!(
2468                        note_id = %note.id,
2469                        model = %model_name,
2470                        error = %e,
2471                        "try_create_note: embedding failed (non-fatal)"
2472                    );
2473                }
2474            }
2475        }
2476
2477        Ok(Some(note))
2478    }
2479
2480    // REASON: private inner function unifies all create_note variants; it receives every
2481    // optional parameter individually so that public variants can pass None without
2482    // requiring callers to construct an intermediate struct.
2483    #[allow(clippy::too_many_arguments)]
2484    async fn create_note_inner(
2485        &self,
2486        token: &NamespaceToken,
2487        kind: &str,
2488        name: Option<&str>,
2489        content: &str,
2490        embedding_content: Option<&str>,
2491        salience: Option<f64>,
2492        decay_factor: Option<f64>,
2493        properties: Option<serde_json::Value>,
2494        annotates: Vec<Uuid>,
2495        embedding_model: Option<&str>,
2496    ) -> RuntimeResult<Note> {
2497        self.validate_note_kind(kind)?;
2498        // Secret gate: scan content, optional name, and structured properties.
2499        crate::secret_gate::check(content)?;
2500        if let Some(n) = name {
2501            crate::secret_gate::check(n)?;
2502        }
2503        if let Some(ref p) = properties {
2504            crate::secret_gate::check_json(p)?;
2505        }
2506        // `embedding_content` is a caller-supplied alternate vector-embedding
2507        // input: it must be a non-empty proper prefix of `content` (never a
2508        // superset, an unrelated string, or the full text) and passes the
2509        // same secret gate as any other stored/embedded text. Rejected
2510        // before any write, same as the checks above.
2511        if let Some(ec) = embedding_content {
2512            if ec.is_empty() {
2513                return Err(RuntimeError::InvalidInput(
2514                    "embedding_content must not be empty".into(),
2515                ));
2516            }
2517            if ec.len() >= content.len() || !content.starts_with(ec) {
2518                return Err(RuntimeError::InvalidInput(
2519                    "embedding_content must be a proper prefix of content".into(),
2520                ));
2521            }
2522            crate::secret_gate::check(ec)?;
2523        }
2524        let ns = token.namespace().as_str();
2525
2526        // Validate all annotates targets before any write (atomicity: all-or-nothing).
2527        // Endpoint resolution is by-ID and namespace-agnostic.
2528        for &target_id in &annotates {
2529            if !self.substrate_exists_by_id(token, target_id).await? {
2530                return Err(RuntimeError::NotFound(format!(
2531                    "create_note annotates target {target_id} not found"
2532                )));
2533            }
2534        }
2535
2536        // Reject non-finite or out-of-range salience/decay at the runtime boundary
2537        // rather than letting storage silently clamp them (coding-standards §508-516).
2538        if let Some(s) = salience {
2539            if !s.is_finite() || !(0.0..=1.0).contains(&s) {
2540                return Err(RuntimeError::InvalidInput(format!(
2541                    "salience must be a finite value in [0.0, 1.0]; got {s}"
2542                )));
2543            }
2544        }
2545        if let Some(d) = decay_factor {
2546            if !d.is_finite() || d < 0.0 {
2547                return Err(RuntimeError::InvalidInput(format!(
2548                    "decay_factor must be a finite value >= 0.0; got {d}"
2549                )));
2550            }
2551        }
2552
2553        // Resolve embedding_model BEFORE any note/FTS/vector write so unknown-model
2554        // errors are atomic at the runtime layer, not just at one pack handler.
2555        // Direct Rust callers (other packs, integration tests) get the same guarantee.
2556        if let Some(model_name) = embedding_model {
2557            self.resolve_embedding_model(Some(model_name))?;
2558        }
2559
2560        let mut note = Note::new(ns, kind, content);
2561        if let Some(s) = salience {
2562            note = note.with_salience(s);
2563        }
2564        if let Some(df) = decay_factor {
2565            note = note.with_decay(df);
2566        }
2567        if let Some(n) = name {
2568            note = note.with_name(n);
2569        }
2570        if let Some(p) = properties {
2571            note = note.with_properties(p);
2572        }
2573        self.notes(token)?.upsert_note(note.clone()).await?;
2574
2575        // From here on, any error must compensate by removing the note row, its
2576        // FTS document, and any vector entries already inserted — the same
2577        // cleanup used by the annotates-edge block below.
2578
2579        // Decide which embedding models to use (before touching FTS/vectors).
2580        let embed_model_names: Vec<String> = if let Some(m) = embedding_model {
2581            vec![m.to_string()]
2582        } else {
2583            // Fan out to ALL registered models — includes both lattice models
2584            // from RuntimeConfig and any custom providers added via
2585            // register_embedder(). Gate on the registry, not
2586            // config().embedding_model, so that custom-only runtimes (no
2587            // lattice model in config) also fan out.
2588            let names = self.registered_embedding_model_names();
2589            if names.is_empty() {
2590                // No models configured at all — skip vector embedding.
2591                vec![]
2592            } else {
2593                names
2594            }
2595        };
2596
2597        // FTS step — compensate note row on failure.
2598        {
2599            // Injection: check FTS_FAIL_NS (armed by `arm_fts_fail(ns)`).
2600            // Fires only when the armed namespace matches this note's namespace,
2601            // then clears (one-shot).  No lock acquisition in release builds —
2602            // the cfg(not) branch is a const false so the compiler eliminates
2603            // the if-branch entirely.
2604            #[cfg(any(test, feature = "fault-injection"))]
2605            let fts_inject = {
2606                let mut g = FTS_FAIL_NS.lock().unwrap();
2607                if g.as_deref() == Some(ns) {
2608                    *g = None;
2609                    true
2610                } else {
2611                    false
2612                }
2613            };
2614            #[cfg(not(any(test, feature = "fault-injection")))]
2615            let fts_inject = false;
2616            let fts_result: RuntimeResult<()> = if fts_inject {
2617                Err(RuntimeError::Internal("injected FTS failure".to_string()))
2618            } else {
2619                match self.text_for_notes(token) {
2620                    Ok(fts) => fts
2621                        .upsert_document(note_fts_document(&note))
2622                        .await
2623                        .map_err(RuntimeError::from),
2624                    Err(e) => Err(e),
2625                }
2626            };
2627
2628            if let Err(e) = fts_result {
2629                // Best-effort compensation — ignore cleanup errors.
2630                if let Ok(store) = self.notes(token) {
2631                    let _ = store.delete_note(note.id, DeleteMode::Hard).await;
2632                }
2633                return Err(e);
2634            }
2635        }
2636
2637        // Vector embedding + insert step — compensate note row + FTS doc on failure.
2638        // Multi-model vector embedding:
2639        //   - explicit embedding_model → single model (existing behaviour)
2640        //   - None + any models registered → ALL registered models in parallel
2641        //   - None + no models configured → skip (text-only)
2642        // The effective text sent to every embedder: the caller-supplied
2643        // capped override when present, otherwise the full stored content.
2644        // FTS indexing above always used the full `note.content` — this cap
2645        // affects only the vector-embedding input.
2646        let embed_text: &str = embedding_content.unwrap_or(content);
2647
2648        if embed_model_names.len() == 1 {
2649            // Single-model path: preserves original sequential behaviour.
2650            let model_name = &embed_model_names[0];
2651            let vec_result = self.embed_document_with_model(model_name, embed_text).await;
2652
2653            // Injection: check VECTOR_FAIL_NS (armed by `arm_vector_fail(ns)`) or
2654            // VECTOR_FAIL_AFTER (armed by `arm_vector_fail_after(n)`). The former
2655            // fires only when the armed namespace matches this note's namespace;
2656            // callers that cannot guarantee no concurrently-running test also
2657            // writes a note into that same namespace (e.g. a test suite whose
2658            // fixtures share one default namespace) should prefer the latter,
2659            // thread-local count instead — see its doc comment. Either clears
2660            // (one-shot) once it fires. No lock/cell access in release builds —
2661            // the cfg(not) branch is a const false eliminating the if-branch.
2662            #[cfg(any(test, feature = "fault-injection"))]
2663            let vec_inject = {
2664                let ns_inject = {
2665                    let mut g = VECTOR_FAIL_NS.lock().unwrap();
2666                    if g.as_deref() == Some(ns) {
2667                        *g = None;
2668                        true
2669                    } else {
2670                        false
2671                    }
2672                };
2673                let count_inject = VECTOR_FAIL_AFTER.with(|cell| match cell.get() {
2674                    Some(0) => {
2675                        cell.set(None);
2676                        true
2677                    }
2678                    Some(n) => {
2679                        cell.set(Some(n - 1));
2680                        false
2681                    }
2682                    None => false,
2683                });
2684                ns_inject || count_inject
2685            };
2686            #[cfg(not(any(test, feature = "fault-injection")))]
2687            let vec_inject = false;
2688            let vec_result: RuntimeResult<Vec<f32>> = if vec_inject {
2689                Err(RuntimeError::Internal(
2690                    "injected vector failure".to_string(),
2691                ))
2692            } else {
2693                vec_result
2694            };
2695
2696            let single_model_result: RuntimeResult<()> = match vec_result {
2697                Ok(vector) => match self.vectors_for_model(token, model_name) {
2698                    Ok(vs) => vs
2699                        .insert(
2700                            note.id,
2701                            SubstrateKind::Note,
2702                            ns,
2703                            "note.content",
2704                            vec![vector],
2705                        )
2706                        .await
2707                        .map_err(RuntimeError::from),
2708                    Err(e) => Err(e),
2709                },
2710                Err(e) => Err(e),
2711            };
2712            if let Err(e) = single_model_result {
2713                // Compensate note row + FTS.
2714                if let Ok(store) = self.notes(token) {
2715                    let _ = store.delete_note(note.id, DeleteMode::Hard).await;
2716                }
2717                if let Ok(fts) = self.text_for_notes(token) {
2718                    let _ = fts.delete_document(ns, note.id).await;
2719                }
2720                return Err(e);
2721            }
2722        } else if !embed_model_names.is_empty() {
2723            // Multi-model path: embed with each model in parallel via spawned tasks,
2724            // then insert one VectorRecord per model.
2725            let rt_clone = self.clone();
2726            let content_owned = embed_text.to_string();
2727            let mut handles = Vec::with_capacity(embed_model_names.len());
2728            for model_name in &embed_model_names {
2729                let rt = rt_clone.clone();
2730                let text = content_owned.clone();
2731                let name = model_name.clone();
2732                handles.push(tokio::spawn(async move {
2733                    rt.embed_document_with_model(&name, &text).await
2734                }));
2735            }
2736            let mut vectors: Vec<Vec<f32>> = Vec::with_capacity(embed_model_names.len());
2737            for handle in handles {
2738                let join_result = handle
2739                    .await
2740                    .map_err(|e| RuntimeError::Internal(format!("embed task panicked: {e}")));
2741                match join_result {
2742                    Err(e) => {
2743                        // Compensate note row + FTS (no vectors inserted yet).
2744                        if let Ok(store) = self.notes(token) {
2745                            let _ = store.delete_note(note.id, DeleteMode::Hard).await;
2746                        }
2747                        if let Ok(fts) = self.text_for_notes(token) {
2748                            let _ = fts.delete_document(ns, note.id).await;
2749                        }
2750                        return Err(e);
2751                    }
2752                    Ok(Err(e)) => {
2753                        // Embed call failed — compensate note row + FTS.
2754                        if let Ok(store) = self.notes(token) {
2755                            let _ = store.delete_note(note.id, DeleteMode::Hard).await;
2756                        }
2757                        if let Ok(fts) = self.text_for_notes(token) {
2758                            let _ = fts.delete_document(ns, note.id).await;
2759                        }
2760                        return Err(e);
2761                    }
2762                    Ok(Ok(vec)) => vectors.push(vec),
2763                }
2764            }
2765            // TODO(P2): parallelize vector inserts
2766            let mut inserted_models: Vec<String> = Vec::with_capacity(embed_model_names.len());
2767            for (model_name, vector) in embed_model_names.iter().zip(vectors) {
2768                let insert_result = match self.vectors_for_model(token, model_name) {
2769                    Ok(vs) => vs
2770                        .insert(
2771                            note.id,
2772                            SubstrateKind::Note,
2773                            ns,
2774                            "note.content",
2775                            vec![vector],
2776                        )
2777                        .await
2778                        .map_err(RuntimeError::from),
2779                    Err(e) => Err(e),
2780                };
2781                if let Err(e) = insert_result {
2782                    // Compensate note row + FTS + already-inserted vectors.
2783                    if let Ok(store) = self.notes(token) {
2784                        let _ = store.delete_note(note.id, DeleteMode::Hard).await;
2785                    }
2786                    if let Ok(fts) = self.text_for_notes(token) {
2787                        let _ = fts.delete_document(ns, note.id).await;
2788                    }
2789                    for m in &inserted_models {
2790                        if let Ok(vs) = self.vectors_for_model(token, m) {
2791                            let _ = vs.delete(note.id).await;
2792                        }
2793                    }
2794                    return Err(e);
2795                }
2796                inserted_models.push(model_name.clone());
2797            }
2798        }
2799
2800        // Create annotates edges, compensating on failure to preserve atomicity.
2801        //
2802        // Pre-validation (above) ensures all targets exist, so link failures are
2803        // unexpected. If one occurs: delete any edges already created, then remove
2804        // the note, its FTS document, and its vector entry.
2805        let mut created_edges: Vec<Uuid> = Vec::with_capacity(annotates.len());
2806
2807        // In test builds, iterate with an index so the failure-injection hook can
2808        // target a specific call.  In release builds, skip the enumerate overhead.
2809        #[cfg(test)]
2810        let annotates_iter: Vec<(usize, Uuid)> = annotates
2811            .iter()
2812            .enumerate()
2813            .map(|(i, &id)| (i, id))
2814            .collect();
2815        #[cfg(test)]
2816        macro_rules! next_target {
2817            ($pair:expr) => {
2818                $pair.1
2819            };
2820        }
2821        #[cfg(not(test))]
2822        let annotates_iter: Vec<Uuid> = annotates.to_vec();
2823        #[cfg(not(test))]
2824        macro_rules! next_target {
2825            ($pair:expr) => {
2826                $pair
2827            };
2828        }
2829
2830        for pair in annotates_iter {
2831            let target_id = next_target!(pair);
2832
2833            // Test-only: inject a failure on the configured call index (1-based).
2834            #[cfg(test)]
2835            let injected_err: Option<RuntimeError> = {
2836                let call_idx = pair.0;
2837                LINK_FAIL_AFTER.with(|cell| {
2838                    let n = cell.get();
2839                    if n > 0 && call_idx + 1 == n {
2840                        cell.set(0); // reset so subsequent calls are unaffected
2841                        Some(RuntimeError::Internal("injected link failure".to_string()))
2842                    } else {
2843                        None
2844                    }
2845                })
2846            };
2847            #[cfg(not(test))]
2848            let injected_err: Option<RuntimeError> = None;
2849
2850            let link_result = if let Some(e) = injected_err {
2851                Err(e)
2852            } else {
2853                self.link(
2854                    token,
2855                    note.id,
2856                    target_id,
2857                    EdgeRelation::Annotates,
2858                    1.0,
2859                    None,
2860                )
2861                .await
2862            };
2863
2864            match link_result {
2865                Ok(edge) => created_edges.push(edge.id.into()),
2866                Err(e) => {
2867                    // Best-effort compensation — ignore cleanup errors.
2868                    for edge_id in created_edges {
2869                        let _ = self.delete_edge(token, edge_id, true).await;
2870                    }
2871                    if let Ok(store) = self.notes(token) {
2872                        let _ = store.delete_note(note.id, DeleteMode::Hard).await;
2873                    }
2874                    if let Ok(fts) = self.text_for_notes(token) {
2875                        let _ = fts.delete_document(ns, note.id).await;
2876                    }
2877                    for model_name in &embed_model_names {
2878                        if let Ok(vs) = self.vectors_for_model(token, model_name) {
2879                            let _ = vs.delete(note.id).await;
2880                        }
2881                    }
2882                    return Err(e);
2883                }
2884            }
2885        }
2886
2887        Ok(note)
2888    }
2889
2890    /// List notes visible to the token, optionally filtered by kind.
2891    ///
2892    /// When the token carries a multi-namespace visible set, notes from all
2893    /// visible namespaces are returned. When the visible set is `[primary]`
2894    /// (the default) this behaves identically to the pre-visibility behaviour.
2895    pub async fn list_notes(
2896        &self,
2897        token: &NamespaceToken,
2898        kind: Option<&str>,
2899        limit: u32,
2900        offset: u32,
2901    ) -> RuntimeResult<Vec<Note>> {
2902        let visible = token.visible_namespaces();
2903        if visible.len() == 1 {
2904            // Fast path: single namespace — use the dedicated query_notes method.
2905            let page = self
2906                .notes(token)?
2907                .query_notes(
2908                    token.namespace().as_str(),
2909                    kind,
2910                    PageRequest {
2911                        offset: offset.into(),
2912                        limit,
2913                    },
2914                )
2915                .await?;
2916            return Ok(page.items);
2917        }
2918        // Multi-namespace path: use query_notes_filtered with the visible set.
2919        use khive_storage::note::NoteFilter;
2920        let ns_strs: Vec<String> = visible.iter().map(|ns| ns.as_str().to_owned()).collect();
2921        let filter = NoteFilter {
2922            kind: kind.map(|k| k.to_string()),
2923            namespaces: ns_strs,
2924            ..Default::default()
2925        };
2926        let page = self
2927            .notes(token)?
2928            .query_notes_filtered(
2929                token.namespace().as_str(),
2930                &filter,
2931                PageRequest {
2932                    offset: offset.into(),
2933                    limit,
2934                },
2935            )
2936            .await?;
2937        Ok(page.items)
2938    }
2939
2940    /// Search notes using a hybrid FTS5 + vector pipeline with salience weighting.
2941    ///
2942    /// Pipeline:
2943    /// 1. FTS5 query against `notes_<namespace>`.
2944    /// 2. If embedding model is configured: vector search filtered to `kind="note"`.
2945    /// 3. RRF fusion (k=60).
2946    /// 4. Salience-weighted rerank: `score *= (0.5 + 0.5 * note.salience)`.
2947    /// 5. Filter soft-deleted notes, apply optional kind / tag / properties predicates.
2948    ///    Tags and properties are pushed into the per-note fetch loop BEFORE truncation
2949    ///    so that matching notes ranked beyond `limit` in the raw fusion are not silently
2950    ///    dropped.
2951    /// 6. Truncate to `limit`.
2952    ///
2953    /// `tags_any`: when non-empty, only notes that have at least one of these tags
2954    /// (stored in `properties["tags"]`, case-insensitive match) are retained. The
2955    /// check happens inside the alive-note loop, before `hits.truncate(limit)`.
2956    ///
2957    /// `properties_filter`: when `Some`, only notes whose `properties` JSON object is
2958    /// a superset of the given filter object are retained. Also applied before truncation.
2959    #[allow(clippy::too_many_arguments)]
2960    pub async fn search_notes(
2961        &self,
2962        token: &NamespaceToken,
2963        query_text: &str,
2964        query_vector: Option<Vec<f32>>,
2965        limit: u32,
2966        note_kind: Option<&str>,
2967        include_superseded: bool,
2968        tags_any: &[String],
2969        properties_filter: Option<&serde_json::Value>,
2970    ) -> RuntimeResult<Vec<NoteSearchHit>> {
2971        const RRF_K: usize = 60;
2972        let candidates = limit.saturating_mul(4).max(limit);
2973        let visible_ns: Vec<String> = token
2974            .visible_namespaces()
2975            .iter()
2976            .map(|ns| ns.as_str().to_owned())
2977            .collect();
2978
2979        // FTS5 over the notes index — search all visible namespaces.
2980        //
2981        // `sanitize_fts5_query` strips known-unsafe FTS5 metacharacters, but
2982        // residual punctuation the sanitizer does not strip can still reach
2983        // the FTS5 parser and error. This fails loud instead of degrading to
2984        // vector-only fusion, so callers see the bad query instead of
2985        // silently losing the lexical leg. Errors from any other leg (vector
2986        // search, note hydration) still propagate normally.
2987        //
2988        // Injection: check FTS_SEARCH_FAIL_NS (armed by `arm_fts_search_fail(ns)`),
2989        // exercising the propagate branch above. Fires only when the armed
2990        // namespace is among this call's visible namespaces, then clears (one-shot).
2991        #[cfg(any(test, feature = "fault-injection"))]
2992        let fts_search_inject = {
2993            let mut g = FTS_SEARCH_FAIL_NS.lock().unwrap();
2994            match g.as_deref() {
2995                Some(armed) if visible_ns.iter().any(|ns| ns == armed) => {
2996                    *g = None;
2997                    true
2998                }
2999                _ => false,
3000            }
3001        };
3002        #[cfg(not(any(test, feature = "fault-injection")))]
3003        let fts_search_inject = false;
3004
3005        let text_search_result = if fts_search_inject {
3006            Err(khive_storage::StorageError::Timeout {
3007                operation: "fts_search".into(),
3008            })
3009        } else {
3010            self.text_for_notes(token)?
3011                .search(TextSearchRequest {
3012                    query: query_text.to_string(),
3013                    mode: TextQueryMode::Plain,
3014                    filter: Some(TextFilter {
3015                        namespaces: visible_ns.clone(),
3016                        ..TextFilter::default()
3017                    }),
3018                    top_k: candidates,
3019                    snippet_chars: 200,
3020                })
3021                .await
3022        };
3023
3024        let text_hits = crate::error::fts_text_leg_or_err(
3025            text_search_result.map_err(RuntimeError::from),
3026            "search_notes",
3027            query_text,
3028        )?;
3029
3030        // Vector search filtered to notes.
3031        let vector_hits = if query_vector.is_some() || self.config().embedding_model.is_some() {
3032            self.vector_search(
3033                token,
3034                query_vector,
3035                Some(query_text),
3036                candidates,
3037                Some(SubstrateKind::Note),
3038            )
3039            .await?
3040        } else {
3041            vec![]
3042        };
3043
3044        // Keep the full text∪vector union through RRF — salience weighting and
3045        // soft-delete/kind filtering happen *after* this, and the final
3046        // `hits.truncate(limit)` is the only result-limiting cut. Truncating to
3047        // `candidates` here would drop a high-salience note ranked just outside
3048        // the raw RRF cutoff before salience ever applied.
3049        let fuse_k = text_hits.len() + vector_hits.len();
3050        let fused = crate::fusion::rrf_fuse_k(text_hits, vector_hits, RRF_K, fuse_k)?;
3051
3052        let candidate_ids: Vec<Uuid> = fused.iter().map(|hit| hit.entity_id).collect();
3053        if candidate_ids.is_empty() {
3054            return Ok(vec![]);
3055        }
3056
3057        // Fetch each candidate note individually to get salience and apply
3058        // soft-delete + (optional) kind filtering. Notes whose `kind` doesn't
3059        // match `note_kind` are dropped post-fetch — they're a small set
3060        // bounded by the text∪vector union (≤ 2×candidates), so the read is cheap.
3061        let note_store = self.notes(token)?;
3062        let mut alive_notes: HashMap<Uuid, Note> = HashMap::new();
3063        for id in &candidate_ids {
3064            if let Some(note) = note_store.get_note(*id).await? {
3065                if note.deleted_at.is_some() {
3066                    continue;
3067                }
3068                if let Some(want_kind) = note_kind {
3069                    if note.kind != want_kind {
3070                        continue;
3071                    }
3072                }
3073                // Apply tag predicate before adding to alive set: tags on notes live
3074                // inside `properties["tags"]` (a JSON array). This pushes the filter
3075                // before truncation so matching notes ranked beyond `limit` in the raw
3076                // fusion are not silently dropped.
3077                if !tags_any.is_empty() {
3078                    let note_tags: Vec<String> = note
3079                        .properties
3080                        .as_ref()
3081                        .and_then(|p| p.get("tags"))
3082                        .and_then(serde_json::Value::as_array)
3083                        .map(|arr| {
3084                            arr.iter()
3085                                .filter_map(serde_json::Value::as_str)
3086                                .map(str::to_owned)
3087                                .collect()
3088                        })
3089                        .unwrap_or_default();
3090                    if !note_tags
3091                        .iter()
3092                        .any(|t| tags_any.iter().any(|w| t.eq_ignore_ascii_case(w)))
3093                    {
3094                        continue;
3095                    }
3096                }
3097                // Apply properties predicate before truncation, same reasoning as tags above.
3098                if let Some(pf) = properties_filter {
3099                    if !note_props_match(note.properties.as_ref(), pf) {
3100                        continue;
3101                    }
3102                }
3103                alive_notes.insert(*id, note);
3104            }
3105        }
3106
3107        // Drop superseded notes unless include_superseded is true: any note targeted
3108        // by a `supersedes` edge is obsolete and excluded from default search.
3109        if !include_superseded && !alive_notes.is_empty() {
3110            let graph = self.graph(token)?;
3111            let mut superseded: std::collections::HashSet<Uuid> = std::collections::HashSet::new();
3112            for &note_id in alive_notes.keys() {
3113                let inbound = graph
3114                    .neighbors(
3115                        note_id,
3116                        NeighborQuery {
3117                            direction: Direction::In,
3118                            relations: Some(vec![EdgeRelation::Supersedes]),
3119                            limit: Some(1),
3120                            min_weight: None,
3121                        },
3122                    )
3123                    .await?;
3124                if !inbound.is_empty() {
3125                    superseded.insert(note_id);
3126                }
3127            }
3128            alive_notes.retain(|id, _| !superseded.contains(id));
3129        }
3130
3131        // Apply salience weighting and collect final hits.
3132        let mut hits: Vec<NoteSearchHit> = fused
3133            .into_iter()
3134            .filter_map(|hit| {
3135                let note = alive_notes.get(&hit.entity_id)?;
3136                let salience = note.salience.unwrap_or(0.5);
3137                let weight = 0.5 + 0.5 * salience;
3138                let weighted = DeterministicScore::from_f64(hit.score.to_f64() * weight);
3139                Some(NoteSearchHit {
3140                    note_id: hit.entity_id,
3141                    score: weighted,
3142                    title: hit.title.or_else(|| note_title(note)),
3143                    snippet: hit.snippet.or_else(|| note_snippet(note)),
3144                })
3145            })
3146            .collect();
3147
3148        hits.sort_by(|a, b| b.score.cmp(&a.score).then(a.note_id.cmp(&b.note_id)));
3149        hits.truncate(limit as usize);
3150        Ok(hits)
3151    }
3152
3153    /// Resolve a short UUID prefix (8+ hex chars) to a full UUID.
3154    ///
3155    /// Searches entities, notes, and edges tables for a UUID starting with the
3156    /// given prefix, scoped to the caller's primary namespace only. Returns
3157    /// `Ok(Some(uuid))` if exactly one match is found, `Ok(None)` if no
3158    /// matches, or an error if ambiguous (multiple matches).
3159    pub async fn resolve_prefix(
3160        &self,
3161        token: &NamespaceToken,
3162        prefix: &str,
3163    ) -> RuntimeResult<Option<Uuid>> {
3164        let namespaces = [token.namespace().as_str().to_owned()];
3165        self.resolve_prefix_inner(Some(&namespaces), prefix, false)
3166            .await
3167    }
3168
3169    pub async fn resolve_prefix_including_deleted(
3170        &self,
3171        token: &NamespaceToken,
3172        prefix: &str,
3173    ) -> RuntimeResult<Option<Uuid>> {
3174        let namespaces = [token.namespace().as_str().to_owned()];
3175        self.resolve_prefix_inner(Some(&namespaces), prefix, true)
3176            .await
3177    }
3178
3179    /// Resolve a short UUID prefix (8+ hex chars) to a full UUID with NO
3180    /// namespace filter at all: mirrors `resolve_by_id`'s by-ID contract:
3181    /// by-ID resolution is namespace-agnostic, since the Gate (not
3182    /// storage-layer filtering) is the authz seam. Used by the four by-ID
3183    /// CRUD verbs (get/update/delete/merge) so their prefix path matches
3184    /// their already-unfiltered full-UUID path. No token param: unlike
3185    /// `resolve_prefix`, there is no namespace to derive from one.
3186    pub async fn resolve_prefix_unfiltered(&self, prefix: &str) -> RuntimeResult<Option<Uuid>> {
3187        self.resolve_prefix_inner(None, prefix, false).await
3188    }
3189
3190    /// `resolve_prefix_unfiltered`, including soft-deleted rows — used by the
3191    /// hard-delete by-ID path.
3192    pub async fn resolve_prefix_unfiltered_including_deleted(
3193        &self,
3194        prefix: &str,
3195    ) -> RuntimeResult<Option<Uuid>> {
3196        self.resolve_prefix_inner(None, prefix, true).await
3197    }
3198
3199    /// Shared prefix-scan implementation over an explicit namespace set.
3200    ///
3201    /// `namespaces` selects the scan scope: `Some(&[ns])` reproduces the
3202    /// historical primary-only behaviour (`resolve_prefix` /
3203    /// `resolve_prefix_including_deleted`); `None` applies
3204    /// no namespace predicate at all (`resolve_prefix_unfiltered*`).
3205    /// Ambiguity (a prefix matching more than one UUID, even across
3206    /// different namespaces in the set, or across all namespaces when
3207    /// unfiltered) is still an error: UUIDs are globally unique, so two
3208    /// distinct rows sharing a prefix always requires caller disambiguation —
3209    /// no cross-namespace dedup is needed or performed.
3210    async fn resolve_prefix_inner(
3211        &self,
3212        namespaces: Option<&[String]>,
3213        prefix: &str,
3214        include_deleted: bool,
3215    ) -> RuntimeResult<Option<Uuid>> {
3216        use khive_storage::types::{SqlStatement, SqlValue};
3217
3218        // Every caller is expected to pre-validate hex-only input, but this is
3219        // the single choke point every `resolve_prefix*` variant funnels
3220        // through, so re-validate here too. A prefix containing anything other
3221        // than hex digits and
3222        // canonical hyphen separators (`%`, `_`, or other LIKE-wildcard /
3223        // injection-shaped input) never matches a real id and is rejected
3224        // before it can reach the LIKE pattern, instead of relying on bound
3225        // params alone to neutralize wildcard semantics.
3226        if !prefix.chars().all(|c| c.is_ascii_hexdigit() || c == '-') {
3227            return Ok(None);
3228        }
3229
3230        let pattern = format!("{}%", hex_prefix_to_uuid_pattern(prefix));
3231
3232        let tables = [
3233            ("entities", true),
3234            ("notes", true),
3235            ("events", false),
3236            ("graph_edges", false),
3237        ];
3238
3239        let ns_clause = namespaces.map(|ns| {
3240            let placeholders: Vec<String> = (0..ns.len()).map(|i| format!("?{}", i + 2)).collect();
3241            format!(" AND namespace IN ({})", placeholders.join(", "))
3242        });
3243
3244        // A UUID can legitimately exist in more than one scanned table
3245        // (e.g. an entity id string that also happens to be an edge id — the
3246        // scan is purely a text-prefix LIKE across independent tables, not a
3247        // substrate-exclusive lookup). Without dedup, a single record hit
3248        // twice across tables inflated `matches.len()` past 1 and produced a
3249        // false `AmbiguousPrefix` naming the SAME UUID twice. `seen` tracks
3250        // UUIDs already pushed so `matches` (and thus every length check,
3251        // including the early-exit below) reflects DISTINCT UUIDs only.
3252        let mut matches: Vec<String> = Vec::new();
3253        let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
3254        let mut reader = self.sql().reader().await.map_err(RuntimeError::Storage)?;
3255
3256        for (table, has_deleted_at) in tables {
3257            let deleted_filter = if has_deleted_at && !include_deleted {
3258                " AND deleted_at IS NULL"
3259            } else {
3260                ""
3261            };
3262            let mut params = vec![SqlValue::Text(pattern.clone())];
3263            if let Some(ns) = namespaces {
3264                params.extend(ns.iter().map(|n| SqlValue::Text(n.clone())));
3265            }
3266            let sql = SqlStatement {
3267                sql: format!(
3268                    "SELECT id FROM {table} WHERE id LIKE ?1{ns_clause}{deleted_filter} LIMIT 2",
3269                    ns_clause = ns_clause.as_deref().unwrap_or("")
3270                ),
3271                params,
3272                label: Some("resolve_prefix".into()),
3273            };
3274            match reader.query_all(sql).await {
3275                Ok(rows) => {
3276                    for row in rows {
3277                        if let Some(col) = row.columns.first() {
3278                            if let SqlValue::Text(s) = &col.value {
3279                                if seen.insert(s.clone()) {
3280                                    matches.push(s.clone());
3281                                }
3282                            }
3283                        }
3284                    }
3285                }
3286                Err(e) => {
3287                    let msg = e.to_string();
3288                    if msg.contains("no such table") {
3289                        continue;
3290                    }
3291                    return Err(RuntimeError::Storage(e));
3292                }
3293            }
3294            if matches.len() > 1 {
3295                break;
3296            }
3297        }
3298
3299        match matches.len() {
3300            0 => Ok(None),
3301            1 => {
3302                let uuid = Uuid::from_str(&matches[0])
3303                    .map_err(|e| RuntimeError::Internal(format!("stored UUID is invalid: {e}")))?;
3304                Ok(Some(uuid))
3305            }
3306            _ => {
3307                let uuids: Vec<uuid::Uuid> = matches
3308                    .iter()
3309                    .filter_map(|s| Uuid::from_str(s).ok())
3310                    .collect();
3311                Err(RuntimeError::AmbiguousPrefix {
3312                    prefix: prefix.to_string(),
3313                    matches: uuids,
3314                })
3315            }
3316        }
3317    }
3318
3319    /// Resolve a UUID to its substrate kind with NO namespace filter.
3320    ///
3321    /// By-ID contract: UUID v4 is globally unique: by-ID substrate
3322    /// inference must return the record regardless of caller namespace.  Used by
3323    /// the public `update` and `delete` verb handlers when no explicit `kind` is
3324    /// supplied.
3325    ///
3326    /// Does NOT consult the visible set or the primary-namespace check.  The
3327    /// token is still required to route to the correct backend pool but its
3328    /// namespace value is not used as a filter.
3329    pub async fn resolve_by_id(
3330        &self,
3331        token: &NamespaceToken,
3332        id: Uuid,
3333    ) -> RuntimeResult<Option<Resolved>> {
3334        // Entity: direct by-UUID fetch (ID-only, no namespace check).
3335        if let Some(entity) = self.entities(token)?.get_entity(id).await? {
3336            return Ok(Some(Resolved::Entity(entity)));
3337        }
3338
3339        // Note: direct by-UUID fetch (ID-only).
3340        if let Some(note) = self.notes(token)?.get_note(id).await? {
3341            return Ok(Some(Resolved::Note(note)));
3342        }
3343
3344        // Edges and events are not returned here; the caller's `_` arm handles
3345        // those with a separate get_edge / get_event check.
3346        Ok(None)
3347    }
3348
3349    /// Resolve a UUID to its substrate kind with NO namespace filter, including
3350    /// soft-deleted rows.
3351    ///
3352    /// Used by the hard-delete path when no explicit `kind` is supplied, so
3353    /// already-soft-deleted records can still be located by UUID alone.
3354    pub async fn resolve_by_id_including_deleted(
3355        &self,
3356        token: &NamespaceToken,
3357        id: Uuid,
3358    ) -> RuntimeResult<Option<Resolved>> {
3359        // Entity: including soft-deleted, no namespace check.
3360        if let Some(entity) = self
3361            .entities(token)?
3362            .get_entity_including_deleted(id)
3363            .await?
3364        {
3365            return Ok(Some(Resolved::Entity(entity)));
3366        }
3367
3368        // Note: including soft-deleted, no namespace check.
3369        if let Some(note) = self.notes(token)?.get_note_including_deleted(id).await? {
3370            return Ok(Some(Resolved::Note(note)));
3371        }
3372
3373        // Edges and events are not returned here; the caller's `_` arm handles
3374        // those with a separate get_edge_including_deleted check.
3375        Ok(None)
3376    }
3377
3378    /// Resolve a UUID to its substrate kind by trying entity, then note, then event stores.
3379    ///
3380    /// Returns `None` if the UUID is not found in any substrate.
3381    /// Cost: at most 3 store lookups per call (cheap for v0.1).
3382    pub async fn resolve(
3383        &self,
3384        token: &NamespaceToken,
3385        id: Uuid,
3386    ) -> RuntimeResult<Option<Resolved>> {
3387        // Entity: use the namespace-checked getter (errors on mismatch/absent).
3388        match self.get_entity(token, id).await {
3389            Ok(entity) => return Ok(Some(Resolved::Entity(entity))),
3390            Err(RuntimeError::NotFound(_) | RuntimeError::NamespaceMismatch { .. }) => {}
3391            Err(e) => return Err(e),
3392        }
3393
3394        // Note: storage get_note is ID-only — verify against visible set.
3395        if let Some(note) = self.notes(token)?.get_note(id).await? {
3396            if Self::ensure_namespace_visible(&note.namespace, token).is_ok() {
3397                return Ok(Some(Resolved::Note(note)));
3398            }
3399        }
3400
3401        // Event: storage get_event is ID-only — verify against visible set.
3402        if let Some(event) = self.events(token)?.get_event(id).await? {
3403            if Self::ensure_namespace_visible(&event.namespace, token).is_ok() {
3404                return Ok(Some(Resolved::Event(event)));
3405            }
3406        }
3407
3408        Ok(None)
3409    }
3410
3411    /// Resolve a UUID to its substrate kind with NO namespace filter, for edge
3412    /// endpoint validation.
3413    ///
3414    /// `link` and `create`'s `annotates` targets consume by-ID endpoints, so
3415    /// their existence check must follow the same by-ID contract as `get()`:
3416    /// by-ID ops are namespace-agnostic: the Gate, not storage-layer
3417    /// filtering, is the authz seam. Mirrors `resolve_by_id`
3418    /// (entity + note, unfiltered) and additionally resolves events,
3419    /// unfiltered, so edge endpoint validation resolves exactly what `get()`
3420    /// resolves regardless of the caller's namespace.
3421    pub async fn resolve_edge_endpoint(
3422        &self,
3423        token: &NamespaceToken,
3424        id: Uuid,
3425    ) -> RuntimeResult<Option<Resolved>> {
3426        if let Some(resolved) = self.resolve_by_id(token, id).await? {
3427            return Ok(Some(resolved));
3428        }
3429        if let Some(event) = self.events(token)?.get_event(id).await? {
3430            return Ok(Some(Resolved::Event(event)));
3431        }
3432        Ok(None)
3433    }
3434
3435    /// Resolve a UUID to its substrate kind using primary-namespace-only enforcement.
3436    ///
3437    /// Unlike `resolve`, never consults the visible set. Use from GTD dependency
3438    /// validation paths where strict primary ownership is required.
3439    pub async fn resolve_primary(
3440        &self,
3441        token: &NamespaceToken,
3442        id: Uuid,
3443    ) -> RuntimeResult<Option<Resolved>> {
3444        let ns = token.namespace().as_str();
3445
3446        // Entity: primary-only check (exclude entities in visible-only namespaces).
3447        if let Some(entity) = self.entities(token)?.get_entity(id).await? {
3448            if Self::ensure_namespace(&entity.namespace, ns).is_ok() {
3449                return Ok(Some(Resolved::Entity(entity)));
3450            }
3451        }
3452
3453        // Note: primary-only check.
3454        if let Some(note) = self.notes(token)?.get_note(id).await? {
3455            if Self::ensure_namespace(&note.namespace, ns).is_ok() {
3456                return Ok(Some(Resolved::Note(note)));
3457            }
3458        }
3459
3460        // Event: primary-only check.
3461        if let Some(event) = self.events(token)?.get_event(id).await? {
3462            if Self::ensure_namespace(&event.namespace, ns).is_ok() {
3463                return Ok(Some(Resolved::Event(event)));
3464            }
3465        }
3466
3467        Ok(None)
3468    }
3469
3470    /// Resolve a UUID to its substrate kind, including soft-deleted rows.
3471    ///
3472    /// Used exclusively by the hard-delete path to locate records that have
3473    /// already been soft-deleted. Namespace isolation is still enforced.
3474    pub async fn resolve_including_deleted(
3475        &self,
3476        token: &NamespaceToken,
3477        id: Uuid,
3478    ) -> RuntimeResult<Option<Resolved>> {
3479        let ns = token.namespace().as_str();
3480
3481        if let Some(entity) = self
3482            .entities(token)?
3483            .get_entity_including_deleted(id)
3484            .await?
3485        {
3486            if Self::ensure_namespace(&entity.namespace, ns).is_ok() {
3487                return Ok(Some(Resolved::Entity(entity)));
3488            }
3489        }
3490
3491        if let Some(note) = self.notes(token)?.get_note_including_deleted(id).await? {
3492            if Self::ensure_namespace(&note.namespace, ns).is_ok() {
3493                return Ok(Some(Resolved::Note(note)));
3494            }
3495        }
3496
3497        if let Some(event) = self.events(token)?.get_event(id).await? {
3498            if Self::ensure_namespace(&event.namespace, ns).is_ok() {
3499                return Ok(Some(Resolved::Event(event)));
3500            }
3501        }
3502
3503        Ok(None)
3504    }
3505
3506    /// Hard-delete a single graph node (entity, note, or edge-as-node row)
3507    /// AND purge its incident edges in ONE write transaction.
3508    ///
3509    /// The endpoint row delete and the incident-edge cascade used
3510    /// to run as two independently-committing storage calls. A concurrent
3511    /// guarded write (`upsert_edge_guarded`/`upsert_edges_guarded`) landing
3512    /// between them could see the endpoint still live, insert a fresh edge
3513    /// against it, and then survive the cascade that already ran — a
3514    /// durably dangling edge with no second purge. Routing both statements
3515    /// through one [`run_atomic_unit`] call closes the window: since every
3516    /// write (this one and the guarded insert) funnels through the same
3517    /// single-writer queue, a concurrent guarded write either fully commits
3518    /// before this unit starts (and its edge is then swept by the purge
3519    /// below, in the same transaction as the row delete) or fully commits
3520    /// after this unit has already committed (and its own endpoint-existence
3521    /// check then sees the endpoint gone and refuses the write) — there is
3522    /// no state in which it can observe the endpoint alive with edges
3523    /// already purged.
3524    ///
3525    /// `row_statement` is the exact hard-delete `DELETE` for the target row
3526    /// (entity, note, or edge). Returns `Ok(true)` if the row was deleted,
3527    /// `Ok(false)` if it no longer existed (lost a race with a concurrent
3528    /// delete of the same row) — never an error for that case, matching the
3529    /// non-atomic bool-returning shape callers had before this fix.
3530    async fn atomic_hard_delete_with_edge_purge(
3531        &self,
3532        row_statement: SqlStatement,
3533        node_id: Uuid,
3534    ) -> RuntimeResult<bool> {
3535        let plan = AtomicOpPlan::Delete(DeletePlan {
3536            target_id: node_id,
3537            statements: vec![
3538                PlanStatement {
3539                    statement: row_statement,
3540                    guard: Some(AffectedRowGuard::exactly(1)),
3541                },
3542                PlanStatement {
3543                    statement: purge_incident_edges_statement(node_id),
3544                    guard: None,
3545                },
3546            ],
3547            post_commit: PostCommitEffect::None,
3548        });
3549        match run_atomic_unit(self.sql().as_ref(), vec![plan]).await {
3550            Ok(AtomicRunOutcome::Committed { .. }) => Ok(true),
3551            Ok(AtomicRunOutcome::RolledBack {
3552                failure: AtomicOpFailure::GuardFailed { .. },
3553                ..
3554            }) => Ok(false),
3555            Ok(AtomicRunOutcome::RolledBack {
3556                failure: AtomicOpFailure::SqlError { message, .. },
3557                ..
3558            }) => Err(RuntimeError::Internal(format!(
3559                "hard delete + edge purge for {node_id} failed: {message}"
3560            ))),
3561            Err(e) => Err(RuntimeError::Internal(format!(
3562                "hard delete + edge purge for {node_id}: atomic unit seam failure: {}",
3563                e.0
3564            ))),
3565        }
3566    }
3567
3568    /// Soft-delete or hard-delete a note by ID.
3569    ///
3570    /// On hard delete, cascades to remove all incident edges (both inbound and
3571    /// outbound) and cleans up FTS and vector indexes, preventing dangling
3572    /// references for `annotates` edges that target this note.
3573    /// Soft delete also cleans FTS and vector indexes; edges are left in place.
3574    ///
3575    /// UUID v4 is globally unique: no namespace filter on by-ID ops.
3576    /// Cascade and index cleanup target the RECORD's stored namespace, not the caller token's.
3577    /// Returns `Ok(false)` if the note does not exist.
3578    pub async fn delete_note(
3579        &self,
3580        token: &NamespaceToken,
3581        id: Uuid,
3582        hard: bool,
3583    ) -> RuntimeResult<bool> {
3584        let note_store = self.notes(token)?;
3585        let note = if hard {
3586            match note_store.get_note_including_deleted(id).await? {
3587                Some(n) => n,
3588                None => return Ok(false),
3589            }
3590        } else {
3591            match note_store.get_note(id).await? {
3592                Some(n) => n,
3593                None => return Ok(false),
3594            }
3595        };
3596        let mode = if hard {
3597            DeleteMode::Hard
3598        } else {
3599            DeleteMode::Soft
3600        };
3601
3602        // Route index cleanup through the RECORD's namespace, not the caller's.
3603        let record_tok = NamespaceToken::for_namespace(
3604            khive_types::Namespace::parse(&note.namespace)
3605                .map_err(|e| RuntimeError::Internal(format!("note namespace invalid: {e}")))?,
3606        );
3607        let record_ns = note.namespace.clone();
3608
3609        // On hard delete, the row delete and the incident-edge cascade (including
3610        // already-soft-deleted edges) run as ONE write transaction: see
3611        // `atomic_hard_delete_with_edge_purge`. Index cleanup follows the
3612        // commit; it is best-effort and idempotent, unlike the row/edge pair.
3613        let deleted = if hard {
3614            let deleted = self
3615                .atomic_hard_delete_with_edge_purge(note_hard_delete_statement(id), id)
3616                .await?;
3617            self.text_for_notes(&record_tok)?
3618                .delete_document(&record_ns, id)
3619                .await?;
3620            // Scoped delete: iterate over EVERY registered embedding model's
3621            // vector store so non-default vectors don't orphan when the note is deleted.
3622            for model_name in self.registered_embedding_model_names() {
3623                self.vectors_for_model(&record_tok, &model_name)?
3624                    .delete(id)
3625                    .await?;
3626            }
3627            deleted
3628        } else {
3629            let deleted = note_store.delete_note(id, mode).await?;
3630            if deleted {
3631                self.text_for_notes(&record_tok)?
3632                    .delete_document(&record_ns, id)
3633                    .await?;
3634                for model_name in self.registered_embedding_model_names() {
3635                    self.vectors_for_model(&record_tok, &model_name)?
3636                        .delete(id)
3637                        .await?;
3638                }
3639            }
3640            deleted
3641        };
3642        if deleted {
3643            let event_store = self.events(token)?;
3644            let event = khive_storage::event::Event::new(
3645                record_ns.clone(),
3646                "delete",
3647                EventKind::NoteDeleted,
3648                SubstrateKind::Note,
3649                "",
3650            )
3651            .with_target(id)
3652            .with_payload(serde_json::json!({"id": id, "namespace": record_ns, "hard": hard}));
3653            event_store.append_event(event).await.map_err(|e| {
3654                RuntimeError::Internal(format!("delete_note: event store write failed: {e}"))
3655            })?;
3656            // A soft OR hard delete removes the note's vectors/FTS document
3657            // above: any pack-owned vector-derived cache (e.g.
3658            // khive-pack-memory's warm ANN index) needs to know the corpus
3659            // changed, reached via this generic hook so khive-runtime never
3660            // takes a dependency on khive-pack-memory. No-op when no pack has
3661            // installed a hook.
3662            self.fire_note_mutation_hook(&note.kind, id).await;
3663        }
3664        Ok(deleted)
3665    }
3666
3667    /// Row-first compensating delete for rolling back a partially-written note
3668    /// (e.g. `dual_write_message` rollback after a later delivery step fails).
3669    /// Unlike [`KhiveRuntime::delete_note`], which cleans up graph/FTS/
3670    /// vector indexes *before* removing the row, this removes the row first so
3671    /// that a cleanup failure afterward cannot leave the compensated note live.
3672    ///
3673    /// Returns `Ok(())` once the row is gone (whether or not cleanup fully
3674    /// succeeded). Returns `Err(RuntimeError::Internal)` naming the failed
3675    /// cleanup legs when row removal succeeded but cleanup did not — the
3676    /// message is gone, but stale index entries may remain and should be
3677    /// surfaced to the caller rather than silently discarded.
3678    ///
3679    /// Returns `Ok(())` immediately, with no cleanup attempted, if the note
3680    /// does not exist (nothing to compensate).
3681    ///
3682    /// Not a general-purpose replacement for `delete_note(..., hard=true)`:
3683    /// normal hard delete still needs cleanup-first semantics (no dangling
3684    /// references) since a caller-visible error there should not remove the row.
3685    pub async fn delete_note_row_first_for_compensation(
3686        &self,
3687        token: &NamespaceToken,
3688        id: Uuid,
3689    ) -> RuntimeResult<()> {
3690        let note_store = self.notes(token)?;
3691        let Some(note) = note_store.get_note_including_deleted(id).await? else {
3692            return Ok(());
3693        };
3694        let record_tok = NamespaceToken::for_namespace(
3695            khive_types::Namespace::parse(&note.namespace)
3696                .map_err(|e| RuntimeError::Internal(format!("note namespace invalid: {e}")))?,
3697        );
3698        let record_ns = note.namespace.clone();
3699
3700        // Critical ordering: remove the row before any cleanup that can fail.
3701        note_store.delete_note(id, DeleteMode::Hard).await?;
3702
3703        #[cfg(any(test, feature = "fault-injection"))]
3704        {
3705            let armed = ROLLBACK_CLEANUP_FAIL_NS.lock().unwrap().take();
3706            if armed.as_deref() == Some(record_ns.as_str()) {
3707                return Err(RuntimeError::Internal(
3708                    "row removed but compensation cleanup failed: injected=true".to_string(),
3709                ));
3710            }
3711        }
3712
3713        let mut cleanup_errors = Vec::new();
3714        if let Err(e) = self.graph(&record_tok)?.purge_incident_edges(id).await {
3715            cleanup_errors.push(format!("graph={e}"));
3716        }
3717        if let Err(e) = self
3718            .text_for_notes(&record_tok)?
3719            .delete_document(&record_ns, id)
3720            .await
3721        {
3722            cleanup_errors.push(format!("fts={e}"));
3723        }
3724        for model_name in self.registered_embedding_model_names() {
3725            if let Err(e) = self
3726                .vectors_for_model(&record_tok, &model_name)?
3727                .delete(id)
3728                .await
3729            {
3730                cleanup_errors.push(format!("vector[{model_name}]={e}"));
3731            }
3732        }
3733        if cleanup_errors.is_empty() {
3734            Ok(())
3735        } else {
3736            Err(RuntimeError::Internal(format!(
3737                "row removed but compensation cleanup failed: {}",
3738                cleanup_errors.join("; ")
3739            )))
3740        }
3741    }
3742}
3743
3744/// Result of a GQL/SPARQL query with optional validation warnings.
3745#[derive(Clone, Debug, Serialize)]
3746pub struct QueryResult {
3747    pub rows: Vec<SqlRow>,
3748    #[serde(skip_serializing_if = "Vec::is_empty")]
3749    pub warnings: Vec<String>,
3750}
3751
3752impl KhiveRuntime {
3753    // ---- Query operations ----
3754
3755    /// Execute a GQL or SPARQL query string, returning raw SQL rows.
3756    ///
3757    /// The query is compiled to SQL with the namespace scope applied.
3758    /// GQL syntax: `MATCH (a:concept)-[e:extends]->(b) RETURN a, b LIMIT 10`
3759    /// SPARQL syntax: `SELECT ?a WHERE { ?a :kind "concept" . }`
3760    pub async fn query(&self, token: &NamespaceToken, query: &str) -> RuntimeResult<Vec<SqlRow>> {
3761        Ok(self
3762            .query_with_metadata(token, query, khive_query::CompileOptions::default())
3763            .await?
3764            .rows)
3765    }
3766
3767    /// Execute a GQL/SPARQL query, returning rows and any validation warnings.
3768    pub async fn query_with_metadata(
3769        &self,
3770        token: &NamespaceToken,
3771        query: &str,
3772        mut opts: khive_query::CompileOptions,
3773    ) -> RuntimeResult<QueryResult> {
3774        use khive_query::QueryValue;
3775        use khive_storage::types::SqlValue;
3776
3777        let ast = khive_query::parse_auto(query)?;
3778        opts.scopes = token
3779            .visible_namespaces()
3780            .iter()
3781            .map(|ns| ns.as_str().to_string())
3782            .collect();
3783        let compiled = khive_query::compile(&ast, &opts)?;
3784        let mut warnings = compiled.warnings;
3785        let truncation_check = compiled.truncation_check;
3786
3787        // Convert QueryValue params (query-layer type) to SqlValue (storage-layer type)
3788        // at the query–storage boundary.
3789        let params: Vec<SqlValue> = compiled
3790            .params
3791            .into_iter()
3792            .map(|qv| match qv {
3793                QueryValue::Null => SqlValue::Null,
3794                QueryValue::Integer(n) => SqlValue::Integer(n),
3795                QueryValue::Float(f) => SqlValue::Float(f),
3796                QueryValue::Text(s) => SqlValue::Text(s),
3797                QueryValue::Blob(b) => SqlValue::Blob(b),
3798            })
3799            .collect();
3800
3801        let mut reader = self.sql().reader().await?;
3802        let stmt = SqlStatement {
3803            sql: compiled.sql,
3804            params,
3805            label: None,
3806        };
3807        let mut rows = reader.query_all(stmt).await?;
3808
3809        // When the server-side cap was the binding constraint, the compiled
3810        // SQL asked for one extra (sentinel) row. Its presence in the actual
3811        // result set — not the requested LIMIT — is the truncation signal
3812        // (a `LIMIT 1000` that only matches 20 rows must not warn, and a
3813        // query with no `LIMIT` that matches 501+ rows must).
3814        if let Some(check) = truncation_check {
3815            if rows.len() > check.max_limit {
3816                rows.truncate(check.max_limit);
3817                warnings.push(match check.requested_limit {
3818                    Some(requested) => format!(
3819                        "result set capped at {} rows; requested limit {requested} exceeds the \
3820                         cap — use LIMIT/OFFSET to page through the remaining results",
3821                        check.max_limit
3822                    ),
3823                    None => format!(
3824                        "result set capped at {} rows; more than {} rows matched with no LIMIT \
3825                         clause — use LIMIT/OFFSET to page through the remaining results",
3826                        check.max_limit, check.max_limit
3827                    ),
3828                });
3829            }
3830        }
3831
3832        Ok(QueryResult { rows, warnings })
3833    }
3834
3835    /// Soft-delete or hard-delete an entity by ID (soft delete by default).
3836    ///
3837    /// On hard delete, cascades to remove all incident edges (both inbound and
3838    /// outbound) to prevent dangling references. Soft delete also cleans FTS
3839    /// and vector indexes; edges are left in place.
3840    ///
3841    /// UUID v4 is globally unique: no namespace filter on by-ID ops.
3842    pub async fn delete_entity(
3843        &self,
3844        token: &NamespaceToken,
3845        id: Uuid,
3846        hard: bool,
3847    ) -> RuntimeResult<bool> {
3848        let entity = if hard {
3849            match self
3850                .entities(token)?
3851                .get_entity_including_deleted(id)
3852                .await?
3853            {
3854                Some(e) => e,
3855                None => return Ok(false),
3856            }
3857        } else {
3858            match self.entities(token)?.get_entity(id).await? {
3859                Some(e) => e,
3860                None => return Ok(false),
3861            }
3862        };
3863        let mode = if hard {
3864            DeleteMode::Hard
3865        } else {
3866            DeleteMode::Soft
3867        };
3868
3869        // Route cascade and index cleanup through the RECORD's namespace, not the caller's.
3870        let record_tok = NamespaceToken::for_namespace(
3871            khive_types::Namespace::parse(&entity.namespace)
3872                .map_err(|e| RuntimeError::Internal(format!("entity namespace invalid: {e}")))?,
3873        );
3874
3875        // On hard delete, the row delete and the incident-edge cascade (including
3876        // already-soft-deleted edges) run as ONE write transaction: see
3877        // `atomic_hard_delete_with_edge_purge`. Index cleanup follows the
3878        // commit; it is best-effort and idempotent, unlike the row/edge pair.
3879        let deleted = if hard {
3880            let deleted = self
3881                .atomic_hard_delete_with_edge_purge(entity_hard_delete_statement(id), id)
3882                .await?;
3883            self.remove_from_indexes(&record_tok, id).await?;
3884            deleted
3885        } else {
3886            let deleted = self.entities(token)?.delete_entity(id, mode).await?;
3887            if deleted {
3888                self.remove_from_indexes(&record_tok, id).await?;
3889            }
3890            deleted
3891        };
3892        if deleted {
3893            let event_store = self.events(token)?;
3894            let ns = entity.namespace.clone();
3895            let event = khive_storage::event::Event::new(
3896                ns.clone(),
3897                "delete",
3898                EventKind::EntityDeleted,
3899                SubstrateKind::Entity,
3900                "",
3901            )
3902            .with_target(id)
3903            .with_payload(serde_json::json!({"id": id, "namespace": ns, "hard": hard}));
3904            event_store.append_event(event).await.map_err(|e| {
3905                RuntimeError::Internal(format!("delete_entity: event store write failed: {e}"))
3906            })?;
3907        }
3908        Ok(deleted)
3909    }
3910
3911    /// Count entities in a namespace, optionally filtered.
3912    pub async fn count_entities(
3913        &self,
3914        token: &NamespaceToken,
3915        kind: Option<&str>,
3916    ) -> RuntimeResult<u64> {
3917        let filter = EntityFilter {
3918            kinds: match kind {
3919                Some(k) => vec![k.to_string()],
3920                None => vec![],
3921            },
3922            ..Default::default()
3923        };
3924        Ok(self
3925            .entities(token)?
3926            .count_entities(token.namespace().as_str(), filter)
3927            .await?)
3928    }
3929
3930    // ---- Edge CRUD operations ----
3931
3932    /// Fetch a single edge by id.
3933    ///
3934    /// UUID v4 is globally unique: returns the edge regardless of which
3935    /// namespace the token carries. `Ok(None)` means the edge does not exist at all.
3936    pub async fn get_edge(
3937        &self,
3938        _token: &NamespaceToken,
3939        edge_id: Uuid,
3940    ) -> RuntimeResult<Option<Edge>> {
3941        let mut reader = self.sql().reader().await?;
3942        let record_ns = reader
3943            .query_scalar(SqlStatement {
3944                sql: "SELECT namespace FROM graph_edges \
3945                      WHERE id = ?1 AND deleted_at IS NULL LIMIT 1"
3946                    .into(),
3947                params: vec![SqlValue::Text(edge_id.to_string())],
3948                label: Some("get_edge_namespace".into()),
3949            })
3950            .await?;
3951
3952        let Some(SqlValue::Text(record_ns)) = record_ns else {
3953            return Ok(None);
3954        };
3955        // Route the storage fetch through the record's own namespace — the token is
3956        // just the caller context; by-ID ops cross namespace boundaries.
3957        let record_tok = NamespaceToken::for_namespace(
3958            khive_types::Namespace::parse(&record_ns)
3959                .map_err(|e| RuntimeError::Internal(format!("edge namespace invalid: {e}")))?,
3960        );
3961        Ok(self
3962            .graph(&record_tok)?
3963            .get_edge(LinkId::from(edge_id))
3964            .await?)
3965    }
3966
3967    /// Fetch a single edge by id.
3968    ///
3969    /// Delegates to `get_edge`: no visible-set check.  By-ID ops are
3970    /// namespace-agnostic; UUID v4 is globally unique.
3971    pub async fn get_edge_visible(
3972        &self,
3973        token: &NamespaceToken,
3974        edge_id: Uuid,
3975    ) -> RuntimeResult<Option<Edge>> {
3976        self.get_edge(token, edge_id).await
3977    }
3978
3979    /// Fetch an edge by UUID including soft-deleted rows.
3980    ///
3981    /// Returns the edge regardless of which namespace the token carries:
3982    /// UUID v4 is globally unique. Used by the hard-delete path so that a
3983    /// soft-deleted edge can still be purged via its edge ID.
3984    pub async fn get_edge_including_deleted(
3985        &self,
3986        _token: &NamespaceToken,
3987        edge_id: Uuid,
3988    ) -> RuntimeResult<Option<Edge>> {
3989        let mut reader = self.sql().reader().await?;
3990        let record_ns = reader
3991            .query_scalar(SqlStatement {
3992                sql: "SELECT namespace FROM graph_edges WHERE id = ?1 LIMIT 1".into(),
3993                params: vec![SqlValue::Text(edge_id.to_string())],
3994                label: Some("get_edge_including_deleted_namespace".into()),
3995            })
3996            .await?;
3997
3998        let Some(SqlValue::Text(record_ns)) = record_ns else {
3999            return Ok(None);
4000        };
4001        // Route through the record's own namespace store (no namespace equality check).
4002        let record_tok = NamespaceToken::for_namespace(
4003            khive_types::Namespace::parse(&record_ns)
4004                .map_err(|e| RuntimeError::Internal(format!("edge namespace invalid: {e}")))?,
4005        );
4006        Ok(self
4007            .graph(&record_tok)?
4008            .get_edge_including_deleted(LinkId::from(edge_id))
4009            .await?)
4010    }
4011
4012    /// Maximum rows returned by a single [`Self::list_edges`] /
4013    /// [`Self::list_edges_after`] page. A lower bound the docs promise callers
4014    /// can rely on; kept as a named constant so tests can exercise pagination
4015    /// (page tiling, out-of-range offsets) without needing >1000 real rows.
4016    pub const EDGE_LIST_MAX_LIMIT: u32 = 1000;
4017
4018    /// List edges matching `filter`, paging by `offset`. `limit` is capped at
4019    /// [`Self::EDGE_LIST_MAX_LIMIT`]; defaults to 100.
4020    ///
4021    /// `offset` pages through the full matching set (previously hard-coded to
4022    /// 0, so every page returned the same first rows). For
4023    /// O(1)-at-depth walks over large edge populations, prefer
4024    /// [`Self::list_edges_after`] instead of paging offset deep.
4025    pub async fn list_edges(
4026        &self,
4027        token: &NamespaceToken,
4028        filter: crate::curation::EdgeListFilter,
4029        limit: u32,
4030        offset: u32,
4031    ) -> RuntimeResult<Vec<Edge>> {
4032        let limit = limit.clamp(1, Self::EDGE_LIST_MAX_LIMIT);
4033        let visible = token.visible_namespaces();
4034
4035        // Common case: a single visible namespace — page directly against the
4036        // store so `offset`/`limit` reach SQL unmodified.
4037        if let [ns] = visible {
4038            let temp = NamespaceToken::for_namespace(ns.clone());
4039            let page = self
4040                .graph(&temp)?
4041                .query_edges(
4042                    filter.into(),
4043                    vec![SortOrder {
4044                        field: EdgeSortField::CreatedAt,
4045                        direction: khive_storage::types::SortDirection::Asc,
4046                    }],
4047                    PageRequest {
4048                        offset: offset.into(),
4049                        limit,
4050                    },
4051                )
4052                .await?;
4053            return Ok(page.items);
4054        }
4055
4056        // Multi-namespace visibility: `offset` must apply to the combined,
4057        // deduplicated set rather than per-namespace pages, so fetch enough
4058        // of each namespace's page to cover it, merge, then slice.
4059        let fetch_limit = offset.saturating_add(limit);
4060        let mut results = Vec::new();
4061        for ns in visible {
4062            let temp = NamespaceToken::for_namespace(ns.clone());
4063            let page = self
4064                .graph(&temp)?
4065                .query_edges(
4066                    filter.clone().into(),
4067                    vec![SortOrder {
4068                        field: EdgeSortField::CreatedAt,
4069                        direction: khive_storage::types::SortDirection::Asc,
4070                    }],
4071                    PageRequest {
4072                        offset: 0,
4073                        limit: fetch_limit,
4074                    },
4075                )
4076                .await?;
4077            results.extend(page.items);
4078        }
4079        results.sort_by_key(|e| Uuid::from(e.id));
4080        results.dedup_by_key(|e| Uuid::from(e.id));
4081        let start = (offset as usize).min(results.len());
4082        let end = (start + limit as usize).min(results.len());
4083        Ok(results[start..end].to_vec())
4084    }
4085
4086    /// Keyset (seek) page of edges matching `filter`, ordered by edge `id`
4087    /// ascending. `after` is the last edge id from the previous page
4088    /// (exclusive); omit to start from the beginning. Returns
4089    /// `(items, next_after)` — `next_after` is `Some` when more rows remain
4090    /// past this page.
4091    ///
4092    /// Unlike [`Self::list_edges`], this is O(log n + limit) at any depth: the
4093    /// underlying store issues an indexed `id > ?` range scan instead of an
4094    /// `OFFSET` skip, avoiding the O(offset) daemon CPU cost of a naive
4095    /// offset-based paging loop over a large edge population.
4096    pub async fn list_edges_after(
4097        &self,
4098        token: &NamespaceToken,
4099        filter: crate::curation::EdgeListFilter,
4100        after: Option<Uuid>,
4101        limit: u32,
4102    ) -> RuntimeResult<(Vec<Edge>, Option<Uuid>)> {
4103        let limit = limit.clamp(1, Self::EDGE_LIST_MAX_LIMIT);
4104        let visible = token.visible_namespaces();
4105        let limit_usize = limit as usize;
4106
4107        if let [ns] = visible {
4108            let temp = NamespaceToken::for_namespace(ns.clone());
4109            let page = self
4110                .graph(&temp)?
4111                .query_edges_after(filter.into(), after, limit)
4112                .await?;
4113            return Ok((page.items, page.next_after));
4114        }
4115
4116        // Multi-namespace visibility: seek each namespace from the same
4117        // cursor (ids are globally unique UUIDs), merge, then take the head
4118        // of the merged set as this page.
4119        let probe_limit = limit + 1;
4120        let mut results = Vec::new();
4121        for ns in visible {
4122            let temp = NamespaceToken::for_namespace(ns.clone());
4123            let page = self
4124                .graph(&temp)?
4125                .query_edges_after(filter.clone().into(), after, probe_limit)
4126                .await?;
4127            results.extend(page.items);
4128        }
4129        results.sort_by_key(|e| Uuid::from(e.id));
4130        results.dedup_by_key(|e| Uuid::from(e.id));
4131        let has_more = results.len() > limit_usize;
4132        if has_more {
4133            results.truncate(limit_usize);
4134        }
4135        let next_after = if has_more {
4136            results.last().map(|e| Uuid::from(e.id))
4137        } else {
4138            None
4139        };
4140        Ok((results, next_after))
4141    }
4142
4143    /// Count edges by relation, ignoring soft-deleted rows. Used by
4144    /// `stats()` to report the true per-relation population so full-graph
4145    /// audits know what they're sampling from before they walk it.
4146    pub async fn count_edges_by_relation(
4147        &self,
4148        token: &NamespaceToken,
4149    ) -> RuntimeResult<std::collections::HashMap<String, u64>> {
4150        let mut totals: std::collections::HashMap<String, u64> = std::collections::HashMap::new();
4151        for ns in token.visible_namespaces() {
4152            let temp = NamespaceToken::for_namespace(ns.clone());
4153            for (relation, count) in self.graph(&temp)?.count_edges_by_relation().await? {
4154                *totals.entry(relation.to_string()).or_insert(0) += count;
4155            }
4156        }
4157        Ok(totals)
4158    }
4159
4160    /// DML-only body of the symmetric-relation conflict-resolution path in
4161    /// [`Self::update_edge`]. Runs the conflict-check SELECT, then either the
4162    /// DELETE+UPDATE (case b, a canonical row already exists) or the
4163    /// in-place UPDATE (case a, no conflict). Callers own the surrounding transaction
4164    /// boundary — this function issues DML only, no `BEGIN`/`COMMIT`/`ROLLBACK`.
4165    ///
4166    /// Returns `Ok(Some(existing_id))` when a canonical conflict was absorbed (the
4167    /// requested edge was deleted, the existing canonical row refreshed), or
4168    /// `Ok(None)` when the requested edge was updated in place.
4169    ///
4170    /// DML text is the single source of truth shared with the atomic
4171    /// `prepare_update_edge` symmetric branch:
4172    /// [`khive_db::stores::graph::EDGE_SYMMETRIC_CONFLICT_PROBE_SQL`] /
4173    /// `EDGE_SYMMETRIC_DELETE_NONCANONICAL_SQL` /
4174    /// `EDGE_SYMMETRIC_REFRESH_CANONICAL_SQL` /
4175    /// `EDGE_SYMMETRIC_UPDATE_INPLACE_SQL` — this function binds them against
4176    /// `rusqlite::params!` (it runs inside an existing transaction on a
4177    /// borrowed `&rusqlite::Connection`), the atomic path binds the same text
4178    /// via `SqlValue` plan params; see the constants' doc comment in
4179    /// `khive-db` for why a single bridge type isn't used for both.
4180    #[allow(clippy::too_many_arguments)]
4181    fn update_edge_symmetric_dml(
4182        conn: &rusqlite::Connection,
4183        ns: &str,
4184        edge_id_str: &str,
4185        canon_src_str: &str,
4186        canon_tgt_str: &str,
4187        relation_str: &str,
4188        weight: f64,
4189        metadata: Option<String>,
4190        target_backend: Option<String>,
4191    ) -> Result<Option<String>, SqliteError> {
4192        // `updated_at` is stored in MICROSECONDS on `graph_edges` (every other
4193        // write path — `edge_upsert_statement`, `edge_soft_delete_statement` —
4194        // uses `timestamp_micros()`; the column is read back via
4195        // `micros_to_datetime`). `timestamp()` (seconds) here was a
4196        // pre-existing bug in this raw-SQL path, found while unifying it with
4197        // the atomic builder (which already used `timestamp_micros()`
4198        // correctly).
4199        let now_ts = chrono::Utc::now().timestamp_micros();
4200
4201        // Check for a conflicting canonical row (same namespace + natural key,
4202        // different id). This catches conflicts whether or not endpoints were flipped.
4203        let conflict_id: Option<String> = conn
4204            .query_row(
4205                khive_db::stores::graph::EDGE_SYMMETRIC_CONFLICT_PROBE_SQL,
4206                rusqlite::params![
4207                    &ns,
4208                    &canon_src_str,
4209                    &canon_tgt_str,
4210                    &relation_str,
4211                    &edge_id_str
4212                ],
4213                |row| row.get(0),
4214            )
4215            .optional()
4216            .map_err(SqliteError::Rusqlite)?;
4217
4218        if let Some(existing_id) = conflict_id {
4219            // Case (b): canonical row already exists — delete the non-canonical
4220            // edge and refresh the existing canonical row. Return the surviving
4221            // id so the caller can re-fetch it (never the deleted edge's id).
4222            conn.execute(
4223                khive_db::stores::graph::EDGE_SYMMETRIC_DELETE_NONCANONICAL_SQL,
4224                rusqlite::params![&ns, &edge_id_str],
4225            )
4226            .map_err(SqliteError::Rusqlite)?;
4227            let affected = conn
4228                .execute(
4229                    khive_db::stores::graph::EDGE_SYMMETRIC_REFRESH_CANONICAL_SQL,
4230                    rusqlite::params![weight, now_ts, target_backend, metadata, &ns, &existing_id],
4231                )
4232                .map_err(SqliteError::Rusqlite)?;
4233            if affected == 0 {
4234                return Err(SqliteError::InvalidData(format!(
4235                    "update_edge: surviving canonical row {existing_id} vanished during update"
4236                )));
4237            }
4238            Ok(Some(existing_id))
4239        } else {
4240            // Case (a): no conflict — update source_id/target_id in-place,
4241            // preserving the original edge UUID.
4242            let affected = conn
4243                .execute(
4244                    khive_db::stores::graph::EDGE_SYMMETRIC_UPDATE_INPLACE_SQL,
4245                    rusqlite::params![
4246                        &canon_src_str,
4247                        &canon_tgt_str,
4248                        &relation_str,
4249                        weight,
4250                        now_ts,
4251                        metadata,
4252                        &ns,
4253                        &edge_id_str,
4254                    ],
4255                )
4256                .map_err(SqliteError::Rusqlite)?;
4257            if affected == 0 {
4258                // The edge row was not found under the record's namespace.
4259                // This must never happen because ns = record_ns (fetched above).
4260                return Err(SqliteError::InvalidData(format!(
4261                    "update_edge: zero rows affected updating edge {edge_id_str} \
4262                     in namespace {ns} — row vanished between fetch and update"
4263                )));
4264            }
4265            Ok(None)
4266        }
4267    }
4268
4269    /// Patch-style edge update. Only `Some(_)` fields are applied.
4270    ///
4271    /// When `relation` is `Some(new_rel)`, validates that the edge's existing endpoints
4272    /// are legal for `new_rel` before persisting. Weight-only updates (`relation = None`)
4273    /// skip validation. Returns `InvalidInput` if the new relation would violate the
4274    /// three-case endpoint contract; the edge is NOT mutated on error.
4275    ///
4276    /// For symmetric relations (`competes_with`, `composed_with`), endpoint order is
4277    /// canonicalised to `source_uuid < target_uuid` after validation. If a canonical
4278    /// row already exists at the target triple, the non-canonical edge is deleted and
4279    /// the existing canonical row is refreshed (DELETE + UPDATE pattern, mirroring
4280    /// `merge_entity_sql`).
4281    pub async fn update_edge(
4282        &self,
4283        token: &NamespaceToken,
4284        edge_id: Uuid,
4285        patch: crate::curation::EdgePatch,
4286    ) -> RuntimeResult<Edge> {
4287        // Fetch the edge by UUID: ID-only, no namespace check.
4288        // get_edge already uses the record's stored namespace internally.
4289        let graph_for_fetch = self.graph(token)?;
4290        let mut edge = graph_for_fetch
4291            .get_edge(LinkId::from(edge_id))
4292            .await?
4293            .ok_or_else(|| crate::RuntimeError::NotFound(format!("edge {edge_id}")))?;
4294
4295        // After fetching, all mutations and validation must use the
4296        // RECORD's namespace, not the caller's.  Derive record_tok from the stored edge
4297        // namespace so that endpoint validation, raw-SQL predicates, and graph routing
4298        // all address the correct backend partition.
4299        let record_ns: String = edge.namespace.clone();
4300        let record_tok = NamespaceToken::for_namespace(
4301            khive_types::Namespace::parse(&record_ns)
4302                .map_err(|e| RuntimeError::Internal(format!("edge namespace invalid: {e}")))?,
4303        );
4304        let graph = self.graph(&record_tok)?;
4305
4306        let mut changed_fields: Vec<&'static str> = Vec::new();
4307        if let Some(r) = patch.relation {
4308            // Validate before mutating — use the existing endpoints with the new relation.
4309            // Use record_tok so that endpoint existence checks look in the edge's own namespace.
4310            self.validate_edge_relation_endpoints(&record_tok, edge.source_id, edge.target_id, r)
4311                .await?;
4312            edge.relation = r;
4313            changed_fields.push("relation");
4314        }
4315        if let Some(w) = patch.weight {
4316            // Reject non-finite or out-of-range weight explicitly; do not silently
4317            // clamp invalid caller input (coding-standards §608-622).
4318            if !w.is_finite() || !(0.0..=1.0).contains(&w) {
4319                return Err(RuntimeError::InvalidInput(format!(
4320                    "edge weight must be a finite value in [0.0, 1.0]; got {w}"
4321                )));
4322            }
4323            edge.weight = w;
4324            changed_fields.push("weight");
4325        }
4326        if let Some(props) = patch.properties {
4327            edge.metadata = Some(props);
4328        }
4329
4330        // For symmetric relations, canonicalise endpoint order and check
4331        // for natural-key conflicts regardless of whether endpoints were flipped.
4332        //
4333        // The raw-SQL path is used for ALL symmetric relations because `upsert_edge`
4334        // resolves ON CONFLICT(namespace,id) first and cannot detect a duplicate at
4335        // the natural key (namespace, source_id, target_id, relation) with a different
4336        // id. Bug-fix: this path must also run when endpoints are already canonical
4337        // (endpoints_flipped=false) to catch conflicts arising from a relation change
4338        // that collides with an existing canonical row.
4339        let (canon_src, canon_tgt) =
4340            canonical_edge_endpoints(edge.relation, edge.source_id, edge.target_id);
4341
4342        if edge.relation.is_symmetric() {
4343            // Raw-SQL path (mirrors merge_entity_sql).
4344            // Use record_ns (the stored edge namespace) — NOT token.namespace() — so that
4345            // WHERE namespace = ?N predicates match the actual row.
4346            let ns = record_ns.clone();
4347            let edge_id_str = edge_id.to_string();
4348            let relation_str = edge.relation.to_string();
4349            let canon_src_str = canon_src.to_string();
4350            let canon_tgt_str = canon_tgt.to_string();
4351            let weight = edge.weight;
4352            let metadata = edge
4353                .metadata
4354                .as_ref()
4355                .map(|v| serde_json::to_string(v).unwrap_or_default());
4356            let target_backend = edge.target_backend.clone();
4357
4358            let pool = self.backend().pool_arc();
4359            // Route through the single-writer task when the write queue is
4360            // enabled; best-effort lookup degrades to the legacy pool-mutex
4361            // path (mirrors merge_entity/merge_note above).
4362            let writer_task = pool.writer_task_handle().ok().flatten();
4363
4364            // Some(surviving_id) when a canonical conflict was absorbed (the requested
4365            // edge was deleted, existing canonical row refreshed), or None when the
4366            // requested edge was updated in-place.
4367            let surviving_id: Option<String> = if let Some(writer_task) = writer_task {
4368                writer_task
4369                    .send(move |conn| {
4370                        Self::update_edge_symmetric_dml(
4371                            conn,
4372                            &ns,
4373                            &edge_id_str,
4374                            &canon_src_str,
4375                            &canon_tgt_str,
4376                            &relation_str,
4377                            weight,
4378                            metadata,
4379                            target_backend,
4380                        )
4381                        .map_err(|e| {
4382                            khive_storage::StorageError::driver(
4383                                khive_storage::StorageCapability::Graph,
4384                                "update_edge",
4385                                e,
4386                            )
4387                        })
4388                    })
4389                    .await
4390                    .map_err(RuntimeError::Storage)?
4391            } else {
4392                tokio::task::spawn_blocking(move || {
4393                    let guard = pool.writer()?;
4394                    guard.transaction(|conn| {
4395                        Self::update_edge_symmetric_dml(
4396                            conn,
4397                            &ns,
4398                            &edge_id_str,
4399                            &canon_src_str,
4400                            &canon_tgt_str,
4401                            &relation_str,
4402                            weight,
4403                            metadata,
4404                            target_backend,
4405                        )
4406                    })
4407                })
4408                .await
4409                .map_err(|e| {
4410                    RuntimeError::Internal(format!("update_edge: spawn_blocking join: {e}"))
4411                })?
4412                .map_err(RuntimeError::Sqlite)?
4413            };
4414
4415            if let Some(sid) = surviving_id {
4416                // A conflict was absorbed: re-fetch the surviving canonical row so the
4417                // caller receives its real id.
4418                // Use record_tok — the surviving row lives in the same namespace as the original.
4419                let surviving_uuid = Uuid::parse_str(&sid).map_err(|e| {
4420                    RuntimeError::Internal(format!("update_edge: surviving id parse failed: {e}"))
4421                })?;
4422                edge = self
4423                    .get_edge(&record_tok, surviving_uuid)
4424                    .await?
4425                    .ok_or_else(|| {
4426                        RuntimeError::Internal(format!(
4427                            "update_edge: surviving canonical row {surviving_uuid} vanished after update"
4428                        ))
4429                    })?;
4430            } else {
4431                // Reflect canonical endpoints in the returned edge (no conflict absorbed).
4432                edge.source_id = canon_src;
4433                edge.target_id = canon_tgt;
4434            }
4435        } else {
4436            // Non-symmetric: upsert_edge takes namespace from edge.namespace (not from the
4437            // graph store's routing namespace), so this is already record-namespace correct.
4438            // `graph` is already self.graph(&record_tok)?.
4439            graph.upsert_edge(edge.clone()).await?;
4440        }
4441
4442        // Audit event: use the record's namespace (record_ns) for the event payload.
4443        let event_store = self.events(&record_tok)?;
4444        let event = khive_storage::event::Event::new(
4445            record_ns.clone(),
4446            "update",
4447            EventKind::EdgeUpdated,
4448            SubstrateKind::Entity,
4449            "",
4450        )
4451        .with_target(edge_id)
4452        .with_payload(
4453            serde_json::json!({"id": edge_id, "namespace": record_ns, "changed_fields": changed_fields}),
4454        );
4455        event_store.append_event(event).await.map_err(|e| {
4456            RuntimeError::Internal(format!("update_edge: event store write failed: {e}"))
4457        })?;
4458
4459        Ok(edge)
4460    }
4461
4462    /// Hard-delete an edge by id.
4463    ///
4464    /// Cascades to remove any `annotates` edges whose target is the deleted edge
4465    /// (`annotates` is note → anything; deleting an edge target leaves annotation
4466    /// edges dangling if not cleaned up). Returns `true` if the primary
4467    /// edge was removed.
4468    ///
4469    /// If `edge_id` does not refer to an edge (e.g. the caller passes an entity or
4470    /// note UUID by mistake), this method returns `Ok(false)` immediately with no
4471    /// side effects — it does **not** cascade inbound edges of the non-edge record.
4472    pub async fn delete_edge(
4473        &self,
4474        token: &NamespaceToken,
4475        edge_id: Uuid,
4476        hard: bool,
4477    ) -> RuntimeResult<bool> {
4478        let mode = if hard {
4479            DeleteMode::Hard
4480        } else {
4481            DeleteMode::Soft
4482        };
4483
4484        // Fetch the edge first to obtain the record's own namespace.
4485        // By-ID ops cross namespace boundaries; all graph routing and audit
4486        // events must use the record namespace, not the caller's (mirrors update_edge).
4487        // For hard delete we also check soft-deleted rows so a soft-deleted edge
4488        // can still be purged via its edge ID.
4489        let edge = if hard {
4490            self.get_edge_including_deleted(token, edge_id).await?
4491        } else {
4492            self.get_edge(token, edge_id).await?
4493        };
4494        let Some(edge) = edge else {
4495            return Ok(false);
4496        };
4497
4498        // Derive record_ns / record_tok from the fetched edge (mirrors update_edge).
4499        let record_ns: String = edge.namespace.clone();
4500        let record_tok = NamespaceToken::for_namespace(
4501            khive_types::Namespace::parse(&record_ns)
4502                .map_err(|e| RuntimeError::Internal(format!("edge namespace invalid: {e}")))?,
4503        );
4504        let graph = self.graph(&record_tok)?;
4505
4506        // Cascade: on hard delete, remove ALL annotates edges targeting this edge — including
4507        // already-soft-deleted ones: to prevent dangling graph_edges rows. The row
4508        // delete and the cascade purge run as ONE write transaction: see
4509        // `atomic_hard_delete_with_edge_purge`.
4510        // On soft delete the cascade is skipped (data-vs-view principle: soft-deleting the base
4511        // edge does not cascade to annotation edges; only a hard purge cleans up incident rows).
4512        let deleted = if hard {
4513            self.atomic_hard_delete_with_edge_purge(edge_hard_delete_statement(edge_id), edge_id)
4514                .await?
4515        } else {
4516            graph.delete_edge(LinkId::from(edge_id), mode).await?
4517        };
4518        if deleted {
4519            // Audit event: use the record's namespace (record_ns), not the caller's namespace.
4520            let event_store = self.events(&record_tok)?;
4521            let event = khive_storage::event::Event::new(
4522                record_ns.clone(),
4523                "delete",
4524                EventKind::EdgeDeleted,
4525                SubstrateKind::Entity,
4526                "",
4527            )
4528            .with_target(edge_id)
4529            .with_payload(serde_json::json!({"id": edge_id, "namespace": record_ns, "hard": hard}));
4530            event_store.append_event(event).await.map_err(|e| {
4531                RuntimeError::Internal(format!("delete_edge: event store write failed: {e}"))
4532            })?;
4533        }
4534        Ok(deleted)
4535    }
4536
4537    /// Count edges matching `filter`.
4538    pub async fn count_edges(
4539        &self,
4540        token: &NamespaceToken,
4541        filter: crate::curation::EdgeListFilter,
4542    ) -> RuntimeResult<u64> {
4543        Ok(self.graph(token)?.count_edges(filter.into()).await?)
4544    }
4545
4546    /// Validate and construct an edge from a [`LinkSpec`] without writing to storage.
4547    ///
4548    /// Applies the full edge contract (endpoint validation, symmetric
4549    /// canonicalization, `dependency_kind` inference and metadata validation).
4550    /// Returns the constructed `Edge` on success; the caller is responsible for
4551    /// persisting it (e.g. via `upsert_edge` or `link_many`).
4552    ///
4553    /// The `token` must be a pre-authorized namespace token from the dispatch
4554    /// layer. If `spec.namespace` is set it must match `token.namespace()`;
4555    /// a mismatch returns `RuntimeError::InvalidInput`.
4556    pub async fn build_edge(&self, token: &NamespaceToken, spec: &LinkSpec) -> RuntimeResult<Edge> {
4557        let ns_str = match &spec.namespace {
4558            Some(s) => {
4559                let spec_ns = crate::Namespace::parse(s)
4560                    .map_err(|e| RuntimeError::InvalidInput(format!("invalid namespace: {e}")))?;
4561                if &spec_ns != token.namespace() {
4562                    return Err(RuntimeError::InvalidInput(
4563                        "LinkSpec namespace does not match token namespace".into(),
4564                    ));
4565                }
4566                s.as_str()
4567            }
4568            None => token.namespace().as_str(),
4569        };
4570        self.validate_edge_relation_endpoints(token, spec.source_id, spec.target_id, spec.relation)
4571            .await?;
4572        let (source_id, target_id) =
4573            canonical_edge_endpoints(spec.relation, spec.source_id, spec.target_id);
4574        let metadata = if spec.relation == EdgeRelation::DependsOn {
4575            // By-ID, unfiltered — matches the namespace-agnostic endpoint validation
4576            // above. The visible-set-scoped `resolve` would silently drop the
4577            // dependency_kind inference for endpoints validation now allows outside
4578            // the caller's visible set.
4579            match (
4580                self.resolve_edge_endpoint(token, source_id).await?,
4581                self.resolve_edge_endpoint(token, target_id).await?,
4582            ) {
4583                (Some(Resolved::Entity(src_e)), Some(Resolved::Entity(tgt_e))) => {
4584                    merge_dependency_kind(&src_e.kind, &tgt_e.kind, spec.metadata.clone())
4585                }
4586                _ => spec.metadata.clone(),
4587            }
4588        } else {
4589            spec.metadata.clone()
4590        };
4591        validate_edge_metadata(spec.relation, metadata.as_ref())?;
4592        let now = chrono::Utc::now();
4593        Ok(Edge {
4594            id: LinkId::from(Uuid::new_v4()),
4595            namespace: ns_str.to_string(),
4596            source_id,
4597            target_id,
4598            relation: spec.relation,
4599            weight: spec.weight,
4600            created_at: now,
4601            updated_at: now,
4602            deleted_at: None,
4603            metadata,
4604            target_backend: None,
4605        })
4606    }
4607
4608    /// Validate and atomically upsert a batch of edges.
4609    ///
4610    /// All edges are validated and constructed with `build_edge` before any
4611    /// write. If validation fails for any entry the entire batch is rejected
4612    /// (no writes occur). On success, all edges are persisted in a single
4613    /// atomic transaction via `upsert_edges`.
4614    ///
4615    /// After the bulk upsert, each edge is read back by its natural key
4616    /// (namespace, source_id, target_id, relation) so that the returned IDs
4617    /// are always the persisted row IDs, not the locally-generated UUIDs that
4618    /// may have been displaced by an ON CONFLICT DO UPDATE. This mirrors the
4619    /// same read-back applied to singleton `link()` and prevents phantom-ID
4620    /// exposure when callers upsert overlapping triples with `verbose=true`.
4621    ///
4622    /// All specs must share the same namespace; the namespace is taken from
4623    /// `token` (or validated against it if `spec.namespace` is set).
4624    pub async fn link_many(
4625        &self,
4626        token: &NamespaceToken,
4627        specs: Vec<LinkSpec>,
4628    ) -> RuntimeResult<Vec<Edge>> {
4629        if specs.is_empty() {
4630            return Ok(vec![]);
4631        }
4632        let mut edges = Vec::with_capacity(specs.len());
4633        for spec in &specs {
4634            edges.push(self.build_edge(token, spec).await?);
4635        }
4636        // `upsert_edges_guarded` re-checks every edge's endpoints as part of the
4637        // same write, not the separate per-spec `build_edge` validation reads
4638        // above. A concurrent hard-delete of any endpoint landing between those
4639        // reads and this write aborts the whole batch (all-or-nothing, no
4640        // partial write) instead of persisting a dangling edge. The failing
4641        // entry's index and its missing endpoint(s) come from the guard's own
4642        // in-transaction pre-check (`GuardedBatchOutcome::refused`), not a
4643        // post-hoc re-read of the batch after the write already failed.
4644        let outcome = self
4645            .graph(token)?
4646            .upsert_edges_guarded(edges.clone())
4647            .await?;
4648        if let Some(refusal) = outcome.refused {
4649            return Err(RuntimeError::GuardedWriteFailed(GuardedWriteFailure {
4650                entry_index: Some(refusal.entry_index),
4651                missing_source: refusal
4652                    .missing
4653                    .source
4654                    .then_some(edges[refusal.entry_index].source_id),
4655                missing_target: refusal
4656                    .missing
4657                    .target
4658                    .then_some(edges[refusal.entry_index].target_id),
4659            }));
4660        }
4661        if outcome.summary.affected != edges.len() as u64 {
4662            return Err(RuntimeError::NotFound(format!(
4663                "link_many: one or more edge endpoints no longer exist at write time: {}",
4664                outcome.summary.first_error
4665            )));
4666        }
4667
4668        // Read back each persisted edge by natural key so callers always
4669        // receive the stored row ID, not the pre-upsert generated UUID.
4670        let mut persisted = Vec::with_capacity(edges.len());
4671        for edge in &edges {
4672            let row = self
4673                .list_edges(
4674                    token,
4675                    crate::curation::EdgeListFilter {
4676                        source_id: Some(edge.source_id),
4677                        target_id: Some(edge.target_id),
4678                        relations: vec![edge.relation],
4679                        ..Default::default()
4680                    },
4681                    1,
4682                    0,
4683                )
4684                .await?
4685                .into_iter()
4686                .next()
4687                .ok_or_else(|| {
4688                    crate::RuntimeError::Internal(format!(
4689                        "upsert_edges succeeded but natural-key lookup for ({}, {}, {}) returned nothing",
4690                        edge.source_id, edge.target_id, edge.relation.as_str()
4691                    ))
4692                })?;
4693            persisted.push(row);
4694        }
4695        Ok(persisted)
4696    }
4697
4698    /// Create a batch of entities atomically.
4699    ///
4700    /// All specs are validated before any write. If ANY spec fails validation
4701    /// (unknown kind, empty name, secret-gate violation), the method returns
4702    /// that error and no entities are written.
4703    ///
4704    /// Storage writes are issued as ONE `upsert_entities` call followed by ONE
4705    /// `upsert_documents` call — the same primitives that the single-entity path
4706    /// uses, but batched. Embedding is intentionally skipped: bulk structural
4707    /// ingest is the expected use-case, and dense vectors are backfilled later
4708    /// via a `reindex` call. Callers that need immediate vector search
4709    /// immediately after creation should use per-entity `create_entity` instead.
4710    ///
4711    /// On FTS failure, every newly written entity row is hard-deleted to maintain
4712    /// consistency (mirrors the single-entity rollback in `create_entity`).
4713    pub async fn create_many(
4714        &self,
4715        token: &NamespaceToken,
4716        specs: Vec<EntityCreateSpec>,
4717    ) -> RuntimeResult<Vec<Entity>> {
4718        if specs.is_empty() {
4719            return Ok(vec![]);
4720        }
4721        let ns = token.namespace().as_str();
4722
4723        // Phase 1: validate ALL specs before any write.
4724        // Includes entity-type validation via the pack-installed validator when available.
4725        // Any validation failure here guarantees zero rows are written.
4726        let mut entities = Vec::with_capacity(specs.len());
4727        for spec in &specs {
4728            self.validate_entity_kind(&spec.kind)?;
4729            // Validate entity_type at the runtime layer via pack-installed callback.
4730            // When no validator is installed (bare runtime, unit tests without packs),
4731            // the type passes through unchanged — same skip-when-absent pattern as
4732            // validate_entity_kind. The handler layer remains the primary enforcement point.
4733            let validated_type =
4734                self.validate_entity_type_for_kind(&spec.kind, spec.entity_type.as_deref())?;
4735            if spec.name.trim().is_empty() {
4736                return Err(RuntimeError::InvalidInput("name must not be empty".into()));
4737            }
4738            crate::secret_gate::check(&spec.name)?;
4739            if let Some(d) = &spec.description {
4740                crate::secret_gate::check(d)?;
4741            }
4742            if let Some(ref p) = spec.properties {
4743                crate::secret_gate::check_json(p)?;
4744            }
4745            crate::secret_gate::check_tags(&spec.tags)?;
4746
4747            let mut entity =
4748                Entity::new(ns, &spec.kind, &spec.name).with_entity_type(validated_type.as_deref());
4749            if let Some(d) = &spec.description {
4750                entity = entity.with_description(d);
4751            }
4752            if let Some(p) = spec.properties.clone() {
4753                entity = entity.with_properties(p);
4754            }
4755            if !spec.tags.is_empty() {
4756                entity = entity.with_tags(spec.tags.clone());
4757            }
4758            entities.push(entity);
4759        }
4760
4761        // Phase 2: single bulk entity write.
4762        // Capture the BatchWriteSummary to detect partial failures.
4763        // The store commits the transaction even when some rows fail (per-row error
4764        // isolation). If any row failed, compensate by hard-deleting the rows that DID
4765        // land, then return Err so the caller sees zero net writes.
4766        //
4767        // NOTE: this compensation path (delete-on-partial-failure) is a stopgap until
4768        // a true single-transaction bulk primitive is available in the entity store.
4769        // That primitive (writing entity rows and FTS rows in one SQL transaction) is
4770        // tracked as a follow-up issue.
4771        let entity_summary = self
4772            .entities(token)?
4773            .upsert_entities(entities.clone())
4774            .await?;
4775
4776        if entity_summary.failed > 0 {
4777            // Compensate: hard-delete any entity rows that did land.
4778            if let Ok(store) = self.entities(token) {
4779                for entity in &entities {
4780                    if let Err(ce) = store.delete_entity(entity.id, DeleteMode::Hard).await {
4781                        tracing::error!(
4782                            error = %ce,
4783                            id = %entity.id,
4784                            "create_many: failed to roll back entity row after partial entity write"
4785                        );
4786                    }
4787                }
4788            }
4789            return Err(RuntimeError::Internal(format!(
4790                "create_many: {}/{} entity rows failed to write (first error: {}); \
4791                 all rows rolled back",
4792                entity_summary.failed, entity_summary.attempted, entity_summary.first_error
4793            )));
4794        }
4795
4796        // Phase 3: single bulk FTS write.
4797        //
4798        // The FTS store commits partial batches and signals per-document failures
4799        // via BatchWriteSummary.failed (same as the entity store in Phase 2).
4800        // We must capture the summary and treat failed > 0 as an error.
4801        //
4802        // Compensation is symmetric: on any FTS failure (Err or failed > 0),
4803        // we first delete any FTS documents that may have landed, then
4804        // hard-delete the entity rows.  This order matters: the entity delete
4805        // is the authoritative write; FTS is a derived index.  Cleaning FTS
4806        // first avoids a window where entity rows are gone but stale FTS rows
4807        // survive.
4808        let docs: Vec<_> = entities.iter().map(entity_fts_document).collect();
4809
4810        #[cfg(any(test, feature = "fault-injection"))]
4811        let fts_many_inject = {
4812            let mut g = FTS_FAIL_MANY_NS.lock().unwrap();
4813            if g.as_deref() == Some(ns) {
4814                *g = None;
4815                true
4816            } else {
4817                false
4818            }
4819        };
4820        #[cfg(not(any(test, feature = "fault-injection")))]
4821        let fts_many_inject = false;
4822
4823        // Partial-failure seam: returns Ok(summary) with failed > 0 so the
4824        // `summary.failed > 0` rollback branch is exercised in tests.
4825        #[cfg(any(test, feature = "fault-injection"))]
4826        let fts_many_inject_partial = {
4827            let mut g = FTS_FAIL_MANY_PARTIAL_NS.lock().unwrap();
4828            if g.as_deref() == Some(ns) {
4829                *g = None;
4830                true
4831            } else {
4832                false
4833            }
4834        };
4835        #[cfg(not(any(test, feature = "fault-injection")))]
4836        let fts_many_inject_partial = false;
4837
4838        let fts_summary_result: RuntimeResult<BatchWriteSummary> = if fts_many_inject {
4839            Err(RuntimeError::Internal(
4840                "injected FTS failure for create_many".to_string(),
4841            ))
4842        } else if fts_many_inject_partial {
4843            Ok(BatchWriteSummary {
4844                attempted: docs.len() as u64,
4845                affected: docs.len().saturating_sub(1) as u64,
4846                failed: 1,
4847                first_error: "injected partial FTS failure for create_many".to_string(),
4848            })
4849        } else {
4850            match self.text(token) {
4851                Ok(fts) => fts.upsert_documents(docs).await.map_err(RuntimeError::from),
4852                Err(e) => Err(e),
4853            }
4854        };
4855
4856        let fts_err: Option<RuntimeError> = match fts_summary_result {
4857            Err(e) => Some(e),
4858            Ok(summary) if summary.failed > 0 => Some(RuntimeError::Internal(format!(
4859                "create_many: {}/{} FTS rows failed to index (first error: {}); \
4860                 all rows rolled back",
4861                summary.failed, summary.attempted, summary.first_error
4862            ))),
4863            Ok(_) => None,
4864        };
4865
4866        if let Some(e) = fts_err {
4867            // Clean up any FTS docs that landed before deleting entity rows.
4868            if let Ok(fts) = self.text(token) {
4869                for entity in &entities {
4870                    if let Err(ce) = fts.delete_document(ns, entity.id).await {
4871                        tracing::error!(
4872                            error = %ce,
4873                            id = %entity.id,
4874                            "create_many: failed to remove FTS doc during rollback"
4875                        );
4876                    }
4877                }
4878            }
4879            if let Ok(store) = self.entities(token) {
4880                for entity in &entities {
4881                    if let Err(ce) = store.delete_entity(entity.id, DeleteMode::Hard).await {
4882                        tracing::error!(
4883                            error = %ce,
4884                            id = %entity.id,
4885                            "create_many: failed to roll back entity row after FTS failure"
4886                        );
4887                    }
4888                }
4889            }
4890            return Err(e);
4891        }
4892
4893        // Embedding is skipped intentionally — see doc comment above.
4894        Ok(entities)
4895    }
4896}
4897
4898/// Fully specified edge creation request — input to [`KhiveRuntime::build_edge`]
4899/// and [`KhiveRuntime::link_many`].
4900#[derive(Clone, Debug)]
4901pub struct LinkSpec {
4902    pub namespace: Option<String>,
4903    pub source_id: Uuid,
4904    pub target_id: Uuid,
4905    pub relation: EdgeRelation,
4906    pub weight: f64,
4907    pub metadata: Option<serde_json::Value>,
4908}
4909
4910/// Fully specified entity creation request — input to [`KhiveRuntime::create_many`].
4911///
4912/// `entity_type` is validated at the runtime layer by the pack-installed
4913/// entity-type validator. When a validator
4914/// is installed (e.g. by `KgPack`), unknown types are rejected with the valid
4915/// set listed. When no validator is installed (bare runtime without packs),
4916/// the value passes through — the handler layer is the primary enforcement point.
4917#[derive(Clone, Debug)]
4918pub struct EntityCreateSpec {
4919    pub kind: String,
4920    pub entity_type: Option<String>,
4921    pub name: String,
4922    pub description: Option<String>,
4923    pub properties: Option<serde_json::Value>,
4924    pub tags: Vec<String>,
4925}
4926
4927// INLINE TEST JUSTIFICATION: tests here exercise private helpers (canonical_edge_endpoints,
4928// validate_edge_metadata, merge_dependency_kind, link-fail injection) and runtime methods
4929// that require pub(crate) KhiveRuntime construction. Moving them to tests/ would require
4930// pub-exporting those private helpers, which would widen the crate's public API surface
4931// undesirably. Broad behavioral tests live in tests/integration.rs.
4932#[cfg(test)]
4933mod tests {
4934    use super::*;
4935    use crate::curation::EdgeListFilter;
4936    use crate::embedder_registry::EmbedderProvider;
4937    use crate::error::RuntimeError;
4938    use crate::runtime::{KhiveRuntime, NamespaceToken};
4939    use crate::{ActorRef, Namespace};
4940    use async_trait::async_trait;
4941    use khive_storage::types::PathNode;
4942    use lattice_embed::{EmbedError, EmbeddingModel, EmbeddingService};
4943    use std::sync::atomic::{AtomicUsize, Ordering};
4944    use std::sync::Arc;
4945
4946    fn rt() -> KhiveRuntime {
4947        KhiveRuntime::memory().unwrap()
4948    }
4949
4950    // ── Custom embedder fan-out regression ──────────────────────────────────
4951    // A runtime with no `config.embedding_model` but a custom registered
4952    // embedder must fan out create_note through that embedder and store a
4953    // vector so recall can find the note.
4954
4955    /// Trivial constant-vector embedding service.  The model argument is ignored;
4956    /// the service always returns a synthetic `dims × 1.0f32` vector.
4957    struct ConstVecService {
4958        dims: usize,
4959    }
4960
4961    #[async_trait]
4962    impl EmbeddingService for ConstVecService {
4963        async fn embed(
4964            &self,
4965            texts: &[String],
4966            _model: EmbeddingModel,
4967        ) -> std::result::Result<Vec<Vec<f32>>, EmbedError> {
4968            Ok(texts.iter().map(|_| vec![1.0_f32; self.dims]).collect())
4969        }
4970
4971        fn supports_model(&self, _model: EmbeddingModel) -> bool {
4972            true
4973        }
4974
4975        fn name(&self) -> &'static str {
4976            "const-vec"
4977        }
4978    }
4979
4980    struct ConstVecProvider {
4981        provider_name: String,
4982        dims: usize,
4983        pub build_count: Arc<AtomicUsize>,
4984    }
4985
4986    impl ConstVecProvider {
4987        fn new(name: &str, dims: usize) -> (Self, Arc<AtomicUsize>) {
4988            let counter = Arc::new(AtomicUsize::new(0));
4989            let provider = Self {
4990                provider_name: name.to_owned(),
4991                dims,
4992                build_count: Arc::clone(&counter),
4993            };
4994            (provider, counter)
4995        }
4996    }
4997
4998    #[async_trait]
4999    impl EmbedderProvider for ConstVecProvider {
5000        fn name(&self) -> &str {
5001            &self.provider_name
5002        }
5003
5004        fn dimensions(&self) -> usize {
5005            self.dims
5006        }
5007
5008        async fn build(&self) -> crate::error::RuntimeResult<Arc<dyn EmbeddingService>> {
5009            self.build_count.fetch_add(1, Ordering::SeqCst);
5010            Ok(Arc::new(ConstVecService { dims: self.dims }))
5011        }
5012    }
5013
5014    /// Custom embedder with no lattice model in config must participate in
5015    /// fan-out: the gate must check `registered_embedding_model_names()`, not
5016    /// `config().embedding_model.is_some()`: the latter falls through to
5017    /// `vec![]` when only a custom provider is registered.
5018    #[tokio::test]
5019    async fn custom_embedder_only_runtime_fanout_stores_vector() {
5020        const MODEL_NAME: &str = "test-custom-encoder";
5021        const DIMS: usize = 8;
5022
5023        // Build a runtime with no lattice embedding_model.
5024        let rt = KhiveRuntime::memory().unwrap();
5025
5026        // Register the custom provider — this is the only embedder configured.
5027        let (provider, _counter) = ConstVecProvider::new(MODEL_NAME, DIMS);
5028        rt.register_embedder(provider);
5029
5030        // Sanity: config.embedding_model is None, but the registry has one entry.
5031        assert!(rt.config().embedding_model.is_none());
5032        assert_eq!(rt.registered_embedding_model_names(), vec![MODEL_NAME]);
5033
5034        let tok = NamespaceToken::local();
5035
5036        // create_note should fan out to the custom embedder and store a vector.
5037        let note = rt
5038            .create_note(
5039                &tok,
5040                "memory",
5041                None,
5042                "custom embedder integration test content",
5043                Some(0.7),
5044                None,
5045                vec![],
5046            )
5047            .await
5048            .expect("create_note with custom-only embedder must succeed");
5049
5050        // Verify: a vector was written in the custom model's store.
5051        use khive_storage::types::VectorSearchRequest;
5052        let query_vec = vec![1.0_f32; DIMS];
5053        let hits = rt
5054            .vectors_for_model(&tok, MODEL_NAME)
5055            .expect("vector store for custom model must be accessible")
5056            .search(VectorSearchRequest {
5057                query_vectors: vec![query_vec],
5058                top_k: 5,
5059                namespace: Some(tok.namespace().as_str().to_string()),
5060                kind: Some(khive_types::SubstrateKind::Note),
5061                embedding_model: Some(MODEL_NAME.to_string()),
5062                filter: None,
5063                backend_hints: None,
5064            })
5065            .await
5066            .expect("vector search succeeds");
5067
5068        assert!(
5069            hits.iter().any(|h| h.subject_id == note.id),
5070            "custom embedder must have written a vector for note {}: hits={hits:?}",
5071            note.id
5072        );
5073    }
5074
5075    /// Custom-only embedder participates in `embed_with_model` so recall
5076    /// fan-out also works: the lattice alias parse must be optional, with
5077    /// the embedder registry consulted directly, since requiring a lattice
5078    /// alias would reject valid custom provider names with `UnknownModel`.
5079    #[tokio::test]
5080    async fn embed_with_model_accepts_custom_provider_name() {
5081        const MODEL_NAME: &str = "my-custom-enc";
5082        const DIMS: usize = 4;
5083
5084        let rt = KhiveRuntime::memory().unwrap();
5085        let (provider, _counter) = ConstVecProvider::new(MODEL_NAME, DIMS);
5086        rt.register_embedder(provider);
5087
5088        let result = rt
5089            .embed_with_model(MODEL_NAME, "hello world")
5090            .await
5091            .expect("embed_with_model must accept custom provider names");
5092
5093        assert_eq!(
5094            result.len(),
5095            DIMS,
5096            "embedding dimension must match provider"
5097        );
5098        assert!(
5099            result.iter().all(|&v| (v - 1.0_f32).abs() < 1e-6),
5100            "ConstVecService must produce all-ones vector; got: {result:?}"
5101        );
5102    }
5103
5104    /// `embed_with_model` must still reject names that are not in the
5105    /// registry (neither lattice aliases nor custom providers).
5106    #[tokio::test]
5107    async fn embed_with_model_rejects_unregistered_name() {
5108        let rt = KhiveRuntime::memory().unwrap();
5109        let result = rt.embed_with_model("nonexistent-model", "hello").await;
5110        assert!(
5111            matches!(result.unwrap_err(), RuntimeError::UnknownModel(ref n) if n == "nonexistent-model"),
5112            "unregistered model name must return UnknownModel"
5113        );
5114    }
5115
5116    // ── No-embeddings config regression ─────────────────────────────────────
5117    // `RuntimeConfig::no_embeddings()` must register zero embedders, so
5118    // `create_note` never attempts to lazily build a lattice embedding model —
5119    // this is what lets `memory.remember` succeed on a machine with no local
5120    // model files present.
5121
5122    #[tokio::test]
5123    async fn no_embeddings_config_registers_zero_embedders() {
5124        let config = crate::config::RuntimeConfig {
5125            db_path: None,
5126            packs: vec!["kg".to_string()],
5127            ..crate::config::RuntimeConfig::no_embeddings()
5128        };
5129        let rt = KhiveRuntime::new(config).expect("runtime construction must succeed");
5130
5131        assert!(rt.config().embedding_model.is_none());
5132        assert!(
5133            rt.registered_embedding_model_names().is_empty(),
5134            "no_embeddings() runtime must register zero embedders"
5135        );
5136    }
5137
5138    #[tokio::test]
5139    async fn no_embeddings_runtime_create_note_succeeds_without_model_fanout() {
5140        let config = crate::config::RuntimeConfig {
5141            db_path: None,
5142            packs: vec!["kg".to_string()],
5143            ..crate::config::RuntimeConfig::no_embeddings()
5144        };
5145        let rt = KhiveRuntime::new(config).expect("runtime construction must succeed");
5146        let tok = NamespaceToken::local();
5147
5148        // With zero registered embedders, create_note's embed fan-out list is
5149        // empty and no lattice model build is ever attempted -- the write must
5150        // succeed, degrading to FTS-only.
5151        let note = rt
5152            .create_note(
5153                &tok,
5154                "memory",
5155                None,
5156                "issue-396 regression: model-less remember must succeed",
5157                Some(0.7),
5158                None,
5159                vec![],
5160            )
5161            .await
5162            .expect("create_note must succeed with zero registered embedders");
5163
5164        assert_eq!(
5165            note.content,
5166            "issue-396 regression: model-less remember must succeed"
5167        );
5168    }
5169
5170    #[tokio::test]
5171    async fn update_edge_changes_weight() {
5172        let rt = rt();
5173        let tok = NamespaceToken::local();
5174        let a = rt
5175            .create_entity(&tok, "concept", None, "A", None, None, vec![])
5176            .await
5177            .unwrap();
5178        let b = rt
5179            .create_entity(&tok, "concept", None, "B", None, None, vec![])
5180            .await
5181            .unwrap();
5182        let edge = rt
5183            .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
5184            .await
5185            .unwrap();
5186        let edge_id: Uuid = edge.id.into();
5187
5188        let updated = rt
5189            .update_edge(
5190                &tok,
5191                edge_id,
5192                crate::curation::EdgePatch {
5193                    weight: Some(0.5),
5194                    ..Default::default()
5195                },
5196            )
5197            .await
5198            .unwrap();
5199        assert!((updated.weight - 0.5).abs() < 0.001);
5200    }
5201
5202    /// Regression test: `update_edge_symmetric_dml` previously stored
5203    /// `updated_at` via `chrono::Utc::now().timestamp()` (SECONDS) while every
5204    /// other `graph_edges` write path (`edge_upsert_statement`,
5205    /// `edge_soft_delete_statement`) uses `timestamp_micros()`: a genuine
5206    /// pre-existing bug, found while unifying this raw-SQL path with the
5207    /// atomic builder (which already used `timestamp_micros()` correctly)
5208    /// onto the shared `EDGE_SYMMETRIC_*_SQL` text. A seconds value misread as
5209    /// microseconds round-trips to a date a few minutes after the Unix epoch,
5210    /// not "now".
5211    #[tokio::test]
5212    async fn update_edge_symmetric_relation_stores_microsecond_updated_at() {
5213        let rt = rt();
5214        let tok = NamespaceToken::local();
5215        let a = rt
5216            .create_entity(&tok, "concept", None, "A", None, None, vec![])
5217            .await
5218            .unwrap();
5219        let b = rt
5220            .create_entity(&tok, "concept", None, "B", None, None, vec![])
5221            .await
5222            .unwrap();
5223        let edge = rt
5224            .link(&tok, a.id, b.id, EdgeRelation::CompetesWith, 1.0, None)
5225            .await
5226            .unwrap();
5227        let edge_id: Uuid = edge.id.into();
5228
5229        let before = chrono::Utc::now();
5230        let updated = rt
5231            .update_edge(
5232                &tok,
5233                edge_id,
5234                crate::curation::EdgePatch {
5235                    weight: Some(0.5),
5236                    ..Default::default()
5237                },
5238            )
5239            .await
5240            .unwrap();
5241
5242        let drift = (updated.updated_at - before).num_seconds().abs();
5243        assert!(
5244            drift < 60,
5245            "updated_at must round-trip as a recent timestamp (micros, not \
5246             seconds); got {:?}, expected within 60s of {:?}",
5247            updated.updated_at,
5248            before
5249        );
5250    }
5251
5252    #[tokio::test]
5253    async fn update_edge_changes_relation() {
5254        let rt = rt();
5255        let tok = NamespaceToken::local();
5256        let a = rt
5257            .create_entity(&tok, "concept", None, "A", None, None, vec![])
5258            .await
5259            .unwrap();
5260        let b = rt
5261            .create_entity(&tok, "concept", None, "B", None, None, vec![])
5262            .await
5263            .unwrap();
5264        let edge = rt
5265            .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
5266            .await
5267            .unwrap();
5268        let edge_id: Uuid = edge.id.into();
5269
5270        let updated = rt
5271            .update_edge(
5272                &tok,
5273                edge_id,
5274                crate::curation::EdgePatch {
5275                    relation: Some(EdgeRelation::VariantOf),
5276                    ..Default::default()
5277                },
5278            )
5279            .await
5280            .unwrap();
5281        assert_eq!(updated.relation, EdgeRelation::VariantOf);
5282    }
5283
5284    // ---- update_edge endpoint validation ----
5285
5286    // update_edge: note→entity annotates → set relation=Supersedes → InvalidInput (crossing).
5287    // Edge must NOT be mutated in the store.
5288    #[tokio::test]
5289    async fn update_edge_annotates_note_to_entity_set_supersedes_returns_invalid_input() {
5290        let rt = rt();
5291        let tok = NamespaceToken::local();
5292        let note = rt
5293            .create_note(&tok, "observation", None, "a note", Some(0.5), None, vec![])
5294            .await
5295            .unwrap();
5296        let entity = rt
5297            .create_entity(&tok, "concept", None, "E", None, None, vec![])
5298            .await
5299            .unwrap();
5300        // Create a valid note→entity annotates edge.
5301        let edge = rt
5302            .link(&tok, note.id, entity.id, EdgeRelation::Annotates, 1.0, None)
5303            .await
5304            .unwrap();
5305        let edge_id: Uuid = edge.id.into();
5306
5307        // Attempt to change relation to Supersedes (crossing substrates → invalid).
5308        let result = rt
5309            .update_edge(
5310                &tok,
5311                edge_id,
5312                crate::curation::EdgePatch {
5313                    relation: Some(EdgeRelation::Supersedes),
5314                    ..Default::default()
5315                },
5316            )
5317            .await;
5318        assert!(
5319            matches!(result, Err(RuntimeError::InvalidInput(_))),
5320            "update to Supersedes on note→entity edge must return InvalidInput, got {result:?}"
5321        );
5322
5323        // Edge must NOT be mutated — re-fetch and verify relation unchanged.
5324        let fetched = rt.get_edge(&tok, edge_id).await.unwrap().unwrap();
5325        assert_eq!(
5326            fetched.relation,
5327            EdgeRelation::Annotates,
5328            "edge relation must be unchanged after failed update"
5329        );
5330    }
5331
5332    // update_edge: entity→entity extends → set relation=Annotates → InvalidInput
5333    // (annotates source must be a note).
5334    #[tokio::test]
5335    async fn update_edge_entity_to_entity_set_annotates_returns_invalid_input() {
5336        let rt = rt();
5337        let tok = NamespaceToken::local();
5338        let a = rt
5339            .create_entity(&tok, "concept", None, "A", None, None, vec![])
5340            .await
5341            .unwrap();
5342        let b = rt
5343            .create_entity(&tok, "concept", None, "B", None, None, vec![])
5344            .await
5345            .unwrap();
5346        let edge = rt
5347            .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
5348            .await
5349            .unwrap();
5350        let edge_id: Uuid = edge.id.into();
5351
5352        let result = rt
5353            .update_edge(
5354                &tok,
5355                edge_id,
5356                crate::curation::EdgePatch {
5357                    relation: Some(EdgeRelation::Annotates),
5358                    ..Default::default()
5359                },
5360            )
5361            .await;
5362        assert!(
5363            matches!(result, Err(RuntimeError::InvalidInput(_))),
5364            "update to Annotates on entity→entity edge must return InvalidInput, got {result:?}"
5365        );
5366    }
5367
5368    // update_edge: entity→entity extends → set relation=Supersedes → Ok
5369    // (entity→entity is valid for supersedes).
5370    #[tokio::test]
5371    async fn update_edge_entity_to_entity_set_supersedes_succeeds() {
5372        let rt = rt();
5373        let tok = NamespaceToken::local();
5374        let a = rt
5375            .create_entity(&tok, "concept", None, "A", None, None, vec![])
5376            .await
5377            .unwrap();
5378        let b = rt
5379            .create_entity(&tok, "concept", None, "B", None, None, vec![])
5380            .await
5381            .unwrap();
5382        let edge = rt
5383            .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
5384            .await
5385            .unwrap();
5386        let edge_id: Uuid = edge.id.into();
5387
5388        let updated = rt
5389            .update_edge(
5390                &tok,
5391                edge_id,
5392                crate::curation::EdgePatch {
5393                    relation: Some(EdgeRelation::Supersedes),
5394                    ..Default::default()
5395                },
5396            )
5397            .await
5398            .unwrap();
5399        assert_eq!(updated.relation, EdgeRelation::Supersedes);
5400
5401        // Verify persisted.
5402        let fetched = rt.get_edge(&tok, edge_id).await.unwrap().unwrap();
5403        assert_eq!(fetched.relation, EdgeRelation::Supersedes);
5404    }
5405
5406    // update_edge: weight-only (relation = None) → Ok, no validation, unchanged relation.
5407    #[tokio::test]
5408    async fn update_edge_weight_only_skips_validation() {
5409        let rt = rt();
5410        let tok = NamespaceToken::local();
5411        let a = rt
5412            .create_entity(&tok, "concept", None, "A", None, None, vec![])
5413            .await
5414            .unwrap();
5415        let b = rt
5416            .create_entity(&tok, "concept", None, "B", None, None, vec![])
5417            .await
5418            .unwrap();
5419        let edge = rt
5420            .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
5421            .await
5422            .unwrap();
5423        let edge_id: Uuid = edge.id.into();
5424
5425        let updated = rt
5426            .update_edge(
5427                &tok,
5428                edge_id,
5429                crate::curation::EdgePatch {
5430                    weight: Some(0.3),
5431                    ..Default::default()
5432                },
5433            )
5434            .await
5435            .unwrap();
5436        assert_eq!(updated.relation, EdgeRelation::Extends);
5437        assert!((updated.weight - 0.3).abs() < 0.001);
5438    }
5439
5440    // update_edge: entity→entity extends → set relation=VariantOf (same class) → Ok.
5441    #[tokio::test]
5442    async fn update_edge_same_class_relation_change_succeeds() {
5443        let rt = rt();
5444        let tok = NamespaceToken::local();
5445        let a = rt
5446            .create_entity(&tok, "concept", None, "A", None, None, vec![])
5447            .await
5448            .unwrap();
5449        let b = rt
5450            .create_entity(&tok, "concept", None, "B", None, None, vec![])
5451            .await
5452            .unwrap();
5453        let edge = rt
5454            .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
5455            .await
5456            .unwrap();
5457        let edge_id: Uuid = edge.id.into();
5458
5459        let updated = rt
5460            .update_edge(
5461                &tok,
5462                edge_id,
5463                crate::curation::EdgePatch {
5464                    relation: Some(EdgeRelation::VariantOf),
5465                    ..Default::default()
5466                },
5467            )
5468            .await
5469            .unwrap();
5470        assert_eq!(updated.relation, EdgeRelation::VariantOf);
5471    }
5472
5473    #[tokio::test]
5474    async fn list_edges_filters_by_relation() {
5475        let rt = rt();
5476        let tok = NamespaceToken::local();
5477        let a = rt
5478            .create_entity(&tok, "concept", None, "A", None, None, vec![])
5479            .await
5480            .unwrap();
5481        let b = rt
5482            .create_entity(&tok, "concept", None, "B", None, None, vec![])
5483            .await
5484            .unwrap();
5485        let c = rt
5486            .create_entity(&tok, "concept", None, "C", None, None, vec![])
5487            .await
5488            .unwrap();
5489
5490        rt.link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
5491            .await
5492            .unwrap();
5493        rt.link(&tok, a.id, c.id, EdgeRelation::Enables, 1.0, None)
5494            .await
5495            .unwrap();
5496
5497        let filter = EdgeListFilter {
5498            relations: vec![EdgeRelation::Extends],
5499            ..Default::default()
5500        };
5501        let edges = rt.list_edges(&tok, filter, 100, 0).await.unwrap();
5502        assert_eq!(edges.len(), 1);
5503        assert_eq!(edges[0].relation, EdgeRelation::Extends);
5504    }
5505
5506    #[tokio::test]
5507    async fn list_edges_filters_by_source() {
5508        let rt = rt();
5509        let tok = NamespaceToken::local();
5510        let a = rt
5511            .create_entity(&tok, "concept", None, "A", None, None, vec![])
5512            .await
5513            .unwrap();
5514        let b = rt
5515            .create_entity(&tok, "concept", None, "B", None, None, vec![])
5516            .await
5517            .unwrap();
5518        let c = rt
5519            .create_entity(&tok, "concept", None, "C", None, None, vec![])
5520            .await
5521            .unwrap();
5522        let d = rt
5523            .create_entity(&tok, "concept", None, "D", None, None, vec![])
5524            .await
5525            .unwrap();
5526
5527        rt.link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
5528            .await
5529            .unwrap();
5530        rt.link(&tok, c.id, d.id, EdgeRelation::Extends, 1.0, None)
5531            .await
5532            .unwrap();
5533
5534        let filter = EdgeListFilter {
5535            source_id: Some(a.id),
5536            ..Default::default()
5537        };
5538        let edges = rt.list_edges(&tok, filter, 100, 0).await.unwrap();
5539        assert_eq!(edges.len(), 1);
5540        let src: Uuid = edges[0].source_id;
5541        assert_eq!(src, a.id);
5542    }
5543
5544    /// Regression: `offset` was hard-coded to 0 in `list_edges`, so every
5545    /// page returned the identical first rows. Pages must now tile the full
5546    /// matching set with no gaps or duplicates, and an out-of-range offset
5547    /// must return empty rather than page 1.
5548    #[tokio::test]
5549    async fn list_edges_offset_pages_through_full_set() {
5550        let rt = rt();
5551        let tok = NamespaceToken::local();
5552        let a = rt
5553            .create_entity(&tok, "concept", None, "A", None, None, vec![])
5554            .await
5555            .unwrap();
5556        for i in 0..5 {
5557            let t = rt
5558                .create_entity(&tok, "concept", None, &format!("T{i}"), None, None, vec![])
5559                .await
5560                .unwrap();
5561            rt.link(&tok, a.id, t.id, EdgeRelation::Extends, 1.0, None)
5562                .await
5563                .unwrap();
5564        }
5565
5566        let filter = EdgeListFilter {
5567            source_id: Some(a.id),
5568            relations: vec![EdgeRelation::Extends],
5569            ..Default::default()
5570        };
5571
5572        let page0 = rt.list_edges(&tok, filter.clone(), 2, 0).await.unwrap();
5573        let page1 = rt.list_edges(&tok, filter.clone(), 2, 2).await.unwrap();
5574        let page2 = rt.list_edges(&tok, filter.clone(), 2, 4).await.unwrap();
5575        assert_eq!(page0.len(), 2);
5576        assert_eq!(page1.len(), 2);
5577        assert_eq!(page2.len(), 1);
5578
5579        let ids = |p: &[Edge]| p.iter().map(|e| Uuid::from(e.id)).collect::<Vec<_>>();
5580        assert_ne!(ids(&page0), ids(&page1), "page 2 must differ from page 1");
5581
5582        let mut all_ids: Vec<Uuid> = ids(&page0)
5583            .into_iter()
5584            .chain(ids(&page1))
5585            .chain(ids(&page2))
5586            .collect();
5587        all_ids.sort();
5588        all_ids.dedup();
5589        assert_eq!(all_ids.len(), 5, "pages must tile the full edge set");
5590
5591        let empty = rt.list_edges(&tok, filter.clone(), 2, 100).await.unwrap();
5592        assert!(
5593            empty.is_empty(),
5594            "offset past the end must return empty, not page 1"
5595        );
5596    }
5597
5598    /// `list_edges_after` seeks via `id > cursor` against the
5599    /// `(namespace, id)` primary key index instead of paging through OFFSET,
5600    /// so cost does not grow with how deep the walk goes.
5601    #[tokio::test]
5602    async fn list_edges_after_keyset_tiles_full_set() {
5603        let rt = rt();
5604        let tok = NamespaceToken::local();
5605        let a = rt
5606            .create_entity(&tok, "concept", None, "A", None, None, vec![])
5607            .await
5608            .unwrap();
5609        for i in 0..5 {
5610            let t = rt
5611                .create_entity(&tok, "concept", None, &format!("K{i}"), None, None, vec![])
5612                .await
5613                .unwrap();
5614            rt.link(&tok, a.id, t.id, EdgeRelation::Extends, 1.0, None)
5615                .await
5616                .unwrap();
5617        }
5618
5619        let filter = EdgeListFilter {
5620            source_id: Some(a.id),
5621            relations: vec![EdgeRelation::Extends],
5622            ..Default::default()
5623        };
5624
5625        let mut seen = Vec::new();
5626        let mut cursor: Option<Uuid> = None;
5627        for _ in 0..20 {
5628            let (page, next) = rt
5629                .list_edges_after(&tok, filter.clone(), cursor, 2)
5630                .await
5631                .unwrap();
5632            if page.is_empty() {
5633                break;
5634            }
5635            seen.extend(page.iter().map(|e| Uuid::from(e.id)));
5636            if next.is_none() {
5637                break;
5638            }
5639            cursor = next;
5640        }
5641        seen.sort();
5642        seen.dedup();
5643        assert_eq!(seen.len(), 5, "keyset walk must tile the full edge set");
5644
5645        // Stability: repeating the same cursor returns the same page — no
5646        // drift under a fixed snapshot. The seek is `WHERE id > ?` against
5647        // the `(namespace, id)` primary key index with `ORDER BY id ASC`
5648        // matching the index order, so this is an indexed range scan, not a
5649        // full-table scan+sort (see `query_edges_after` in khive-db).
5650        let (first_a, next_a) = rt
5651            .list_edges_after(&tok, filter.clone(), None, 2)
5652            .await
5653            .unwrap();
5654        let (first_b, next_b) = rt
5655            .list_edges_after(&tok, filter.clone(), None, 2)
5656            .await
5657            .unwrap();
5658        assert_eq!(
5659            first_a.iter().map(|e| e.id.0).collect::<Vec<_>>(),
5660            first_b.iter().map(|e| e.id.0).collect::<Vec<_>>(),
5661        );
5662        assert_eq!(next_a, next_b);
5663    }
5664
5665    #[tokio::test]
5666    async fn list_edges_after_single_namespace_exact_final_page_has_no_next_after() {
5667        let rt = rt();
5668        let tok = NamespaceToken::local();
5669        let a = rt
5670            .create_entity(&tok, "concept", None, "SingleCursorA", None, None, vec![])
5671            .await
5672            .unwrap();
5673        for i in 0..4 {
5674            let t = rt
5675                .create_entity(
5676                    &tok,
5677                    "concept",
5678                    None,
5679                    &format!("SingleCursorT{i}"),
5680                    None,
5681                    None,
5682                    vec![],
5683                )
5684                .await
5685                .unwrap();
5686            rt.link(&tok, a.id, t.id, EdgeRelation::Extends, 1.0, None)
5687                .await
5688                .unwrap();
5689        }
5690
5691        let filter = EdgeListFilter {
5692            source_id: Some(a.id),
5693            relations: vec![EdgeRelation::Extends],
5694            ..Default::default()
5695        };
5696
5697        let (page1, next1) = rt
5698            .list_edges_after(&tok, filter.clone(), None, 2)
5699            .await
5700            .unwrap();
5701        assert_eq!(page1.len(), 2);
5702        let cursor = next1.expect("first page must report a cursor when two rows remain");
5703
5704        let (page2, next2) = rt
5705            .list_edges_after(&tok, filter, Some(cursor), 2)
5706            .await
5707            .unwrap();
5708        assert_eq!(page2.len(), 2);
5709        assert_eq!(
5710            next2, None,
5711            "an exact-size final single-namespace page must not report a cursor"
5712        );
5713    }
5714
5715    #[tokio::test]
5716    async fn list_edges_after_multi_namespace_exact_final_page_has_no_next_after() {
5717        let rt = rt();
5718        let ns_a = Namespace::parse("cursor-ns-a").unwrap();
5719        let ns_b = Namespace::parse("cursor-ns-b").unwrap();
5720        let tok_a = NamespaceToken::for_namespace(ns_a.clone());
5721        let tok_b = NamespaceToken::for_namespace(ns_b.clone());
5722        let visible = NamespaceToken::mint_with_visibility(ns_a, vec![ns_b], ActorRef::anonymous());
5723
5724        for (tok, prefix) in [(&tok_a, "A"), (&tok_b, "B")] {
5725            let source = rt
5726                .create_entity(
5727                    tok,
5728                    "concept",
5729                    None,
5730                    &format!("MultiCursor{prefix}Source"),
5731                    None,
5732                    None,
5733                    vec![],
5734                )
5735                .await
5736                .unwrap();
5737            for i in 0..2 {
5738                let target = rt
5739                    .create_entity(
5740                        tok,
5741                        "concept",
5742                        None,
5743                        &format!("MultiCursor{prefix}Target{i}"),
5744                        None,
5745                        None,
5746                        vec![],
5747                    )
5748                    .await
5749                    .unwrap();
5750                rt.link(tok, source.id, target.id, EdgeRelation::Extends, 1.0, None)
5751                    .await
5752                    .unwrap();
5753            }
5754        }
5755
5756        let filter = EdgeListFilter {
5757            relations: vec![EdgeRelation::Extends],
5758            ..Default::default()
5759        };
5760        let (page1, next1) = rt
5761            .list_edges_after(&visible, filter.clone(), None, 2)
5762            .await
5763            .unwrap();
5764        assert_eq!(page1.len(), 2);
5765        let cursor = next1.expect("first merged page must report a cursor when rows remain");
5766
5767        let (page2, next2) = rt
5768            .list_edges_after(&visible, filter, Some(cursor), 2)
5769            .await
5770            .unwrap();
5771        assert_eq!(page2.len(), 2);
5772        assert_eq!(
5773            next2, None,
5774            "an exact-size final multi-namespace page must not report a cursor"
5775        );
5776    }
5777
5778    /// `stats()` should be able to report a per-relation breakdown so
5779    /// auditors know the true population per relation before sampling.
5780    #[tokio::test]
5781    async fn count_edges_by_relation_matches_fixtures() {
5782        let rt = rt();
5783        let tok = NamespaceToken::local();
5784        let a = rt
5785            .create_entity(&tok, "concept", None, "A", None, None, vec![])
5786            .await
5787            .unwrap();
5788        let b = rt
5789            .create_entity(&tok, "concept", None, "B", None, None, vec![])
5790            .await
5791            .unwrap();
5792        let c = rt
5793            .create_entity(&tok, "concept", None, "C", None, None, vec![])
5794            .await
5795            .unwrap();
5796
5797        rt.link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
5798            .await
5799            .unwrap();
5800        rt.link(&tok, a.id, c.id, EdgeRelation::Extends, 1.0, None)
5801            .await
5802            .unwrap();
5803        rt.link(&tok, b.id, c.id, EdgeRelation::Enables, 1.0, None)
5804            .await
5805            .unwrap();
5806
5807        let counts = rt.count_edges_by_relation(&tok).await.unwrap();
5808        assert_eq!(counts.get("extends").copied(), Some(2));
5809        assert_eq!(counts.get("enables").copied(), Some(1));
5810    }
5811
5812    #[tokio::test]
5813    async fn delete_edge_removes_from_storage() {
5814        let rt = rt();
5815        let tok = NamespaceToken::local();
5816        let a = rt
5817            .create_entity(&tok, "concept", None, "A", None, None, vec![])
5818            .await
5819            .unwrap();
5820        let b = rt
5821            .create_entity(&tok, "concept", None, "B", None, None, vec![])
5822            .await
5823            .unwrap();
5824        let edge = rt
5825            .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
5826            .await
5827            .unwrap();
5828        let edge_id: Uuid = edge.id.into();
5829
5830        let deleted = rt.delete_edge(&tok, edge_id, true).await.unwrap();
5831        assert!(deleted);
5832
5833        let fetched = rt.get_edge(&tok, edge_id).await.unwrap();
5834        assert!(fetched.is_none(), "edge should be gone after delete");
5835    }
5836
5837    #[tokio::test]
5838    async fn count_edges_matches_filter() {
5839        let rt = rt();
5840        let tok = NamespaceToken::local();
5841        let a = rt
5842            .create_entity(&tok, "concept", None, "A", None, None, vec![])
5843            .await
5844            .unwrap();
5845        let b = rt
5846            .create_entity(&tok, "concept", None, "B", None, None, vec![])
5847            .await
5848            .unwrap();
5849        let c = rt
5850            .create_entity(&tok, "concept", None, "C", None, None, vec![])
5851            .await
5852            .unwrap();
5853
5854        rt.link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
5855            .await
5856            .unwrap();
5857        rt.link(&tok, a.id, c.id, EdgeRelation::Enables, 1.0, None)
5858            .await
5859            .unwrap();
5860
5861        let all = rt
5862            .count_edges(&tok, EdgeListFilter::default())
5863            .await
5864            .unwrap();
5865        assert_eq!(all, 2);
5866
5867        let just_extends = rt
5868            .count_edges(
5869                &tok,
5870                EdgeListFilter {
5871                    relations: vec![EdgeRelation::Extends],
5872                    ..Default::default()
5873                },
5874            )
5875            .await
5876            .unwrap();
5877        assert_eq!(just_extends, 1);
5878    }
5879
5880    // ---- substrate_exists_in_ns must use get_edge_visible ----
5881
5882    /// An edge owned by a visible (non-primary) namespace must be found by
5883    /// `substrate_exists_in_ns` and therefore usable as a graph root in
5884    /// `neighbors` and `traverse`.
5885    #[tokio::test]
5886    async fn edge_in_visible_namespace_reachable_as_graph_root() {
5887        let rt = rt();
5888        let ns_a = Namespace::parse("vis-edge-a").unwrap();
5889        let ns_b = Namespace::parse("vis-edge-b").unwrap();
5890
5891        // Create two entities and an edge in namespace B.
5892        let tok_b = NamespaceToken::for_namespace(ns_b.clone());
5893        let src = rt
5894            .create_entity(&tok_b, "concept", None, "SrcB", None, None, vec![])
5895            .await
5896            .unwrap();
5897        let tgt = rt
5898            .create_entity(&tok_b, "concept", None, "TgtB", None, None, vec![])
5899            .await
5900            .unwrap();
5901        let edge = rt
5902            .link(&tok_b, src.id, tgt.id, EdgeRelation::Extends, 1.0, None)
5903            .await
5904            .unwrap();
5905
5906        // Namespace A with B in its visible set should be able to get the
5907        // edge and use it as a traverse root.
5908        let tok_a_vis = rt
5909            .authorize_with_visibility(ns_a.clone(), vec![ns_b.clone()])
5910            .unwrap();
5911
5912        // Direct get of the edge must succeed (visible namespace).
5913        let got = rt.get_edge_visible(&tok_a_vis, edge.id.0).await.unwrap();
5914        assert!(
5915            got.is_some(),
5916            "edge in visible namespace must be retrievable via get_edge_visible"
5917        );
5918
5919        // neighbors/traverse use substrate_exists_in_ns which now calls
5920        // get_edge_visible — they must not return empty for a visible-ns edge root.
5921        let neighbors = rt
5922            .neighbors(&tok_a_vis, src.id, Direction::Out, Some(16), None)
5923            .await
5924            .unwrap();
5925        assert!(
5926            neighbors.iter().any(|h| h.node_id == tgt.id),
5927            "neighbors of visible-ns node must include its visible-ns neighbor; got: {neighbors:?}"
5928        );
5929    }
5930
5931    // By-ID ops do not enforce namespace isolation. Shared-brain OSS model:
5932    // UUID is globally unique; get/update/delete find the record regardless
5933    // of caller's token namespace.
5934    #[tokio::test]
5935    async fn get_entity_cross_namespace_no_longer_denied() {
5936        let rt = rt();
5937        let ns_a = NamespaceToken::for_namespace(Namespace::parse("ns-a").unwrap());
5938        let ns_b = NamespaceToken::for_namespace(Namespace::parse("ns-b").unwrap());
5939        let entity = rt
5940            .create_entity(&ns_a, "concept", None, "Alpha", None, None, vec![])
5941            .await
5942            .unwrap();
5943
5944        // Same namespace: still works.
5945        let found = rt.get_entity(&ns_a, entity.id).await;
5946        assert!(found.is_ok(), "same-namespace get must succeed");
5947
5948        // Different namespace: now also returns the entity (shared brain).
5949        let cross = rt.get_entity(&ns_b, entity.id).await;
5950        assert!(
5951            cross.is_ok(),
5952            "cross-namespace get must succeed in shared-brain OSS (ADR-007 rule 2)"
5953        );
5954        assert_eq!(cross.unwrap().id, entity.id);
5955    }
5956
5957    #[tokio::test]
5958    async fn delete_entity_cross_namespace_no_longer_denied() {
5959        let rt = rt();
5960        let ns_a = NamespaceToken::for_namespace(Namespace::parse("ns-a").unwrap());
5961        let ns_b = NamespaceToken::for_namespace(Namespace::parse("ns-b").unwrap());
5962        let entity = rt
5963            .create_entity(&ns_a, "concept", None, "Beta", None, None, vec![])
5964            .await
5965            .unwrap();
5966
5967        // Cross-namespace delete now succeeds (shared brain).
5968        let cross_ns_result = rt.delete_entity(&ns_b, entity.id, true).await;
5969        assert!(
5970            cross_ns_result.is_ok(),
5971            "cross-namespace delete must succeed in shared-brain OSS; got {:?}",
5972            cross_ns_result
5973        );
5974        assert!(cross_ns_result.unwrap(), "delete must return true");
5975
5976        // Entity is gone — even from the original namespace.
5977        let gone = rt.get_entity(&ns_a, entity.id).await;
5978        assert!(gone.is_err(), "entity must be gone after delete");
5979    }
5980
5981    // ---- Note annotation tests ----
5982
5983    #[tokio::test]
5984    async fn create_note_indexes_into_fts5() {
5985        let rt = rt();
5986        let tok = NamespaceToken::local();
5987        let note = rt
5988            .create_note(
5989                &tok,
5990                "observation",
5991                None,
5992                "FlashAttention reduces memory by using tiling",
5993                Some(0.8),
5994                None,
5995                vec![],
5996            )
5997            .await
5998            .unwrap();
5999
6000        // FTS5 should have indexed the note content.
6001        let ns = tok.namespace().as_str().to_string();
6002        let hits = rt
6003            .text_for_notes(&tok)
6004            .unwrap()
6005            .search(khive_storage::types::TextSearchRequest {
6006                query: "FlashAttention".to_string(),
6007                mode: khive_storage::types::TextQueryMode::Plain,
6008                filter: Some(khive_storage::types::TextFilter {
6009                    namespaces: vec![ns],
6010                    ..Default::default()
6011                }),
6012                top_k: 10,
6013                snippet_chars: 100,
6014            })
6015            .await
6016            .unwrap();
6017
6018        assert!(
6019            hits.iter().any(|h| h.subject_id == note.id),
6020            "note should be indexed in FTS5 after create"
6021        );
6022    }
6023
6024    /// Regression: unlike `$`, `@` is NOT stripped by `sanitize_fts5_query`
6025    /// (by design: the sanitizer stays minimal). SQLite FTS5's bareword
6026    /// parser still rejects `@` unconditionally, so this query reaches the
6027    /// runtime-level `Err` arm in `search_notes`, which must fail loud
6028    /// (`RuntimeError::InvalidInput`) instead of silently degrading to
6029    /// vector-only fusion.
6030    #[tokio::test]
6031    async fn search_notes_with_residual_fts5_char_fails_loud() {
6032        let rt = rt();
6033        let tok = NamespaceToken::local();
6034        rt.create_note(
6035            &tok,
6036            "observation",
6037            None,
6038            "use foo@bar to chain calls",
6039            Some(0.5),
6040            None,
6041            vec![],
6042        )
6043        .await
6044        .unwrap();
6045
6046        let result = rt
6047            .search_notes(&tok, "foo@bar", None, 10, None, false, &[], None)
6048            .await;
6049
6050        assert!(
6051            result.is_err(),
6052            "#569 search_notes must fail loud when the FTS leg errors on a residual \
6053             FTS5 char ('@'), not silently degrade to vector-only fusion, got: {:?}",
6054            result.ok()
6055        );
6056        assert!(
6057            matches!(result.unwrap_err(), RuntimeError::InvalidInput(_)),
6058            "residual FTS5 parser failure must surface as RuntimeError::InvalidInput"
6059        );
6060    }
6061
6062    /// The `search_notes` FTS fail-open arm must only degrade genuine FTS5
6063    /// parser syntax errors. A non-parser `StorageError`: e.g. a pool
6064    /// exhaustion or connection timeout on the text-search backend — is not a
6065    /// bad query and must propagate as `Err`, not be silently swallowed into
6066    /// an empty (falsely "successful") result set. `search_notes`,
6067    /// `hybrid_search`, `hybrid_search_with_strategy`, and
6068    /// `collect_recall_text_hits` all share the same `is_fts5_syntax_error()`
6069    /// gate on `StorageError`, so this case generalizes to all four call sites.
6070    #[tokio::test]
6071    async fn search_notes_propagates_non_parser_fts_error() {
6072        let rt = rt();
6073        let tok = NamespaceToken::local();
6074        rt.create_note(
6075            &tok,
6076            "observation",
6077            None,
6078            "FlashAttention reduces memory by using tiling",
6079            Some(0.8),
6080            None,
6081            vec![],
6082        )
6083        .await
6084        .unwrap();
6085
6086        let ns = tok.namespace().as_str().to_string();
6087        arm_fts_search_fail(&ns);
6088
6089        let result = rt
6090            .search_notes(&tok, "FlashAttention", None, 10, None, false, &[], None)
6091            .await;
6092
6093        assert!(
6094            result.is_err(),
6095            "search_notes must propagate a non-parser FTS StorageError (Timeout) \
6096             as Err, not silently degrade it to an empty result, got: {:?}",
6097            result.ok()
6098        );
6099        assert!(
6100            matches!(
6101                result.unwrap_err(),
6102                RuntimeError::Storage(khive_storage::StorageError::Timeout { .. })
6103            ),
6104            "propagated error must be the injected StorageError::Timeout, unwrapped \
6105             through RuntimeError::Storage"
6106        );
6107    }
6108
6109    #[tokio::test]
6110    async fn create_note_with_properties() {
6111        let rt = rt();
6112        let tok = NamespaceToken::local();
6113        let props = serde_json::json!({"source": "arxiv:2205.14135"});
6114        let note = rt
6115            .create_note(
6116                &tok,
6117                "insight",
6118                None,
6119                "FlashAttention is IO-aware",
6120                Some(0.9),
6121                Some(props.clone()),
6122                vec![],
6123            )
6124            .await
6125            .unwrap();
6126
6127        assert_eq!(note.properties.as_ref().unwrap(), &props);
6128    }
6129
6130    #[tokio::test]
6131    async fn create_note_creates_annotates_edges() {
6132        let rt = rt();
6133        let tok = NamespaceToken::local();
6134        let entity = rt
6135            .create_entity(&tok, "concept", None, "FlashAttention", None, None, vec![])
6136            .await
6137            .unwrap();
6138
6139        let note = rt
6140            .create_note(
6141                &tok,
6142                "observation",
6143                None,
6144                "FlashAttention uses SRAM tiling for memory efficiency",
6145                Some(0.9),
6146                None,
6147                vec![entity.id],
6148            )
6149            .await
6150            .unwrap();
6151
6152        // The note should have an outbound `annotates` edge to the entity.
6153        let out_neighbors = rt
6154            .neighbors(
6155                &tok,
6156                note.id,
6157                Direction::Out,
6158                None,
6159                Some(vec![EdgeRelation::Annotates]),
6160            )
6161            .await
6162            .unwrap();
6163        assert_eq!(out_neighbors.len(), 1);
6164        assert_eq!(out_neighbors[0].node_id, entity.id);
6165        assert_eq!(out_neighbors[0].relation, EdgeRelation::Annotates);
6166
6167        // The entity should have an inbound `annotates` edge from the note.
6168        let in_neighbors = rt
6169            .neighbors(
6170                &tok,
6171                entity.id,
6172                Direction::In,
6173                None,
6174                Some(vec![EdgeRelation::Annotates]),
6175            )
6176            .await
6177            .unwrap();
6178        assert_eq!(in_neighbors.len(), 1);
6179        assert_eq!(in_neighbors[0].node_id, note.id);
6180    }
6181
6182    #[tokio::test]
6183    async fn neighbors_without_relation_filter_returns_all() {
6184        let rt = rt();
6185        let tok = NamespaceToken::local();
6186        let a = rt
6187            .create_entity(&tok, "concept", None, "A", None, None, vec![])
6188            .await
6189            .unwrap();
6190        let b = rt
6191            .create_entity(&tok, "concept", None, "B", None, None, vec![])
6192            .await
6193            .unwrap();
6194        let c = rt
6195            .create_entity(&tok, "concept", None, "C", None, None, vec![])
6196            .await
6197            .unwrap();
6198
6199        rt.link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
6200            .await
6201            .unwrap();
6202        rt.link(&tok, a.id, c.id, EdgeRelation::Enables, 1.0, None)
6203            .await
6204            .unwrap();
6205
6206        let all = rt
6207            .neighbors(&tok, a.id, Direction::Out, None, None)
6208            .await
6209            .unwrap();
6210        assert_eq!(all.len(), 2);
6211    }
6212
6213    #[tokio::test]
6214    async fn neighbors_with_relation_filter_returns_subset() {
6215        let rt = rt();
6216        let tok = NamespaceToken::local();
6217        let a = rt
6218            .create_entity(&tok, "concept", None, "A", None, None, vec![])
6219            .await
6220            .unwrap();
6221        let b = rt
6222            .create_entity(&tok, "concept", None, "B", None, None, vec![])
6223            .await
6224            .unwrap();
6225        let c = rt
6226            .create_entity(&tok, "concept", None, "C", None, None, vec![])
6227            .await
6228            .unwrap();
6229
6230        rt.link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
6231            .await
6232            .unwrap();
6233        rt.link(&tok, a.id, c.id, EdgeRelation::Enables, 1.0, None)
6234            .await
6235            .unwrap();
6236
6237        let filtered = rt
6238            .neighbors(
6239                &tok,
6240                a.id,
6241                Direction::Out,
6242                None,
6243                Some(vec![EdgeRelation::Extends]),
6244            )
6245            .await
6246            .unwrap();
6247        assert_eq!(filtered.len(), 1);
6248        assert_eq!(filtered[0].node_id, b.id);
6249        assert_eq!(filtered[0].relation, EdgeRelation::Extends);
6250    }
6251
6252    /// Self-loop direction parity:
6253    /// `neighbors_with_query_directed`'s post-merge dedup must not collapse a
6254    /// self-loop edge's Out row and In row into one — they share `(node_id,
6255    /// edge_id)` but are opposite directions, matching what a separate `Out`
6256    /// call plus a separate `In` call would return for the same edge. The
6257    /// self-loop edge is inserted directly through the graph store (`link()`
6258    /// rejects source_id == target_id) to exercise the merge/dedup path.
6259    #[tokio::test]
6260    async fn neighbors_with_query_directed_preserves_self_loop_direction_parity() {
6261        let rt = rt();
6262        let tok = NamespaceToken::local();
6263        let centre = rt
6264            .create_entity(&tok, "concept", None, "Centre", None, None, vec![])
6265            .await
6266            .unwrap();
6267
6268        let now = chrono::Utc::now();
6269        rt.graph(&tok)
6270            .unwrap()
6271            .upsert_edge(Edge {
6272                id: LinkId::from(Uuid::new_v4()),
6273                namespace: "local".to_string(),
6274                source_id: centre.id,
6275                target_id: centre.id,
6276                relation: EdgeRelation::Extends,
6277                weight: 0.7,
6278                created_at: now,
6279                updated_at: now,
6280                deleted_at: None,
6281                metadata: None,
6282                target_backend: None,
6283            })
6284            .await
6285            .unwrap();
6286
6287        let directed = rt
6288            .neighbors_with_query_directed(
6289                &tok,
6290                centre.id,
6291                NeighborQuery {
6292                    direction: Direction::Both,
6293                    relations: None,
6294                    limit: None,
6295                    min_weight: None,
6296                },
6297            )
6298            .await
6299            .unwrap();
6300
6301        assert_eq!(
6302            directed.len(),
6303            2,
6304            "a self-loop edge must produce both an Out hit and an In hit, not one collapsed hit"
6305        );
6306        let directions: Vec<Direction> = directed.iter().map(|(_, d)| d.clone()).collect();
6307        assert!(
6308            directions.contains(&Direction::Out),
6309            "self-loop must retain its Out-tagged hit"
6310        );
6311        assert!(
6312            directions.contains(&Direction::In),
6313            "self-loop must retain its In-tagged hit"
6314        );
6315    }
6316
6317    #[tokio::test]
6318    async fn search_notes_returns_relevant_note() {
6319        let rt = rt();
6320        let tok = NamespaceToken::local();
6321        rt.create_note(
6322            &tok,
6323            "observation",
6324            None,
6325            "GQA reduces KV cache memory for large models",
6326            Some(0.8),
6327            None,
6328            vec![],
6329        )
6330        .await
6331        .unwrap();
6332
6333        let results = rt
6334            .search_notes(&tok, "GQA KV cache", None, 10, None, false, &[], None)
6335            .await
6336            .unwrap();
6337
6338        assert!(!results.is_empty(), "search should return the indexed note");
6339        let hit = &results[0];
6340        assert!(
6341            hit.title.is_some(),
6342            "note hit title should be populated (falls back to content)"
6343        );
6344        assert!(
6345            hit.snippet.is_some(),
6346            "note hit snippet should be populated"
6347        );
6348    }
6349
6350    #[tokio::test]
6351    async fn search_notes_excludes_soft_deleted() {
6352        let rt = rt();
6353        let tok = NamespaceToken::local();
6354        let note = rt
6355            .create_note(
6356                &tok,
6357                "observation",
6358                None,
6359                "RoPE positional encoding rotary embeddings",
6360                Some(0.7),
6361                None,
6362                vec![],
6363            )
6364            .await
6365            .unwrap();
6366
6367        // Soft-delete the note.
6368        rt.notes(&tok)
6369            .unwrap()
6370            .delete_note(note.id, DeleteMode::Soft)
6371            .await
6372            .unwrap();
6373
6374        let results = rt
6375            .search_notes(
6376                &tok,
6377                "RoPE rotary positional",
6378                None,
6379                10,
6380                None,
6381                false,
6382                &[],
6383                None,
6384            )
6385            .await
6386            .unwrap();
6387
6388        assert!(
6389            results.iter().all(|h| h.note_id != note.id),
6390            "soft-deleted note should be excluded from search"
6391        );
6392    }
6393
6394    // ---- predicate pushdown before truncation (note branch) ----
6395
6396    /// Regression: notes store tags inside `properties["tags"]`: there is no
6397    /// separate tags column. Without pushdown, the tag filter is applied after
6398    /// `hits.truncate(limit)`, so a tag-matching note ranked beyond `limit` in
6399    /// the raw RRF fusion is silently dropped.
6400    ///
6401    /// Scenario: `limit=1`, tags_any=["note-target-tag"]. Two notes are inserted:
6402    ///   - decoy: high FTS rank (repeats query terms), NO target tag.
6403    ///   - target: lower FTS rank, HAS "note-target-tag" in `properties["tags"]`.
6404    ///
6405    /// Without pushdown: decoy occupies the slot, target is dropped.
6406    /// With pushdown: decoy is excluded in the alive-note loop, target survives, returned.
6407    #[tokio::test]
6408    async fn search_notes_tag_filter_pushed_before_truncation() {
6409        let rt = rt();
6410        let tok = NamespaceToken::local();
6411
6412        // Decoy note: repeats query tokens → higher FTS rank. No target tag.
6413        rt.create_note(
6414            &tok,
6415            "observation",
6416            None,
6417            "kappa lambda mu note decoy kappa lambda mu note decoy kappa lambda mu",
6418            Some(0.5),
6419            Some(serde_json::json!({"tags": ["other-note-tag"]})),
6420            vec![],
6421        )
6422        .await
6423        .unwrap();
6424
6425        // Target note: fewer query tokens → lower FTS rank. Has the target tag.
6426        let target = rt
6427            .create_note(
6428                &tok,
6429                "observation",
6430                None,
6431                "kappa lambda mu note target",
6432                Some(0.5),
6433                Some(serde_json::json!({"tags": ["note-target-tag"]})),
6434                vec![],
6435            )
6436            .await
6437            .unwrap();
6438
6439        // With limit=1 and tags_any, the fix must return the target note despite the
6440        // decoy ranking higher in raw FTS.
6441        let hits = rt
6442            .search_notes(
6443                &tok,
6444                "kappa lambda mu note",
6445                None,
6446                1,
6447                None,
6448                false,
6449                &["note-target-tag".to_string()],
6450                None,
6451            )
6452            .await
6453            .unwrap();
6454
6455        assert_eq!(
6456            hits.len(),
6457            1,
6458            "exactly one hit expected (tag-matching note)"
6459        );
6460        assert_eq!(
6461            hits[0].note_id, target.id,
6462            "tag-filtered note must be returned even when ranked below limit in raw fusion"
6463        );
6464    }
6465
6466    /// Regression: without pushdown, the properties filter is applied after truncation; a matching
6467    /// note ranked beyond `limit` is silently dropped.
6468    ///
6469    /// Scenario: `limit=1`, properties_filter={{"source": "target"}}. Two notes:
6470    ///   - decoy: high FTS rank, properties {{"source": "other"}}.
6471    ///   - target: lower FTS rank, properties {{"source": "target"}}.
6472    #[tokio::test]
6473    async fn search_notes_props_filter_pushed_before_truncation() {
6474        let rt = rt();
6475        let tok = NamespaceToken::local();
6476
6477        rt.create_note(
6478            &tok,
6479            "observation",
6480            None,
6481            "nu xi omicron note decoy nu xi omicron note decoy nu xi omicron",
6482            Some(0.5),
6483            Some(serde_json::json!({"source": "other"})),
6484            vec![],
6485        )
6486        .await
6487        .unwrap();
6488
6489        let target = rt
6490            .create_note(
6491                &tok,
6492                "observation",
6493                None,
6494                "nu xi omicron note target",
6495                Some(0.5),
6496                Some(serde_json::json!({"source": "target"})),
6497                vec![],
6498            )
6499            .await
6500            .unwrap();
6501
6502        let filter = serde_json::json!({"source": "target"});
6503        let hits = rt
6504            .search_notes(
6505                &tok,
6506                "nu xi omicron note",
6507                None,
6508                1,
6509                None,
6510                false,
6511                &[],
6512                Some(&filter),
6513            )
6514            .await
6515            .unwrap();
6516
6517        assert_eq!(
6518            hits.len(),
6519            1,
6520            "exactly one hit expected (properties-matching note)"
6521        );
6522        assert_eq!(
6523            hits[0].note_id, target.id,
6524            "properties-filtered note must be returned even when ranked below limit"
6525        );
6526    }
6527
6528    #[tokio::test]
6529    async fn resolve_returns_entity() {
6530        let rt = rt();
6531        let tok = NamespaceToken::local();
6532        let entity = rt
6533            .create_entity(&tok, "concept", None, "LoRA", None, None, vec![])
6534            .await
6535            .unwrap();
6536
6537        let resolved = rt.resolve(&tok, entity.id).await.unwrap();
6538        match resolved {
6539            Some(Resolved::Entity(e)) => assert_eq!(e.id, entity.id),
6540            other => panic!("expected Resolved::Entity, got {:?}", other),
6541        }
6542    }
6543
6544    #[tokio::test]
6545    async fn resolve_returns_note() {
6546        let rt = rt();
6547        let tok = NamespaceToken::local();
6548        let note = rt
6549            .create_note(
6550                &tok,
6551                "observation",
6552                None,
6553                "LoRA fine-tunes LLMs with low-rank adapters",
6554                Some(0.85),
6555                None,
6556                vec![],
6557            )
6558            .await
6559            .unwrap();
6560
6561        let resolved = rt.resolve(&tok, note.id).await.unwrap();
6562        match resolved {
6563            Some(Resolved::Note(n)) => assert_eq!(n.id, note.id),
6564            other => panic!("expected Resolved::Note, got {:?}", other),
6565        }
6566    }
6567
6568    #[tokio::test]
6569    async fn resolve_returns_none_for_unknown_uuid() {
6570        let rt = rt();
6571        let tok = NamespaceToken::local();
6572        let unknown = Uuid::new_v4();
6573        let resolved = rt.resolve(&tok, unknown).await.unwrap();
6574        assert!(resolved.is_none(), "unknown UUID should resolve to None");
6575    }
6576
6577    #[tokio::test]
6578    async fn resolve_prefix_finds_entity_in_own_namespace() {
6579        let rt = rt();
6580        let tok = NamespaceToken::local();
6581        let entity = rt
6582            .create_entity(&tok, "concept", None, "PrefixTest", None, None, vec![])
6583            .await
6584            .unwrap();
6585        let prefix = &entity.id.to_string()[..8];
6586
6587        let resolved = rt.resolve_prefix(&tok, prefix).await.unwrap();
6588        assert_eq!(resolved, Some(entity.id));
6589    }
6590
6591    #[test]
6592    fn hex_prefix_to_uuid_pattern_inserts_hyphens_at_canonical_boundaries() {
6593        let full = "aabbccdd112240008000000000000ab1";
6594        let cases: &[(usize, &str)] = &[
6595            (1, "a"),
6596            (7, "aabbccd"),
6597            (8, "aabbccdd"),
6598            (9, "aabbccdd-1"),
6599            (12, "aabbccdd-1122"),
6600            (13, "aabbccdd-1122-4"),
6601            (16, "aabbccdd-1122-4000"),
6602            (18, "aabbccdd-1122-4000-80"),
6603            (20, "aabbccdd-1122-4000-8000"),
6604            (23, "aabbccdd-1122-4000-8000-000"),
6605            (24, "aabbccdd-1122-4000-8000-0000"),
6606            (28, "aabbccdd-1122-4000-8000-00000000"),
6607            (31, "aabbccdd-1122-4000-8000-000000000ab"),
6608        ];
6609        for (len, expected) in cases {
6610            let input = &full[..*len];
6611            assert_eq!(
6612                hex_prefix_to_uuid_pattern(input),
6613                *expected,
6614                "len={len} input={input:?}"
6615            );
6616        }
6617    }
6618
6619    #[test]
6620    fn hex_prefix_to_uuid_pattern_full_32_char_matches_canonical_uuid() {
6621        let compact = "aabbccdd112240008000000000000ab1";
6622        let compact32 = &compact[..32];
6623        assert_eq!(
6624            hex_prefix_to_uuid_pattern(compact32),
6625            "aabbccdd-1122-4000-8000-000000000ab1"
6626        );
6627    }
6628
6629    /// Input longer than 32 hex chars is NOT truncated: the extra chars land
6630    /// past the canonical 12-char final
6631    /// segment with no further hyphen, so the pattern can never match a real
6632    /// (36-char) stored `id`, instead of silently truncating down to a
6633    /// pattern that matches the valid 32-char UUID.
6634    #[test]
6635    fn hex_prefix_to_uuid_pattern_overlong_input_is_not_truncated() {
6636        let compact32 = "aabbccdd112240008000000000000ab1";
6637        let overlong = format!("{compact32}extrahex");
6638        let pattern = hex_prefix_to_uuid_pattern(&overlong);
6639        assert_eq!(
6640            pattern, "aabbccdd-1122-4000-8000-000000000ab1extrahex",
6641            "overlong input must keep its extra chars, not truncate to the valid UUID"
6642        );
6643        assert_ne!(
6644            pattern, "aabbccdd-1122-4000-8000-000000000ab1",
6645            "overlong pattern must not collapse to the canonical 36-char UUID form"
6646        );
6647    }
6648
6649    #[test]
6650    fn hex_prefix_to_uuid_pattern_passes_through_hyphenated_input() {
6651        let hyphenated = "aabbccdd-1122-4000-8000-000000000ab1";
6652        assert_eq!(hex_prefix_to_uuid_pattern(hyphenated), hyphenated);
6653
6654        let partial = "aabbccdd-11";
6655        assert_eq!(hex_prefix_to_uuid_pattern(partial), partial);
6656    }
6657
6658    #[tokio::test]
6659    async fn resolve_prefix_compact_9_to_31_char_matches() {
6660        let rt = rt();
6661        let tok = NamespaceToken::local();
6662        let entity = rt
6663            .create_entity(
6664                &tok,
6665                "concept",
6666                None,
6667                "CompactPrefixTest",
6668                None,
6669                None,
6670                vec![],
6671            )
6672            .await
6673            .unwrap();
6674        let compact = entity.id.simple().to_string();
6675
6676        for len in [9, 12, 16, 20, 24, 28, 31] {
6677            let prefix = &compact[..len];
6678            let resolved = rt.resolve_prefix(&tok, prefix).await.unwrap();
6679            assert_eq!(
6680                resolved,
6681                Some(entity.id),
6682                "compact prefix of len {len} should resolve"
6683            );
6684        }
6685    }
6686
6687    #[tokio::test]
6688    async fn resolve_prefix_compact_full_32_char_matches() {
6689        let rt = rt();
6690        let tok = NamespaceToken::local();
6691        let entity = rt
6692            .create_entity(&tok, "concept", None, "Full32Test", None, None, vec![])
6693            .await
6694            .unwrap();
6695        let compact = entity.id.simple().to_string();
6696        assert_eq!(compact.len(), 32);
6697
6698        let resolved = rt.resolve_prefix(&tok, &compact).await.unwrap();
6699        assert_eq!(resolved, Some(entity.id));
6700    }
6701
6702    /// A valid 32-char compact id with extra trailing hex chars appended must
6703    /// fail to resolve, not silently resolve to the valid entity via truncation.
6704    #[tokio::test]
6705    async fn resolve_prefix_rejects_overlong_all_hex_input() {
6706        let rt = rt();
6707        let tok = NamespaceToken::local();
6708        let entity = rt
6709            .create_entity(&tok, "concept", None, "OverlongTest", None, None, vec![])
6710            .await
6711            .unwrap();
6712        let compact = entity.id.simple().to_string();
6713        assert_eq!(compact.len(), 32);
6714
6715        let overlong = format!("{compact}ab");
6716        let resolved = rt.resolve_prefix(&tok, &overlong).await.unwrap();
6717        assert_eq!(
6718            resolved, None,
6719            "a 32-char id plus extra hex chars must not resolve to the valid entity"
6720        );
6721    }
6722
6723    /// The `resolve_prefix*` boundary rejects
6724    /// non-hex/non-hyphen input (e.g. LIKE wildcards `%`/`_`) instead of
6725    /// letting it reach the bound `LIKE` pattern unfiltered — covers callers
6726    /// (like khive-pack-git/src/ingest.rs) that resolve raw input without
6727    /// their own all-hex gate.
6728    #[tokio::test]
6729    async fn resolve_prefix_rejects_like_wildcard_input() {
6730        let rt = rt();
6731        let tok = NamespaceToken::local();
6732        let entity = rt
6733            .create_entity(&tok, "concept", None, "WildcardTest", None, None, vec![])
6734            .await
6735            .unwrap();
6736        let compact = entity.id.simple().to_string();
6737        // A caller that forgot to hex-gate might pass a `%`-bearing string
6738        // straight through, hoping to broaden a scan; the resolver boundary
6739        // must reject it instead of running it as a wildcard LIKE.
6740        let wildcard_prefix = format!("{}%", &compact[..8]);
6741
6742        let resolved = rt.resolve_prefix(&tok, &wildcard_prefix).await.unwrap();
6743        assert_eq!(
6744            resolved, None,
6745            "prefix containing a LIKE wildcard must be rejected, not resolved"
6746        );
6747    }
6748
6749    #[tokio::test]
6750    async fn resolve_prefix_boundary_at_hyphen_positions() {
6751        let rt = rt();
6752        let tok = NamespaceToken::local();
6753        let entity = rt
6754            .create_entity(&tok, "concept", None, "BoundaryTest", None, None, vec![])
6755            .await
6756            .unwrap();
6757        let compact = entity.id.simple().to_string();
6758
6759        for len in [8, 12, 16, 20, 24] {
6760            let prefix = &compact[..len];
6761            let resolved = rt.resolve_prefix(&tok, prefix).await.unwrap();
6762            assert_eq!(
6763                resolved,
6764                Some(entity.id),
6765                "boundary prefix of len {len} should resolve"
6766            );
6767        }
6768    }
6769
6770    #[tokio::test]
6771    async fn resolve_prefix_ambiguous_still_detected_after_normalization() {
6772        use khive_storage::entity::Entity;
6773
6774        let rt = rt();
6775        let tok = NamespaceToken::local();
6776        let id_a = Uuid::parse_str("aabbccdd-1111-4000-8000-000000000001").unwrap();
6777        let id_b = Uuid::parse_str("aabbccdd-1111-4000-8000-000000000002").unwrap();
6778
6779        let mut entity_a = Entity::new("local", "concept", "AmbigCompactA");
6780        entity_a.id = id_a;
6781        let mut entity_b = Entity::new("local", "concept", "AmbigCompactB");
6782        entity_b.id = id_b;
6783
6784        let store = rt.entities(&tok).unwrap();
6785        store.upsert_entity(entity_a).await.unwrap();
6786        store.upsert_entity(entity_b).await.unwrap();
6787
6788        // Shared 20-char compact prefix (past the first hyphen boundary).
6789        let shared_compact = &id_a.simple().to_string()[..20];
6790        let err = rt.resolve_prefix(&tok, shared_compact).await.unwrap_err();
6791        assert!(
6792            matches!(
6793                err,
6794                RuntimeError::AmbiguousPrefix { ref matches, .. } if matches.len() == 2
6795            ),
6796            "shared compact prefix must still return AmbiguousPrefix; got {err:?}"
6797        );
6798    }
6799
6800    #[tokio::test]
6801    async fn resolve_prefix_invisible_across_namespaces() {
6802        let rt = rt();
6803        let ns_a = NamespaceToken::for_namespace(Namespace::parse("ns-a").unwrap());
6804        let ns_b = NamespaceToken::for_namespace(Namespace::parse("ns-b").unwrap());
6805        let entity = rt
6806            .create_entity(&ns_a, "concept", None, "Invisible", None, None, vec![])
6807            .await
6808            .unwrap();
6809        let prefix = &entity.id.to_string()[..8];
6810
6811        // From ns_b, the entity in ns_a should not be visible.
6812        let resolved = rt.resolve_prefix(&ns_b, prefix).await.unwrap();
6813        assert_eq!(resolved, None);
6814    }
6815
6816    #[tokio::test]
6817    async fn resolve_prefix_ambiguous_same_namespace() {
6818        use khive_storage::entity::Entity;
6819
6820        let rt = rt();
6821        let tok = NamespaceToken::local();
6822        // Two entities with UUIDs sharing the same 8-char prefix "aabbccdd".
6823        let id_a = Uuid::parse_str("aabbccdd-1111-4000-8000-000000000001").unwrap();
6824        let id_b = Uuid::parse_str("aabbccdd-2222-4000-8000-000000000002").unwrap();
6825
6826        let mut entity_a = Entity::new("local", "concept", "AmbigA");
6827        entity_a.id = id_a;
6828        let mut entity_b = Entity::new("local", "concept", "AmbigB");
6829        entity_b.id = id_b;
6830
6831        let store = rt.entities(&tok).unwrap();
6832        store.upsert_entity(entity_a).await.unwrap();
6833        store.upsert_entity(entity_b).await.unwrap();
6834
6835        let err = rt.resolve_prefix(&tok, "aabbccdd").await.unwrap_err();
6836        assert!(
6837            matches!(
6838                err,
6839                RuntimeError::AmbiguousPrefix { ref prefix, ref matches }
6840                    if prefix == "aabbccdd" && matches.len() == 2
6841            ),
6842            "shared 8-char prefix must return AmbiguousPrefix; got {err:?}"
6843        );
6844    }
6845
6846    /// A single UUID legitimately present in TWO scanned tables (entities and
6847    /// notes here) must resolve cleanly to that one UUID, not a false
6848    /// `AmbiguousPrefix` naming the same UUID twice: without cross-table
6849    /// dedup, `matches.len()` becomes 2 for a single record.
6850    #[tokio::test]
6851    async fn resolve_prefix_cross_table_duplicate_uuid_resolves_cleanly() {
6852        use khive_storage::entity::Entity;
6853
6854        let rt = rt();
6855        let tok = NamespaceToken::local();
6856        let shared_id = Uuid::parse_str("ccddeeff-1111-4000-8000-000000000001").unwrap();
6857
6858        let mut entity = Entity::new("local", "concept", "Nvk749Entity");
6859        entity.id = shared_id;
6860        rt.entities(&tok)
6861            .unwrap()
6862            .upsert_entity(entity)
6863            .await
6864            .unwrap();
6865
6866        let mut note = Note::new("local", "observation", "nvk749 note with the same id");
6867        note.id = shared_id;
6868        rt.notes(&tok).unwrap().upsert_note(note).await.unwrap();
6869
6870        let resolved = rt
6871            .resolve_prefix(&tok, "ccddeeff")
6872            .await
6873            .expect("#749: a UUID present in two tables must not be reported as ambiguous");
6874        assert_eq!(
6875            resolved,
6876            Some(shared_id),
6877            "#749: cross-table duplicate must resolve to the single shared UUID"
6878        );
6879    }
6880
6881    /// The early-exit inside the per-table scan loop (`if matches.len()
6882    /// > 1 { break }`) must also operate on DEDUPED state — otherwise a
6883    /// cross-table duplicate could still short-circuit the scan before a
6884    /// later table contributes the SAME UUID again, which would have masked
6885    /// the bug rather than exercising it. This drives the duplicate through
6886    /// the earliest two tables scanned (entities, notes) so the early-exit
6887    /// path is the one under test, not a post-loop dedup applied too late.
6888    #[tokio::test]
6889    async fn resolve_prefix_early_exit_uses_deduped_match_count() {
6890        use khive_storage::entity::Entity;
6891
6892        let rt = rt();
6893        let tok = NamespaceToken::local();
6894        let shared_id = Uuid::parse_str("ddeeff11-2222-4000-8000-000000000002").unwrap();
6895
6896        // entities and notes are the first two tables scanned inside
6897        // resolve_prefix_inner — the same UUID in both must not trip the
6898        // mid-scan `matches.len() > 1` break as if two distinct UUIDs had
6899        // been found.
6900        let mut entity = Entity::new("local", "concept", "Nvk749bEntity");
6901        entity.id = shared_id;
6902        rt.entities(&tok)
6903            .unwrap()
6904            .upsert_entity(entity)
6905            .await
6906            .unwrap();
6907
6908        let mut note = Note::new("local", "observation", "nvk749b note with the same id");
6909        note.id = shared_id;
6910        rt.notes(&tok).unwrap().upsert_note(note).await.unwrap();
6911
6912        let resolved = rt
6913            .resolve_prefix(&tok, "ddeeff11")
6914            .await
6915            .expect("#749: deduped early-exit must not falsely report ambiguity");
6916        assert_eq!(resolved, Some(shared_id));
6917    }
6918
6919    // ---- Event resolution tests ----
6920    //
6921    // resolve_prefix and handle_get already include events; these tests are
6922    // regression coverage confirming event UUIDs are resolvable and that get()
6923    // returns kind="event".
6924
6925    #[tokio::test]
6926    async fn resolve_finds_event_by_full_uuid() {
6927        use khive_storage::Event;
6928        use khive_types::{EventKind, SubstrateKind};
6929
6930        let rt = rt();
6931        let tok = NamespaceToken::local();
6932        let ns = tok.namespace().as_str();
6933        let event = Event::new(
6934            ns,
6935            "test_verb",
6936            EventKind::Audit,
6937            SubstrateKind::Entity,
6938            "actor",
6939        );
6940        let event_id = event.id;
6941        rt.events(&tok).unwrap().append_event(event).await.unwrap();
6942
6943        let resolved = rt.resolve(&tok, event_id).await.unwrap();
6944        assert!(
6945            matches!(resolved, Some(Resolved::Event(_))),
6946            "event UUID must resolve to Resolved::Event, got {resolved:?}"
6947        );
6948    }
6949
6950    #[tokio::test]
6951    async fn resolve_prefix_finds_event() {
6952        use khive_storage::Event;
6953        use khive_types::{EventKind, SubstrateKind};
6954
6955        let rt = rt();
6956        let tok = NamespaceToken::local();
6957        let ns = tok.namespace().as_str();
6958        let event = Event::new(
6959            ns,
6960            "test_verb",
6961            EventKind::Audit,
6962            SubstrateKind::Entity,
6963            "actor",
6964        );
6965        let event_id = event.id;
6966        rt.events(&tok).unwrap().append_event(event).await.unwrap();
6967
6968        let prefix = &event_id.to_string()[..8];
6969        let resolved = rt.resolve_prefix(&tok, prefix).await.unwrap();
6970        assert_eq!(
6971            resolved,
6972            Some(event_id),
6973            "resolve_prefix must return event UUID for 8-char prefix"
6974        );
6975    }
6976
6977    // ---- Referential integrity tests ----
6978
6979    #[tokio::test]
6980    async fn link_phantom_source_returns_not_found() {
6981        let rt = rt();
6982        let tok = NamespaceToken::local();
6983        let b = rt
6984            .create_entity(&tok, "concept", None, "B", None, None, vec![])
6985            .await
6986            .unwrap();
6987        let phantom = Uuid::new_v4();
6988
6989        let result = rt
6990            .link(&tok, phantom, b.id, EdgeRelation::Extends, 1.0, None)
6991            .await;
6992        match result {
6993            Err(RuntimeError::NotFound(msg)) => {
6994                assert!(
6995                    msg.contains("source"),
6996                    "error message must name 'source': {msg}"
6997                );
6998            }
6999            other => panic!("expected NotFound for phantom source, got {other:?}"),
7000        }
7001    }
7002
7003    #[tokio::test]
7004    async fn link_phantom_target_returns_not_found() {
7005        let rt = rt();
7006        let tok = NamespaceToken::local();
7007        let a = rt
7008            .create_entity(&tok, "concept", None, "A", None, None, vec![])
7009            .await
7010            .unwrap();
7011        let phantom = Uuid::new_v4();
7012
7013        let result = rt
7014            .link(&tok, a.id, phantom, EdgeRelation::Extends, 1.0, None)
7015            .await;
7016        match result {
7017            Err(RuntimeError::NotFound(msg)) => {
7018                assert!(
7019                    msg.contains("target"),
7020                    "error message must name 'target': {msg}"
7021                );
7022            }
7023            other => panic!("expected NotFound for phantom target, got {other:?}"),
7024        }
7025    }
7026
7027    #[tokio::test]
7028    async fn link_real_entities_succeeds() {
7029        let rt = rt();
7030        let tok = NamespaceToken::local();
7031        let a = rt
7032            .create_entity(&tok, "concept", None, "A", None, None, vec![])
7033            .await
7034            .unwrap();
7035        let b = rt
7036            .create_entity(&tok, "concept", None, "B", None, None, vec![])
7037            .await
7038            .unwrap();
7039
7040        let edge = rt
7041            .link(&tok, a.id, b.id, EdgeRelation::Extends, 0.8, None)
7042            .await
7043            .unwrap();
7044        assert_eq!(edge.source_id, a.id);
7045        assert_eq!(edge.target_id, b.id);
7046        assert_eq!(edge.relation, EdgeRelation::Extends);
7047    }
7048
7049    // ---- commit-time endpoint guard vs concurrent hard-delete ----
7050
7051    /// Deterministic form of the regression, exercised directly at the
7052    /// write step `link` performs after prepare-time validation: build the
7053    /// exact `Edge` `link` would build, delete the target the way a
7054    /// concurrent racer would, and confirm the guarded write refuses it.
7055    #[tokio::test]
7056    async fn link_write_time_guard_blocks_dangling_edge_after_target_vanishes() {
7057        let rt = rt();
7058        let tok = NamespaceToken::local();
7059        let a = rt
7060            .create_entity(&tok, "concept", None, "A", None, None, vec![])
7061            .await
7062            .unwrap();
7063        let x = rt
7064            .create_entity(&tok, "concept", None, "X", None, None, vec![])
7065            .await
7066            .unwrap();
7067
7068        rt.validate_edge_relation_endpoints(&tok, a.id, x.id, EdgeRelation::Extends)
7069            .await
7070            .expect("prepare-time validation must pass while X is live");
7071
7072        assert!(rt.delete_entity(&tok, x.id, true).await.unwrap());
7073
7074        let now = chrono::Utc::now();
7075        let edge = Edge {
7076            id: LinkId::from(Uuid::new_v4()),
7077            namespace: tok.namespace().as_str().to_string(),
7078            source_id: a.id,
7079            target_id: x.id,
7080            relation: EdgeRelation::Extends,
7081            weight: 1.0,
7082            created_at: now,
7083            updated_at: now,
7084            deleted_at: None,
7085            metadata: None,
7086            target_backend: None,
7087        };
7088        let outcome = rt
7089            .graph(&tok)
7090            .unwrap()
7091            .upsert_edge_guarded(edge)
7092            .await
7093            .unwrap();
7094        match outcome {
7095            khive_storage::GuardedWriteOutcome::Refused(missing) => {
7096                assert!(missing.target, "target must be reported missing");
7097            }
7098            other => panic!(
7099                "guarded write must refuse an edge whose target vanished before commit, got {other:?}"
7100            ),
7101        }
7102
7103        let edges = rt
7104            .list_edges(
7105                &tok,
7106                crate::curation::EdgeListFilter {
7107                    source_id: Some(a.id),
7108                    target_id: Some(x.id),
7109                    relations: vec![EdgeRelation::Extends],
7110                    ..Default::default()
7111                },
7112                10,
7113                0,
7114            )
7115            .await
7116            .unwrap();
7117        assert!(
7118            edges.is_empty(),
7119            "no dangling edge may be persisted after the guarded write refused it"
7120        );
7121    }
7122
7123    #[tokio::test]
7124    async fn link_many_writes_nothing_when_one_target_vanishes_before_write() {
7125        let rt = rt();
7126        let tok = NamespaceToken::local();
7127        let a = rt
7128            .create_entity(&tok, "concept", None, "A", None, None, vec![])
7129            .await
7130            .unwrap();
7131        let b = rt
7132            .create_entity(&tok, "concept", None, "B", None, None, vec![])
7133            .await
7134            .unwrap();
7135        let x = rt
7136            .create_entity(&tok, "concept", None, "X", None, None, vec![])
7137            .await
7138            .unwrap();
7139
7140        let specs = vec![
7141            LinkSpec {
7142                namespace: None,
7143                source_id: a.id,
7144                target_id: x.id,
7145                relation: EdgeRelation::Extends,
7146                weight: 1.0,
7147                metadata: None,
7148            },
7149            LinkSpec {
7150                namespace: None,
7151                source_id: a.id,
7152                target_id: b.id,
7153                relation: EdgeRelation::Extends,
7154                weight: 1.0,
7155                metadata: None,
7156            },
7157        ];
7158
7159        // Both specs validate fine at build_edge time (X and B both live).
7160        let mut edges = Vec::with_capacity(specs.len());
7161        for spec in &specs {
7162            edges.push(rt.build_edge(&tok, spec).await.unwrap());
7163        }
7164
7165        // X vanishes before the batched write — mirrors a concurrent
7166        // hard-delete landing between per-spec validation and link_many's
7167        // single guarded batch write.
7168        assert!(rt.delete_entity(&tok, x.id, true).await.unwrap());
7169
7170        let outcome = rt
7171            .graph(&tok)
7172            .unwrap()
7173            .upsert_edges_guarded(edges)
7174            .await
7175            .unwrap();
7176        assert_eq!(
7177            outcome.summary.affected, 0,
7178            "no edge from the batch may be persisted when any endpoint vanished"
7179        );
7180        assert!(
7181            outcome.refused.is_some(),
7182            "refused batch entry must be reported"
7183        );
7184
7185        let edges = rt
7186            .list_edges(
7187                &tok,
7188                crate::curation::EdgeListFilter {
7189                    source_id: Some(a.id),
7190                    relations: vec![EdgeRelation::Extends],
7191                    ..Default::default()
7192                },
7193                10,
7194                0,
7195            )
7196            .await
7197            .unwrap();
7198        assert!(
7199            edges.is_empty(),
7200            "link_many's guarded batch must be all-or-nothing: the live A-B edge \
7201             must not have been persisted alongside the doomed A-X edge"
7202        );
7203    }
7204
7205    // ---- hard-delete row + incident-edge purge is ONE transaction ----
7206    //
7207    // Six tests below cover both orderings (write-then-delete, and a
7208    // concurrent write raced against delete via `tokio::join!`) across all
7209    // three hard-delete paths that cascade-purge incident edges: entity,
7210    // note, and edge-as-node. No sleeps — the "concurrent" tests assert an
7211    // invariant that must hold for EITHER interleaving the async scheduler
7212    // picks, rather than forcing one specific interleaving, so they are
7213    // deterministic (never flaky) without a barrier.
7214
7215    fn raw_edge(source_id: Uuid, target_id: Uuid, ns: &str) -> Edge {
7216        let now = chrono::Utc::now();
7217        Edge {
7218            id: LinkId::from(Uuid::new_v4()),
7219            namespace: ns.to_string(),
7220            source_id,
7221            target_id,
7222            relation: EdgeRelation::Extends,
7223            weight: 1.0,
7224            created_at: now,
7225            updated_at: now,
7226            deleted_at: None,
7227            metadata: None,
7228            target_backend: None,
7229        }
7230    }
7231
7232    async fn assert_no_edges_touch(rt: &KhiveRuntime, tok: &NamespaceToken, node_id: Uuid) {
7233        let as_source = rt
7234            .list_edges(
7235                tok,
7236                crate::curation::EdgeListFilter {
7237                    source_id: Some(node_id),
7238                    ..Default::default()
7239                },
7240                10,
7241                0,
7242            )
7243            .await
7244            .unwrap();
7245        let as_target = rt
7246            .list_edges(
7247                tok,
7248                crate::curation::EdgeListFilter {
7249                    target_id: Some(node_id),
7250                    ..Default::default()
7251                },
7252                10,
7253                0,
7254            )
7255            .await
7256            .unwrap();
7257        assert!(
7258            as_source.is_empty() && as_target.is_empty(),
7259            "no edge may reference hard-deleted node {node_id}: source-side={as_source:?} \
7260             target-side={as_target:?}"
7261        );
7262    }
7263
7264    #[tokio::test]
7265    async fn hard_delete_entity_purges_edge_written_before_delete() {
7266        let rt = rt();
7267        let tok = NamespaceToken::local();
7268        let a = rt
7269            .create_entity(&tok, "concept", None, "A", None, None, vec![])
7270            .await
7271            .unwrap();
7272        let x = rt
7273            .create_entity(&tok, "concept", None, "X", None, None, vec![])
7274            .await
7275            .unwrap();
7276
7277        let edge = raw_edge(a.id, x.id, tok.namespace().as_str());
7278        assert_eq!(
7279            rt.graph(&tok)
7280                .unwrap()
7281                .upsert_edge_guarded(edge)
7282                .await
7283                .unwrap(),
7284            khive_storage::GuardedWriteOutcome::Written
7285        );
7286
7287        assert!(rt.delete_entity(&tok, x.id, true).await.unwrap());
7288        assert_no_edges_touch(&rt, &tok, x.id).await;
7289    }
7290
7291    #[tokio::test]
7292    async fn hard_delete_entity_concurrent_with_guarded_write_never_leaves_dangling_edge() {
7293        let rt = std::sync::Arc::new(rt());
7294        let tok = NamespaceToken::local();
7295        let a = rt
7296            .create_entity(&tok, "concept", None, "A", None, None, vec![])
7297            .await
7298            .unwrap();
7299        let x = rt
7300            .create_entity(&tok, "concept", None, "X", None, None, vec![])
7301            .await
7302            .unwrap();
7303
7304        let delete_rt = std::sync::Arc::clone(&rt);
7305        let delete_tok = tok.clone();
7306        let delete_task =
7307            tokio::spawn(async move { delete_rt.delete_entity(&delete_tok, x.id, true).await });
7308
7309        let write_rt = std::sync::Arc::clone(&rt);
7310        let write_tok = tok.clone();
7311        let ns = tok.namespace().as_str().to_string();
7312        let write_task = tokio::spawn(async move {
7313            let edge = raw_edge(a.id, x.id, &ns);
7314            write_rt
7315                .graph(&write_tok)
7316                .unwrap()
7317                .upsert_edge_guarded(edge)
7318                .await
7319        });
7320
7321        let (deleted, _written) = tokio::join!(delete_task, write_task);
7322        deleted.unwrap().unwrap();
7323        assert_no_edges_touch(&rt, &tok, x.id).await;
7324    }
7325
7326    #[tokio::test]
7327    async fn hard_delete_note_purges_edge_written_before_delete() {
7328        let rt = rt();
7329        let tok = NamespaceToken::local();
7330        let a = rt
7331            .create_entity(&tok, "concept", None, "A", None, None, vec![])
7332            .await
7333            .unwrap();
7334        let n = rt
7335            .create_note(
7336                &tok,
7337                "observation",
7338                None,
7339                "note content",
7340                None,
7341                None,
7342                vec![],
7343            )
7344            .await
7345            .unwrap();
7346
7347        let edge = raw_edge(a.id, n.id, tok.namespace().as_str());
7348        assert_eq!(
7349            rt.graph(&tok)
7350                .unwrap()
7351                .upsert_edge_guarded(edge)
7352                .await
7353                .unwrap(),
7354            khive_storage::GuardedWriteOutcome::Written
7355        );
7356
7357        assert!(rt.delete_note(&tok, n.id, true).await.unwrap());
7358        assert_no_edges_touch(&rt, &tok, n.id).await;
7359    }
7360
7361    #[tokio::test]
7362    async fn hard_delete_note_concurrent_with_guarded_write_never_leaves_dangling_edge() {
7363        let rt = std::sync::Arc::new(rt());
7364        let tok = NamespaceToken::local();
7365        let a = rt
7366            .create_entity(&tok, "concept", None, "A", None, None, vec![])
7367            .await
7368            .unwrap();
7369        let n = rt
7370            .create_note(
7371                &tok,
7372                "observation",
7373                None,
7374                "note content",
7375                None,
7376                None,
7377                vec![],
7378            )
7379            .await
7380            .unwrap();
7381
7382        let delete_rt = std::sync::Arc::clone(&rt);
7383        let delete_tok = tok.clone();
7384        let note_id = n.id;
7385        let delete_task =
7386            tokio::spawn(async move { delete_rt.delete_note(&delete_tok, note_id, true).await });
7387
7388        let write_rt = std::sync::Arc::clone(&rt);
7389        let write_tok = tok.clone();
7390        let ns = tok.namespace().as_str().to_string();
7391        let write_task = tokio::spawn(async move {
7392            let edge = raw_edge(a.id, note_id, &ns);
7393            write_rt
7394                .graph(&write_tok)
7395                .unwrap()
7396                .upsert_edge_guarded(edge)
7397                .await
7398        });
7399
7400        let (deleted, _written) = tokio::join!(delete_task, write_task);
7401        deleted.unwrap().unwrap();
7402        assert_no_edges_touch(&rt, &tok, note_id).await;
7403    }
7404
7405    #[tokio::test]
7406    async fn hard_delete_edge_endpoint_purges_annotating_edge_written_before_delete() {
7407        let rt = rt();
7408        let tok = NamespaceToken::local();
7409        let a = rt
7410            .create_entity(&tok, "concept", None, "A", None, None, vec![])
7411            .await
7412            .unwrap();
7413        let b = rt
7414            .create_entity(&tok, "concept", None, "B", None, None, vec![])
7415            .await
7416            .unwrap();
7417        let n = rt
7418            .create_note(
7419                &tok,
7420                "observation",
7421                None,
7422                "note content",
7423                None,
7424                None,
7425                vec![],
7426            )
7427            .await
7428            .unwrap();
7429        let base_edge = rt
7430            .link(&tok, a.id, b.id, EdgeRelation::Extends, 0.8, None)
7431            .await
7432            .unwrap();
7433        let base_edge_id = Uuid::from(base_edge.id);
7434
7435        // An edge whose TARGET is another edge — the "edge-as-node" case
7436        // `delete_edge`'s cascade must sweep.
7437        let annotating = raw_edge(n.id, base_edge_id, tok.namespace().as_str());
7438        assert_eq!(
7439            rt.graph(&tok)
7440                .unwrap()
7441                .upsert_edge_guarded(annotating)
7442                .await
7443                .unwrap(),
7444            khive_storage::GuardedWriteOutcome::Written
7445        );
7446
7447        assert!(rt.delete_edge(&tok, base_edge_id, true).await.unwrap());
7448        assert_no_edges_touch(&rt, &tok, base_edge_id).await;
7449    }
7450
7451    #[tokio::test]
7452    async fn hard_delete_edge_endpoint_concurrent_with_guarded_write_never_leaves_dangling_edge() {
7453        let rt = std::sync::Arc::new(rt());
7454        let tok = NamespaceToken::local();
7455        let a = rt
7456            .create_entity(&tok, "concept", None, "A", None, None, vec![])
7457            .await
7458            .unwrap();
7459        let b = rt
7460            .create_entity(&tok, "concept", None, "B", None, None, vec![])
7461            .await
7462            .unwrap();
7463        let n = rt
7464            .create_note(
7465                &tok,
7466                "observation",
7467                None,
7468                "note content",
7469                None,
7470                None,
7471                vec![],
7472            )
7473            .await
7474            .unwrap();
7475        let base_edge = rt
7476            .link(&tok, a.id, b.id, EdgeRelation::Extends, 0.8, None)
7477            .await
7478            .unwrap();
7479        let base_edge_id = Uuid::from(base_edge.id);
7480
7481        let delete_rt = std::sync::Arc::clone(&rt);
7482        let delete_tok = tok.clone();
7483        let delete_task =
7484            tokio::spawn(
7485                async move { delete_rt.delete_edge(&delete_tok, base_edge_id, true).await },
7486            );
7487
7488        let write_rt = std::sync::Arc::clone(&rt);
7489        let write_tok = tok.clone();
7490        let ns = tok.namespace().as_str().to_string();
7491        let write_task = tokio::spawn(async move {
7492            let edge = raw_edge(n.id, base_edge_id, &ns);
7493            write_rt
7494                .graph(&write_tok)
7495                .unwrap()
7496                .upsert_edge_guarded(edge)
7497                .await
7498        });
7499
7500        let (deleted, _written) = tokio::join!(delete_task, write_task);
7501        deleted.unwrap().unwrap();
7502        assert_no_edges_touch(&rt, &tok, base_edge_id).await;
7503    }
7504
7505    // ---- file-backed, both write-queue configs ----
7506    //
7507    // The six tests above run against `KhiveRuntime::memory()` and race
7508    // delete against the guarded write via `tokio::join!` with no explicit
7509    // ordering control, so the scheduler could run them fully sequentially
7510    // on one thread without ever exercising real interleaving, and neither
7511    // the file-backed storage path nor `write_queue_enabled: true`
7512    // (`KHIVE_WRITE_QUEUE=1`, the `WriterTask`-routed write path in
7513    // `SqlGraphStore`) is covered at all. The four tests below close both
7514    // gaps: file-backed databases, one run with the writer queue off
7515    // (default) and one with it on, each provably forcing the guarded write
7516    // to land on one specific side of the delete — fully committed before
7517    // the delete starts (swept by the delete's cascade) and attempted only
7518    // after the delete has already committed (refused by the guard) — via
7519    // plain `.await` sequencing rather than a race whose outcome the test
7520    // does not control.
7521
7522    fn file_backed_runtime(
7523        dir: &tempfile::TempDir,
7524        name: &str,
7525        write_queue_enabled: bool,
7526    ) -> KhiveRuntime {
7527        let path = dir.path().join(name);
7528        if write_queue_enabled {
7529            std::env::set_var("KHIVE_WRITE_QUEUE", "1");
7530        } else {
7531            std::env::remove_var("KHIVE_WRITE_QUEUE");
7532        }
7533        let rt = KhiveRuntime::new(crate::config::RuntimeConfig {
7534            db_path: Some(path),
7535            packs: vec!["kg".to_string()],
7536            brain_profile: None,
7537            actor_id: None,
7538            ..crate::config::RuntimeConfig::no_embeddings()
7539        })
7540        .unwrap();
7541        std::env::remove_var("KHIVE_WRITE_QUEUE");
7542        rt
7543    }
7544
7545    async fn assert_guarded_write_committed_before_delete_is_swept(rt: &KhiveRuntime) {
7546        let tok = NamespaceToken::local();
7547        let a = rt
7548            .create_entity(&tok, "concept", None, "A", None, None, vec![])
7549            .await
7550            .unwrap();
7551        let x = rt
7552            .create_entity(&tok, "concept", None, "X", None, None, vec![])
7553            .await
7554            .unwrap();
7555
7556        // Write lands fully committed while X is still live — squarely
7557        // inside the window before the delete's cascade runs.
7558        let edge = raw_edge(a.id, x.id, tok.namespace().as_str());
7559        assert_eq!(
7560            rt.graph(&tok)
7561                .unwrap()
7562                .upsert_edge_guarded(edge)
7563                .await
7564                .unwrap(),
7565            khive_storage::GuardedWriteOutcome::Written,
7566            "write must succeed while both endpoints are still live"
7567        );
7568
7569        assert!(rt.delete_entity(&tok, x.id, true).await.unwrap());
7570        assert_no_edges_touch(rt, &tok, x.id).await;
7571    }
7572
7573    async fn assert_guarded_write_attempted_after_delete_is_refused(rt: &KhiveRuntime) {
7574        let tok = NamespaceToken::local();
7575        let a = rt
7576            .create_entity(&tok, "concept", None, "A", None, None, vec![])
7577            .await
7578            .unwrap();
7579        let x = rt
7580            .create_entity(&tok, "concept", None, "X", None, None, vec![])
7581            .await
7582            .unwrap();
7583
7584        // The delete's transaction has fully committed before the guarded
7585        // write is even attempted — squarely after the window has closed.
7586        assert!(rt.delete_entity(&tok, x.id, true).await.unwrap());
7587
7588        let edge = raw_edge(a.id, x.id, tok.namespace().as_str());
7589        let outcome = rt
7590            .graph(&tok)
7591            .unwrap()
7592            .upsert_edge_guarded(edge)
7593            .await
7594            .unwrap();
7595        match outcome {
7596            khive_storage::GuardedWriteOutcome::Refused(missing) => {
7597                assert!(
7598                    missing.target,
7599                    "target must be reported missing once the delete has committed"
7600                );
7601                assert!(!missing.source, "source was never deleted");
7602            }
7603            other => panic!(
7604                "guarded write attempted after the delete committed must be refused, got {other:?}"
7605            ),
7606        }
7607        assert_no_edges_touch(rt, &tok, x.id).await;
7608    }
7609
7610    #[tokio::test]
7611    #[serial_test::serial(khive_write_queue_env)]
7612    async fn guarded_write_before_delete_swept_file_backed_write_queue_off() {
7613        let dir = tempfile::tempdir().unwrap();
7614        let rt = file_backed_runtime(&dir, "guard_before_off.db", false);
7615        assert_guarded_write_committed_before_delete_is_swept(&rt).await;
7616    }
7617
7618    #[tokio::test]
7619    #[serial_test::serial(khive_write_queue_env)]
7620    async fn guarded_write_after_delete_refused_file_backed_write_queue_off() {
7621        let dir = tempfile::tempdir().unwrap();
7622        let rt = file_backed_runtime(&dir, "guard_after_off.db", false);
7623        assert_guarded_write_attempted_after_delete_is_refused(&rt).await;
7624    }
7625
7626    #[tokio::test]
7627    #[serial_test::serial(khive_write_queue_env)]
7628    async fn guarded_write_before_delete_swept_file_backed_write_queue_on() {
7629        let dir = tempfile::tempdir().unwrap();
7630        let rt = file_backed_runtime(&dir, "guard_before_on.db", true);
7631        assert_guarded_write_committed_before_delete_is_swept(&rt).await;
7632    }
7633
7634    #[tokio::test]
7635    #[serial_test::serial(khive_write_queue_env)]
7636    async fn guarded_write_after_delete_refused_file_backed_write_queue_on() {
7637        let dir = tempfile::tempdir().unwrap();
7638        let rt = file_backed_runtime(&dir, "guard_after_on.db", true);
7639        assert_guarded_write_attempted_after_delete_is_refused(&rt).await;
7640    }
7641
7642    #[tokio::test]
7643    async fn create_note_annotates_phantom_returns_not_found() {
7644        let rt = rt();
7645        let tok = NamespaceToken::local();
7646        let phantom = Uuid::new_v4();
7647
7648        let result = rt
7649            .create_note(
7650                &tok,
7651                "observation",
7652                None,
7653                "some content",
7654                Some(0.5),
7655                None,
7656                vec![phantom],
7657            )
7658            .await;
7659        assert!(
7660            matches!(result, Err(RuntimeError::NotFound(_))),
7661            "annotates with phantom uuid must return NotFound, got {result:?}"
7662        );
7663    }
7664
7665    #[tokio::test]
7666    async fn create_note_annotates_real_entity_succeeds() {
7667        let rt = rt();
7668        let tok = NamespaceToken::local();
7669        let entity = rt
7670            .create_entity(&tok, "concept", None, "RealTarget", None, None, vec![])
7671            .await
7672            .unwrap();
7673
7674        let note = rt
7675            .create_note(
7676                &tok,
7677                "observation",
7678                None,
7679                "content",
7680                Some(0.5),
7681                None,
7682                vec![entity.id],
7683            )
7684            .await
7685            .unwrap();
7686
7687        let neighbors = rt
7688            .neighbors(
7689                &tok,
7690                note.id,
7691                Direction::Out,
7692                None,
7693                Some(vec![EdgeRelation::Annotates]),
7694            )
7695            .await
7696            .unwrap();
7697        assert_eq!(neighbors.len(), 1);
7698        assert_eq!(neighbors[0].node_id, entity.id);
7699    }
7700
7701    // Atomicity: multi-target annotates golden path — all edges created, note present.
7702    #[tokio::test]
7703    async fn create_note_multi_annotates_creates_all_edges() {
7704        let rt = rt();
7705        let tok = NamespaceToken::local();
7706        let t1 = rt
7707            .create_entity(&tok, "concept", None, "Target1", None, None, vec![])
7708            .await
7709            .unwrap();
7710        let t2 = rt
7711            .create_entity(&tok, "concept", None, "Target2", None, None, vec![])
7712            .await
7713            .unwrap();
7714
7715        let note = rt
7716            .create_note(
7717                &tok,
7718                "observation",
7719                None,
7720                "content",
7721                Some(0.5),
7722                None,
7723                vec![t1.id, t2.id],
7724            )
7725            .await
7726            .unwrap();
7727
7728        let neighbors = rt
7729            .neighbors(
7730                &tok,
7731                note.id,
7732                Direction::Out,
7733                None,
7734                Some(vec![EdgeRelation::Annotates]),
7735            )
7736            .await
7737            .unwrap();
7738        assert_eq!(
7739            neighbors.len(),
7740            2,
7741            "multi-annotates note must have exactly 2 outbound annotates edges"
7742        );
7743        let target_ids: Vec<Uuid> = neighbors.iter().map(|n| n.node_id).collect();
7744        assert!(target_ids.contains(&t1.id));
7745        assert!(target_ids.contains(&t2.id));
7746    }
7747
7748    /// `link` endpoint existence is a by-ID check and therefore namespace-agnostic:
7749    /// a target living in a different namespace than the caller must still
7750    /// resolve, exactly as `get()` would.
7751    #[tokio::test]
7752    async fn link_target_in_different_namespace_succeeds() {
7753        let rt = rt();
7754        let ns_a = NamespaceToken::for_namespace(Namespace::parse("ns-a").unwrap());
7755        let ns_b = NamespaceToken::for_namespace(Namespace::parse("ns-b").unwrap());
7756        let a = rt
7757            .create_entity(&ns_a, "concept", None, "A", None, None, vec![])
7758            .await
7759            .unwrap();
7760        let b = rt
7761            .create_entity(&ns_b, "concept", None, "B", None, None, vec![])
7762            .await
7763            .unwrap();
7764
7765        // Linking from ns-a: target b lives in ns-b — by-ID resolution finds it anyway.
7766        let result = rt
7767            .link(&ns_a, a.id, b.id, EdgeRelation::Extends, 1.0, None)
7768            .await;
7769        assert!(
7770            result.is_ok(),
7771            "target in a different namespace than the caller must resolve (#631), got {result:?}"
7772        );
7773    }
7774
7775    #[tokio::test]
7776    async fn link_phantom_self_loop_returns_invalid_input() {
7777        let rt = rt();
7778        let tok = NamespaceToken::local();
7779        let phantom = Uuid::new_v4();
7780
7781        let result = rt
7782            .link(&tok, phantom, phantom, EdgeRelation::Extends, 1.0, None)
7783            .await;
7784        match result {
7785            Err(RuntimeError::InvalidInput(msg)) => {
7786                assert!(
7787                    msg.contains("self-loop"),
7788                    "self-loop must be rejected with self-loop message: {msg}"
7789                );
7790            }
7791            other => panic!("expected InvalidInput for self-loop, got {other:?}"),
7792        }
7793    }
7794
7795    // ---- edge target coverage + atomicity ----
7796
7797    #[tokio::test]
7798    async fn link_note_to_edge_annotates_succeeds() {
7799        let rt = rt();
7800        let tok = NamespaceToken::local();
7801        let a = rt
7802            .create_entity(&tok, "concept", None, "A", None, None, vec![])
7803            .await
7804            .unwrap();
7805        let b = rt
7806            .create_entity(&tok, "concept", None, "B", None, None, vec![])
7807            .await
7808            .unwrap();
7809        // Create a real edge between a and b, capture its UUID.
7810        let edge = rt
7811            .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
7812            .await
7813            .unwrap();
7814        let edge_uuid: Uuid = edge.id.into();
7815
7816        // Create a note and annotate the edge itself (edge is a valid substrate target for annotates).
7817        let note = rt
7818            .create_note(
7819                &tok,
7820                "observation",
7821                None,
7822                "edge note",
7823                Some(0.5),
7824                None,
7825                vec![],
7826            )
7827            .await
7828            .unwrap();
7829
7830        let result = rt
7831            .link(&tok, note.id, edge_uuid, EdgeRelation::Annotates, 1.0, None)
7832            .await;
7833        assert!(
7834            result.is_ok(),
7835            "note→edge Annotates must succeed, got {result:?}"
7836        );
7837    }
7838
7839    #[tokio::test]
7840    async fn create_note_annotates_real_edge_succeeds() {
7841        let rt = rt();
7842        let tok = NamespaceToken::local();
7843        let a = rt
7844            .create_entity(&tok, "concept", None, "A", None, None, vec![])
7845            .await
7846            .unwrap();
7847        let b = rt
7848            .create_entity(&tok, "concept", None, "B", None, None, vec![])
7849            .await
7850            .unwrap();
7851        let edge = rt
7852            .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
7853            .await
7854            .unwrap();
7855        let edge_uuid: Uuid = edge.id.into();
7856
7857        let note = rt
7858            .create_note(
7859                &tok,
7860                "observation",
7861                None,
7862                "annotating an edge",
7863                Some(0.5),
7864                None,
7865                vec![edge_uuid],
7866            )
7867            .await
7868            .unwrap();
7869
7870        let neighbors = rt
7871            .neighbors(
7872                &tok,
7873                note.id,
7874                Direction::Out,
7875                None,
7876                Some(vec![EdgeRelation::Annotates]),
7877            )
7878            .await
7879            .unwrap();
7880        assert_eq!(neighbors.len(), 1);
7881        assert_eq!(neighbors[0].node_id, edge_uuid);
7882    }
7883
7884    #[tokio::test]
7885    async fn create_note_annotates_phantom_is_atomic_no_note_persisted() {
7886        let rt = rt();
7887        let tok = NamespaceToken::local();
7888        let phantom = Uuid::new_v4();
7889
7890        let before_count = rt.list_notes(&tok, None, 1000, 0).await.unwrap().len();
7891
7892        let result = rt
7893            .create_note(
7894                &tok,
7895                "observation",
7896                None,
7897                "should not persist",
7898                Some(0.5),
7899                None,
7900                vec![phantom],
7901            )
7902            .await;
7903        assert!(
7904            matches!(result, Err(RuntimeError::NotFound(_))),
7905            "phantom annotates target must return NotFound, got {result:?}"
7906        );
7907
7908        // Atomicity: the note row must NOT have been written.
7909        let after_count = rt.list_notes(&tok, None, 1000, 0).await.unwrap().len();
7910        assert_eq!(
7911            before_count, after_count,
7912            "failed create_note must not persist any note row (atomicity)"
7913        );
7914
7915        // FTS must not contain the content either.
7916        let search_hits = rt
7917            .search_notes(&tok, "should not persist", None, 10, None, false, &[], None)
7918            .await
7919            .unwrap();
7920        assert!(
7921            search_hits.is_empty(),
7922            "failed create_note must not index into FTS (atomicity)"
7923        );
7924        // Vector-store row: only written when an embedding model is configured; the rt()
7925        // harness has none, so no vector assertion is needed here.
7926    }
7927
7928    // ---- relation-aware endpoint contract ----
7929
7930    // Test #2: entity→entity with non-annotates rejects an edge UUID as target.
7931    #[tokio::test]
7932    async fn link_entity_to_edge_uuid_non_annotates_returns_invalid_input() {
7933        let rt = rt();
7934        let tok = NamespaceToken::local();
7935        let a = rt
7936            .create_entity(&tok, "concept", None, "A", None, None, vec![])
7937            .await
7938            .unwrap();
7939        let b = rt
7940            .create_entity(&tok, "concept", None, "B", None, None, vec![])
7941            .await
7942            .unwrap();
7943        // Create a real edge; capture its UUID as the bad target.
7944        let edge = rt
7945            .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
7946            .await
7947            .unwrap();
7948        let edge_uuid: Uuid = edge.id.into();
7949
7950        let result = rt
7951            .link(&tok, a.id, edge_uuid, EdgeRelation::Extends, 1.0, None)
7952            .await;
7953        match result {
7954            Err(RuntimeError::InvalidInput(msg)) => {
7955                assert!(
7956                    msg.contains("target"),
7957                    "error message must name 'target': {msg}"
7958                );
7959            }
7960            other => {
7961                panic!("expected InvalidInput for edge-uuid target with Extends, got {other:?}")
7962            }
7963        }
7964    }
7965
7966    // Test #3: non-annotates rejects a note UUID as source.
7967    #[tokio::test]
7968    async fn link_note_as_source_non_annotates_returns_invalid_input() {
7969        let rt = rt();
7970        let tok = NamespaceToken::local();
7971        let note = rt
7972            .create_note(&tok, "observation", None, "a note", Some(0.5), None, vec![])
7973            .await
7974            .unwrap();
7975        let entity = rt
7976            .create_entity(&tok, "concept", None, "E", None, None, vec![])
7977            .await
7978            .unwrap();
7979
7980        let result = rt
7981            .link(&tok, note.id, entity.id, EdgeRelation::DependsOn, 1.0, None)
7982            .await;
7983        match result {
7984            Err(RuntimeError::InvalidInput(msg)) => {
7985                assert!(
7986                    msg.contains("source"),
7987                    "error message must name 'source': {msg}"
7988                );
7989            }
7990            other => panic!("expected InvalidInput for note source with DependsOn, got {other:?}"),
7991        }
7992    }
7993
7994    // Test #4: annotates rejects entity as source (source must be a note).
7995    #[tokio::test]
7996    async fn link_entity_as_annotates_source_returns_invalid_input() {
7997        let rt = rt();
7998        let tok = NamespaceToken::local();
7999        let a = rt
8000            .create_entity(&tok, "concept", None, "A", None, None, vec![])
8001            .await
8002            .unwrap();
8003        let b = rt
8004            .create_entity(&tok, "concept", None, "B", None, None, vec![])
8005            .await
8006            .unwrap();
8007
8008        let result = rt
8009            .link(&tok, a.id, b.id, EdgeRelation::Annotates, 1.0, None)
8010            .await;
8011        match result {
8012            Err(RuntimeError::InvalidInput(msg)) => {
8013                assert!(
8014                    msg.contains("source") && msg.contains("note"),
8015                    "error must say source must be a note: {msg}"
8016                );
8017            }
8018            other => {
8019                panic!("expected InvalidInput for entity source with Annotates, got {other:?}")
8020            }
8021        }
8022    }
8023
8024    #[tokio::test]
8025    async fn link_edge_as_annotates_source_returns_invalid_input() {
8026        let rt = rt();
8027        let tok = NamespaceToken::local();
8028        let a = rt
8029            .create_entity(&tok, "concept", None, "A", None, None, vec![])
8030            .await
8031            .unwrap();
8032        let b = rt
8033            .create_entity(&tok, "concept", None, "B", None, None, vec![])
8034            .await
8035            .unwrap();
8036        let edge = rt
8037            .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
8038            .await
8039            .unwrap();
8040        let edge_uuid: Uuid = edge.id.into();
8041
8042        // An existing edge used as an annotates source: wrong kind, not absent.
8043        let result = rt
8044            .link(&tok, edge_uuid, a.id, EdgeRelation::Annotates, 1.0, None)
8045            .await;
8046        match result {
8047            Err(RuntimeError::InvalidInput(msg)) => {
8048                assert!(
8049                    msg.contains("source") && msg.contains("note"),
8050                    "edge-as-annotates-source must report wrong kind, not NotFound: {msg}"
8051                );
8052            }
8053            other => panic!("expected InvalidInput for edge source with Annotates, got {other:?}"),
8054        }
8055    }
8056
8057    // Test #5: note→event with annotates succeeds (event is a valid annotates target).
8058    #[tokio::test]
8059    async fn link_note_to_event_annotates_succeeds() {
8060        use khive_storage::Event;
8061        use khive_types::{EventKind, SubstrateKind};
8062
8063        let rt = rt();
8064        let tok = NamespaceToken::local();
8065        let note = rt
8066            .create_note(
8067                &tok,
8068                "observation",
8069                None,
8070                "observing an event",
8071                Some(0.6),
8072                None,
8073                vec![],
8074            )
8075            .await
8076            .unwrap();
8077
8078        // Build an event directly via the store (no runtime create_event exists).
8079        let ns = tok.namespace().as_str();
8080        let event = Event::new(
8081            ns,
8082            "test_verb",
8083            EventKind::Audit,
8084            SubstrateKind::Entity,
8085            "test_actor",
8086        );
8087        let event_id = event.id;
8088        rt.events(&tok).unwrap().append_event(event).await.unwrap();
8089
8090        let result = rt
8091            .link(&tok, note.id, event_id, EdgeRelation::Annotates, 1.0, None)
8092            .await;
8093        assert!(
8094            result.is_ok(),
8095            "note→event Annotates must succeed, got {result:?}"
8096        );
8097    }
8098
8099    // Test #6: create_note with event as annotates target succeeds.
8100    #[tokio::test]
8101    async fn create_note_annotates_event_succeeds() {
8102        use khive_storage::Event;
8103        use khive_types::{EventKind, SubstrateKind};
8104
8105        let rt = rt();
8106        let tok = NamespaceToken::local();
8107        let ns = tok.namespace().as_str();
8108        let event = Event::new(
8109            ns,
8110            "test_verb",
8111            EventKind::Audit,
8112            SubstrateKind::Entity,
8113            "test_actor",
8114        );
8115        let event_id = event.id;
8116        rt.events(&tok).unwrap().append_event(event).await.unwrap();
8117
8118        let result = rt
8119            .create_note(
8120                &tok,
8121                "observation",
8122                None,
8123                "note annotating an event",
8124                Some(0.5),
8125                None,
8126                vec![event_id],
8127            )
8128            .await;
8129        assert!(
8130            result.is_ok(),
8131            "create_note with event annotates target must succeed, got {result:?}"
8132        );
8133        // Verify the annotates edge was created.
8134        let note = result.unwrap();
8135        let neighbors = rt
8136            .neighbors(
8137                &tok,
8138                note.id,
8139                Direction::Out,
8140                None,
8141                Some(vec![EdgeRelation::Annotates]),
8142            )
8143            .await
8144            .unwrap();
8145        assert_eq!(neighbors.len(), 1);
8146        assert_eq!(neighbors[0].node_id, event_id);
8147    }
8148
8149    // ---- supersedes same-substrate contract ----
8150
8151    // Headline regression: note→note supersedes must succeed (was wrongly rejected before this fix).
8152    #[tokio::test]
8153    async fn link_supersedes_note_to_note_succeeds() {
8154        let rt = rt();
8155        let tok = NamespaceToken::local();
8156        let old_note = rt
8157            .create_note(
8158                &tok,
8159                "observation",
8160                None,
8161                "old observation",
8162                Some(0.7),
8163                None,
8164                vec![],
8165            )
8166            .await
8167            .unwrap();
8168        let new_note = rt
8169            .create_note(
8170                &tok,
8171                "observation",
8172                None,
8173                "revised observation superseding the old one",
8174                Some(0.9),
8175                None,
8176                vec![],
8177            )
8178            .await
8179            .unwrap();
8180
8181        let result = rt
8182            .link(
8183                &tok,
8184                new_note.id,
8185                old_note.id,
8186                EdgeRelation::Supersedes,
8187                1.0,
8188                None,
8189            )
8190            .await;
8191        assert!(
8192            result.is_ok(),
8193            "note→note Supersedes must succeed (note supersession), got {result:?}"
8194        );
8195    }
8196
8197    #[tokio::test]
8198    async fn link_supersedes_entity_to_entity_succeeds() {
8199        let rt = rt();
8200        let tok = NamespaceToken::local();
8201        let old_entity = rt
8202            .create_entity(&tok, "concept", None, "OldConcept", None, None, vec![])
8203            .await
8204            .unwrap();
8205        let new_entity = rt
8206            .create_entity(&tok, "concept", None, "NewConcept", None, None, vec![])
8207            .await
8208            .unwrap();
8209
8210        let result = rt
8211            .link(
8212                &tok,
8213                new_entity.id,
8214                old_entity.id,
8215                EdgeRelation::Supersedes,
8216                1.0,
8217                None,
8218            )
8219            .await;
8220        assert!(
8221            result.is_ok(),
8222            "entity→entity Supersedes must succeed, got {result:?}"
8223        );
8224    }
8225
8226    #[tokio::test]
8227    async fn link_supersedes_note_to_entity_returns_invalid_input() {
8228        let rt = rt();
8229        let tok = NamespaceToken::local();
8230        let note = rt
8231            .create_note(&tok, "observation", None, "a note", Some(0.5), None, vec![])
8232            .await
8233            .unwrap();
8234        let entity = rt
8235            .create_entity(&tok, "concept", None, "SomeEntity", None, None, vec![])
8236            .await
8237            .unwrap();
8238
8239        let result = rt
8240            .link(
8241                &tok,
8242                note.id,
8243                entity.id,
8244                EdgeRelation::Supersedes,
8245                1.0,
8246                None,
8247            )
8248            .await;
8249        match result {
8250            Err(RuntimeError::InvalidInput(msg)) => {
8251                assert!(
8252                    msg.contains("same substrate") || msg.contains("same-substrate"),
8253                    "error must name the same-substrate rule: {msg}"
8254                );
8255            }
8256            other => panic!(
8257                "expected InvalidInput for note→entity Supersedes (cross-substrate), got {other:?}"
8258            ),
8259        }
8260    }
8261
8262    #[tokio::test]
8263    async fn link_supersedes_entity_to_note_returns_invalid_input() {
8264        let rt = rt();
8265        let tok = NamespaceToken::local();
8266        let entity = rt
8267            .create_entity(&tok, "concept", None, "SomeEntity", None, None, vec![])
8268            .await
8269            .unwrap();
8270        let note = rt
8271            .create_note(&tok, "observation", None, "a note", Some(0.5), None, vec![])
8272            .await
8273            .unwrap();
8274
8275        let result = rt
8276            .link(
8277                &tok,
8278                entity.id,
8279                note.id,
8280                EdgeRelation::Supersedes,
8281                1.0,
8282                None,
8283            )
8284            .await;
8285        match result {
8286            Err(RuntimeError::InvalidInput(msg)) => {
8287                assert!(
8288                    msg.contains("same substrate") || msg.contains("same-substrate"),
8289                    "error must name the same-substrate rule: {msg}"
8290                );
8291            }
8292            other => panic!(
8293                "expected InvalidInput for entity→note Supersedes (cross-substrate), got {other:?}"
8294            ),
8295        }
8296    }
8297
8298    #[tokio::test]
8299    async fn link_supersedes_event_source_returns_invalid_input() {
8300        use khive_storage::Event;
8301        use khive_types::{EventKind, SubstrateKind};
8302
8303        let rt = rt();
8304        let tok = NamespaceToken::local();
8305        let ns = tok.namespace().as_str();
8306        let event = Event::new(
8307            ns,
8308            "test_verb",
8309            EventKind::Audit,
8310            SubstrateKind::Entity,
8311            "test_actor",
8312        );
8313        let event_id = event.id;
8314        rt.events(&tok).unwrap().append_event(event).await.unwrap();
8315
8316        let entity = rt
8317            .create_entity(&tok, "concept", None, "SomeEntity", None, None, vec![])
8318            .await
8319            .unwrap();
8320
8321        let result = rt
8322            .link(
8323                &tok,
8324                event_id,
8325                entity.id,
8326                EdgeRelation::Supersedes,
8327                1.0,
8328                None,
8329            )
8330            .await;
8331        match result {
8332            Err(RuntimeError::InvalidInput(msg)) => {
8333                assert!(msg.contains("event"), "error must mention 'event': {msg}");
8334            }
8335            other => {
8336                panic!("expected InvalidInput for event source with Supersedes, got {other:?}")
8337            }
8338        }
8339    }
8340
8341    #[tokio::test]
8342    async fn link_supersedes_event_target_returns_invalid_input() {
8343        use khive_storage::Event;
8344        use khive_types::{EventKind, SubstrateKind};
8345
8346        let rt = rt();
8347        let tok = NamespaceToken::local();
8348        let ns = tok.namespace().as_str();
8349        let event = Event::new(
8350            ns,
8351            "test_verb",
8352            EventKind::Audit,
8353            SubstrateKind::Entity,
8354            "test_actor",
8355        );
8356        let event_id = event.id;
8357        rt.events(&tok).unwrap().append_event(event).await.unwrap();
8358
8359        let entity = rt
8360            .create_entity(&tok, "concept", None, "SomeEntity", None, None, vec![])
8361            .await
8362            .unwrap();
8363
8364        let result = rt
8365            .link(
8366                &tok,
8367                entity.id,
8368                event_id,
8369                EdgeRelation::Supersedes,
8370                1.0,
8371                None,
8372            )
8373            .await;
8374        match result {
8375            Err(RuntimeError::InvalidInput(msg)) => {
8376                assert!(msg.contains("event"), "error must mention 'event': {msg}");
8377            }
8378            other => {
8379                panic!("expected InvalidInput for event target with Supersedes, got {other:?}")
8380            }
8381        }
8382    }
8383
8384    #[tokio::test]
8385    async fn link_supersedes_edge_source_returns_invalid_input() {
8386        let rt = rt();
8387        let tok = NamespaceToken::local();
8388        let a = rt
8389            .create_entity(&tok, "concept", None, "A", None, None, vec![])
8390            .await
8391            .unwrap();
8392        let b = rt
8393            .create_entity(&tok, "concept", None, "B", None, None, vec![])
8394            .await
8395            .unwrap();
8396        let edge = rt
8397            .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
8398            .await
8399            .unwrap();
8400        let edge_uuid: Uuid = edge.id.into();
8401
8402        let result = rt
8403            .link(&tok, edge_uuid, a.id, EdgeRelation::Supersedes, 1.0, None)
8404            .await;
8405        match result {
8406            Err(RuntimeError::InvalidInput(msg)) => {
8407                assert!(msg.contains("source"), "error must name 'source': {msg}");
8408            }
8409            other => {
8410                panic!("expected InvalidInput for edge-uuid source with Supersedes, got {other:?}")
8411            }
8412        }
8413    }
8414
8415    #[tokio::test]
8416    async fn link_supersedes_edge_target_returns_invalid_input() {
8417        let rt = rt();
8418        let tok = NamespaceToken::local();
8419        let a = rt
8420            .create_entity(&tok, "concept", None, "A", None, None, vec![])
8421            .await
8422            .unwrap();
8423        let b = rt
8424            .create_entity(&tok, "concept", None, "B", None, None, vec![])
8425            .await
8426            .unwrap();
8427        let edge = rt
8428            .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
8429            .await
8430            .unwrap();
8431        let edge_uuid: Uuid = edge.id.into();
8432
8433        let result = rt
8434            .link(&tok, a.id, edge_uuid, EdgeRelation::Supersedes, 1.0, None)
8435            .await;
8436        match result {
8437            Err(RuntimeError::InvalidInput(msg)) => {
8438                assert!(msg.contains("target"), "error must name 'target': {msg}");
8439            }
8440            other => {
8441                panic!("expected InvalidInput for edge-uuid target with Supersedes, got {other:?}")
8442            }
8443        }
8444    }
8445
8446    #[tokio::test]
8447    async fn link_supersedes_phantom_source_returns_not_found() {
8448        let rt = rt();
8449        let tok = NamespaceToken::local();
8450        let note = rt
8451            .create_note(
8452                &tok,
8453                "observation",
8454                None,
8455                "existing note",
8456                Some(0.5),
8457                None,
8458                vec![],
8459            )
8460            .await
8461            .unwrap();
8462        let phantom = Uuid::new_v4();
8463
8464        let result = rt
8465            .link(&tok, phantom, note.id, EdgeRelation::Supersedes, 1.0, None)
8466            .await;
8467        match result {
8468            Err(RuntimeError::NotFound(msg)) => {
8469                assert!(msg.contains("source"), "error must name 'source': {msg}");
8470            }
8471            other => panic!("expected NotFound for phantom source with Supersedes, got {other:?}"),
8472        }
8473    }
8474
8475    #[tokio::test]
8476    async fn link_supersedes_phantom_target_returns_not_found() {
8477        let rt = rt();
8478        let tok = NamespaceToken::local();
8479        let note = rt
8480            .create_note(
8481                &tok,
8482                "observation",
8483                None,
8484                "existing note",
8485                Some(0.5),
8486                None,
8487                vec![],
8488            )
8489            .await
8490            .unwrap();
8491        let phantom = Uuid::new_v4();
8492
8493        let result = rt
8494            .link(&tok, note.id, phantom, EdgeRelation::Supersedes, 1.0, None)
8495            .await;
8496        match result {
8497            Err(RuntimeError::NotFound(msg)) => {
8498                assert!(msg.contains("target"), "error must name 'target': {msg}");
8499            }
8500            other => panic!("expected NotFound for phantom target with Supersedes, got {other:?}"),
8501        }
8502    }
8503
8504    /// The canonical `remember | supersedes` chain: a `supersedes` source note living
8505    /// in a different namespace than the caller must still resolve as a by-ID endpoint.
8506    #[tokio::test]
8507    async fn link_supersedes_cross_namespace_source_succeeds() {
8508        let rt = rt();
8509        let ns_a = NamespaceToken::for_namespace(Namespace::parse("ns-a").unwrap());
8510        let ns_b = NamespaceToken::for_namespace(Namespace::parse("ns-b").unwrap());
8511        let note_a = rt
8512            .create_note(
8513                &ns_a,
8514                "observation",
8515                None,
8516                "note in ns-a",
8517                Some(0.5),
8518                None,
8519                vec![],
8520            )
8521            .await
8522            .unwrap();
8523        let note_b = rt
8524            .create_note(
8525                &ns_b,
8526                "observation",
8527                None,
8528                "note in ns-b",
8529                Some(0.5),
8530                None,
8531                vec![],
8532            )
8533            .await
8534            .unwrap();
8535
8536        // From ns-a perspective, note_b is in a different namespace — by-ID resolution
8537        // finds it anyway.
8538        let result = rt
8539            .link(
8540                &ns_a,
8541                note_b.id,
8542                note_a.id,
8543                EdgeRelation::Supersedes,
8544                1.0,
8545                None,
8546            )
8547            .await;
8548        assert!(
8549            result.is_ok(),
8550            "cross-namespace supersedes source must resolve (#631), got {result:?}"
8551        );
8552    }
8553
8554    // Sanity: extends (non-annotates, non-supersedes) still requires entity→entity.
8555    #[tokio::test]
8556    async fn link_extends_note_source_still_returns_invalid_input() {
8557        let rt = rt();
8558        let tok = NamespaceToken::local();
8559        let note = rt
8560            .create_note(
8561                &tok,
8562                "observation",
8563                None,
8564                "a note that cannot be an extends source",
8565                Some(0.5),
8566                None,
8567                vec![],
8568            )
8569            .await
8570            .unwrap();
8571        let entity = rt
8572            .create_entity(&tok, "concept", None, "E", None, None, vec![])
8573            .await
8574            .unwrap();
8575
8576        let result = rt
8577            .link(&tok, note.id, entity.id, EdgeRelation::Extends, 1.0, None)
8578            .await;
8579        assert!(
8580            matches!(result, Err(RuntimeError::InvalidInput(_))),
8581            "note source with Extends must still return InvalidInput after this fix, got {result:?}"
8582        );
8583    }
8584
8585    // Sanity: annotates note→edge still succeeds (unchanged path not broken by this fix).
8586    #[tokio::test]
8587    async fn link_annotates_note_to_edge_still_succeeds_after_fix() {
8588        let rt = rt();
8589        let tok = NamespaceToken::local();
8590        let a = rt
8591            .create_entity(&tok, "concept", None, "A", None, None, vec![])
8592            .await
8593            .unwrap();
8594        let b = rt
8595            .create_entity(&tok, "concept", None, "B", None, None, vec![])
8596            .await
8597            .unwrap();
8598        let edge = rt
8599            .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
8600            .await
8601            .unwrap();
8602        let edge_uuid: Uuid = edge.id.into();
8603
8604        let note = rt
8605            .create_note(
8606                &tok,
8607                "observation",
8608                None,
8609                "annotating an edge",
8610                Some(0.5),
8611                None,
8612                vec![],
8613            )
8614            .await
8615            .unwrap();
8616
8617        let result = rt
8618            .link(&tok, note.id, edge_uuid, EdgeRelation::Annotates, 1.0, None)
8619            .await;
8620        assert!(
8621            result.is_ok(),
8622            "note→edge Annotates must still succeed after supersedes fix, got {result:?}"
8623        );
8624    }
8625
8626    // ---- Compensation-path rollback (fix/annotates) ----
8627
8628    // The compensation branch in `create_note_inner` (operations.rs) rolls back
8629    // a partial write — note row + first edge + FTS + vector — when a subsequent
8630    // link call fails. The failure trigger is a storage error (e.g. I/O failure)
8631    // that cannot occur in the in-memory runtime; this test instead exercises the
8632    // exact cleanup operations that the compensation branch performs, starting from
8633    // a manually-constructed partial state, and verifies the post-cleanup invariants.
8634    //
8635    // What this covers: the cleanup sequence (delete_edge, delete_note hard, FTS
8636    // index clean) is correct and leaves the DB in a pristine state. What it does
8637    // not cover: the trigger condition (second link failure). Storage-error injection
8638    // would require a mock GraphStore, which is beyond the current test infrastructure.
8639    #[tokio::test]
8640    async fn create_note_multi_annotates_compensation_cleanup_restores_pristine_state() {
8641        let rt = rt();
8642        let tok = NamespaceToken::local();
8643        let t1 = rt
8644            .create_entity(&tok, "concept", None, "T1", None, None, vec![])
8645            .await
8646            .unwrap();
8647
8648        // Construct the partial state that the compensation branch would encounter:
8649        // note persisted + first annotates edge created.
8650        let note = rt
8651            .create_note(
8652                &tok,
8653                "observation",
8654                None,
8655                "partial note",
8656                Some(0.5),
8657                None,
8658                vec![t1.id],
8659            )
8660            .await
8661            .unwrap();
8662
8663        // Confirm the partial state exists before compensation.
8664        let before_notes = rt.list_notes(&tok, None, 1000, 0).await.unwrap();
8665        assert_eq!(before_notes.len(), 1, "note must be present before cleanup");
8666        let before_edges = rt
8667            .neighbors(
8668                &tok,
8669                note.id,
8670                Direction::Out,
8671                None,
8672                Some(vec![EdgeRelation::Annotates]),
8673            )
8674            .await
8675            .unwrap();
8676        assert_eq!(
8677            before_edges.len(),
8678            1,
8679            "one annotates edge must exist before cleanup"
8680        );
8681        let edge_id: Uuid = before_edges[0].edge_id;
8682
8683        // Execute the same cleanup sequence that `create_note_inner`'s Err branch runs.
8684        rt.delete_edge(&tok, edge_id, true).await.unwrap();
8685        rt.delete_note(&tok, note.id, true /* hard */)
8686            .await
8687            .unwrap();
8688
8689        // Post-compensation invariants:
8690        let after_notes = rt.list_notes(&tok, None, 1000, 0).await.unwrap();
8691        assert!(
8692            after_notes.is_empty(),
8693            "compensation must remove the note row; got {after_notes:?}"
8694        );
8695        let search_hits = rt
8696            .search_notes(&tok, "partial note", None, 10, None, false, &[], None)
8697            .await
8698            .unwrap();
8699        assert!(
8700            search_hits.is_empty(),
8701            "compensation must clean the FTS index; got {search_hits:?}"
8702        );
8703        let after_edges = rt
8704            .neighbors(&tok, note.id, Direction::Out, None, None)
8705            .await
8706            .unwrap();
8707        assert!(
8708            after_edges.is_empty(),
8709            "compensation must remove all partial edges; got {after_edges:?}"
8710        );
8711    }
8712
8713    // ---- Hard-delete cascade for note and edge annotation targets (fix/annotates) ----
8714
8715    // annotates is note → ANYTHING (entity, note, edge, event);
8716    // targets may be entity, edge, event, or note.
8717    // Hard-deleting any of those targets must cascade incident annotates edges.
8718    // Soft deletes leave edges (data-vs-view rule).
8719
8720    #[tokio::test]
8721    async fn annotated_entity_hard_delete_cascades_annotate_edge() {
8722        let rt = rt();
8723        let tok = NamespaceToken::local();
8724        let entity = rt
8725            .create_entity(&tok, "concept", None, "E", None, None, vec![])
8726            .await
8727            .unwrap();
8728        let note = rt
8729            .create_note(
8730                &tok,
8731                "observation",
8732                None,
8733                "note about entity",
8734                Some(0.5),
8735                None,
8736                vec![entity.id],
8737            )
8738            .await
8739            .unwrap();
8740
8741        // Confirm edge exists before delete.
8742        let before = rt
8743            .neighbors(
8744                &tok,
8745                note.id,
8746                Direction::Out,
8747                None,
8748                Some(vec![EdgeRelation::Annotates]),
8749            )
8750            .await
8751            .unwrap();
8752        assert_eq!(
8753            before.len(),
8754            1,
8755            "annotates edge must exist before entity delete"
8756        );
8757
8758        // Hard delete the entity.
8759        let deleted = rt.delete_entity(&tok, entity.id, true).await.unwrap();
8760        assert!(deleted, "entity hard delete must return true");
8761
8762        // Annotates edge must be gone.
8763        let after = rt
8764            .neighbors(
8765                &tok,
8766                note.id,
8767                Direction::Out,
8768                None,
8769                Some(vec![EdgeRelation::Annotates]),
8770            )
8771            .await
8772            .unwrap();
8773        assert!(
8774            after.is_empty(),
8775            "annotates edge must be cascaded on entity hard delete; got {after:?}"
8776        );
8777    }
8778
8779    #[tokio::test]
8780    async fn annotated_note_hard_delete_cascades_annotate_edge() {
8781        let rt = rt();
8782        let tok = NamespaceToken::local();
8783        // note_target is the thing being annotated (a note itself).
8784        let note_target = rt
8785            .create_note(
8786                &tok,
8787                "observation",
8788                None,
8789                "target note",
8790                Some(0.5),
8791                None,
8792                vec![],
8793            )
8794            .await
8795            .unwrap();
8796        // note_source annotates note_target.
8797        let note_source = rt
8798            .create_note(
8799                &tok,
8800                "insight",
8801                None,
8802                "annotation",
8803                Some(0.5),
8804                None,
8805                vec![note_target.id],
8806            )
8807            .await
8808            .unwrap();
8809
8810        let before = rt
8811            .neighbors(
8812                &tok,
8813                note_source.id,
8814                Direction::Out,
8815                None,
8816                Some(vec![EdgeRelation::Annotates]),
8817            )
8818            .await
8819            .unwrap();
8820        assert_eq!(
8821            before.len(),
8822            1,
8823            "annotates edge must exist before note delete"
8824        );
8825
8826        // Hard delete the annotation TARGET note.
8827        let deleted = rt.delete_note(&tok, note_target.id, true).await.unwrap();
8828        assert!(deleted, "note hard delete must return true");
8829
8830        // The annotates edge targeting note_target must be gone.
8831        let after = rt
8832            .neighbors(
8833                &tok,
8834                note_source.id,
8835                Direction::Out,
8836                None,
8837                Some(vec![EdgeRelation::Annotates]),
8838            )
8839            .await
8840            .unwrap();
8841        assert!(
8842            after.is_empty(),
8843            "annotates edge must be cascaded on note-target hard delete; got {after:?}"
8844        );
8845    }
8846
8847    #[tokio::test]
8848    async fn annotated_edge_delete_cascades_annotate_edge() {
8849        let rt = rt();
8850        let tok = NamespaceToken::local();
8851        let a = rt
8852            .create_entity(&tok, "concept", None, "A", None, None, vec![])
8853            .await
8854            .unwrap();
8855        let b = rt
8856            .create_entity(&tok, "concept", None, "B", None, None, vec![])
8857            .await
8858            .unwrap();
8859        // Create an edge to annotate.
8860        let base_edge = rt
8861            .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
8862            .await
8863            .unwrap();
8864        let base_edge_uuid: Uuid = base_edge.id.into();
8865
8866        // Create a note that annotates the edge.
8867        let note = rt
8868            .create_note(
8869                &tok,
8870                "observation",
8871                None,
8872                "note about edge",
8873                Some(0.5),
8874                None,
8875                vec![base_edge_uuid],
8876            )
8877            .await
8878            .unwrap();
8879
8880        let before = rt
8881            .neighbors(
8882                &tok,
8883                note.id,
8884                Direction::Out,
8885                None,
8886                Some(vec![EdgeRelation::Annotates]),
8887            )
8888            .await
8889            .unwrap();
8890        assert_eq!(
8891            before.len(),
8892            1,
8893            "annotates edge must exist before base edge delete"
8894        );
8895
8896        // Delete the base edge.
8897        let deleted = rt.delete_edge(&tok, base_edge_uuid, true).await.unwrap();
8898        assert!(deleted, "edge delete must return true");
8899
8900        // The annotates edge targeting base_edge must be gone.
8901        let after = rt
8902            .neighbors(
8903                &tok,
8904                note.id,
8905                Direction::Out,
8906                None,
8907                Some(vec![EdgeRelation::Annotates]),
8908            )
8909            .await
8910            .unwrap();
8911        assert!(
8912            after.is_empty(),
8913            "annotates edge must be cascaded on base edge delete; got {after:?}"
8914        );
8915    }
8916
8917    #[tokio::test]
8918    async fn mixed_multi_annotates_partial_target_hard_delete_leaves_remaining_edges() {
8919        let rt = rt();
8920        let tok = NamespaceToken::local();
8921        let t1 = rt
8922            .create_entity(&tok, "concept", None, "T1", None, None, vec![])
8923            .await
8924            .unwrap();
8925        let t2 = rt
8926            .create_entity(&tok, "concept", None, "T2", None, None, vec![])
8927            .await
8928            .unwrap();
8929
8930        // Note annotates both t1 and t2.
8931        let note = rt
8932            .create_note(
8933                &tok,
8934                "observation",
8935                None,
8936                "multi-target note",
8937                Some(0.5),
8938                None,
8939                vec![t1.id, t2.id],
8940            )
8941            .await
8942            .unwrap();
8943
8944        let before = rt
8945            .neighbors(
8946                &tok,
8947                note.id,
8948                Direction::Out,
8949                None,
8950                Some(vec![EdgeRelation::Annotates]),
8951            )
8952            .await
8953            .unwrap();
8954        assert_eq!(
8955            before.len(),
8956            2,
8957            "must have 2 annotates edges before any delete"
8958        );
8959
8960        // Hard delete only t1.
8961        rt.delete_entity(&tok, t1.id, true).await.unwrap();
8962
8963        // Edge to t1 must be gone, edge to t2 must remain.
8964        let after = rt
8965            .neighbors(
8966                &tok,
8967                note.id,
8968                Direction::Out,
8969                None,
8970                Some(vec![EdgeRelation::Annotates]),
8971            )
8972            .await
8973            .unwrap();
8974        assert_eq!(
8975            after.len(),
8976            1,
8977            "only the edge to t1 must be cascaded; t2 edge must remain"
8978        );
8979        assert_eq!(
8980            after[0].node_id, t2.id,
8981            "remaining annotates edge must point to t2"
8982        );
8983    }
8984
8985    #[tokio::test]
8986    async fn annotated_note_soft_delete_preserves_annotate_edge() {
8987        let rt = rt();
8988        let tok = NamespaceToken::local();
8989        let note_target = rt
8990            .create_note(&tok, "observation", None, "target", Some(0.5), None, vec![])
8991            .await
8992            .unwrap();
8993        let note_source = rt
8994            .create_note(
8995                &tok,
8996                "insight",
8997                None,
8998                "annotation",
8999                Some(0.5),
9000                None,
9001                vec![note_target.id],
9002            )
9003            .await
9004            .unwrap();
9005
9006        let before = rt
9007            .neighbors(
9008                &tok,
9009                note_source.id,
9010                Direction::Out,
9011                None,
9012                Some(vec![EdgeRelation::Annotates]),
9013            )
9014            .await
9015            .unwrap();
9016        assert_eq!(before.len(), 1);
9017        let edge_id = before[0].edge_id;
9018
9019        // Soft delete must NOT cascade edges (data-vs-view principle).
9020        let deleted = rt.delete_note(&tok, note_target.id, false).await.unwrap();
9021        assert!(deleted, "soft delete must return true");
9022
9023        // The edge itself must survive the soft delete — checked at the
9024        // storage/edge layer directly (`get_edge`), not through `neighbors()`.
9025        // `neighbors()` is a VIEW query and correctly screens
9026        // out soft-deleted note targets — so it no longer surfaces this edge
9027        // once note_target is soft-deleted, even though the edge row itself
9028        // is untouched (data-vs-view principle: the edge is data, what
9029        // `neighbors()` shows is a view decision).
9030        let edge_after = rt.get_edge(&tok, edge_id).await.unwrap();
9031        assert!(
9032            edge_after.is_some(),
9033            "soft delete must NOT cascade edges; get_edge returned None"
9034        );
9035
9036        let after = rt
9037            .neighbors(
9038                &tok,
9039                note_source.id,
9040                Direction::Out,
9041                None,
9042                Some(vec![EdgeRelation::Annotates]),
9043            )
9044            .await
9045            .unwrap();
9046        assert_eq!(
9047            after.len(),
9048            0,
9049            "#748: neighbors() must screen out the soft-deleted note target; got {after:?}"
9050        );
9051    }
9052
9053    // ---- delete_edge public-API safety ----
9054
9055    // Passing an entity/note UUID to `delete_edge` must return Ok(false) with no
9056    // side effects — it must NOT delete inbound annotates edges targeting that record.
9057    // Without the get_edge guard, the old code would cascade inbound edges before
9058    // returning false.
9059    #[tokio::test]
9060    async fn delete_edge_non_edge_uuid_has_no_side_effects() {
9061        let rt = rt();
9062        let tok = NamespaceToken::local();
9063
9064        // Create an entity that has an inbound annotates edge.
9065        let entity = rt
9066            .create_entity(&tok, "concept", None, "Target", None, None, vec![])
9067            .await
9068            .unwrap();
9069        let note = rt
9070            .create_note(
9071                &tok,
9072                "observation",
9073                None,
9074                "annotates the entity",
9075                Some(0.5),
9076                None,
9077                vec![entity.id],
9078            )
9079            .await
9080            .unwrap();
9081
9082        // Confirm the annotates edge exists.
9083        let before = rt
9084            .neighbors(
9085                &tok,
9086                note.id,
9087                Direction::Out,
9088                None,
9089                Some(vec![EdgeRelation::Annotates]),
9090            )
9091            .await
9092            .unwrap();
9093        assert_eq!(before.len(), 1, "annotates edge must exist before test");
9094        let annotates_edge_id: Uuid = before[0].edge_id;
9095
9096        // Call delete_edge with the entity UUID (NOT an edge UUID).
9097        let result = rt.delete_edge(&tok, entity.id, true).await;
9098        assert!(
9099            result.is_ok(),
9100            "delete_edge must not error on a non-edge UUID"
9101        );
9102        assert!(
9103            !result.unwrap(),
9104            "delete_edge must return false for a non-edge UUID"
9105        );
9106
9107        // The inbound annotates edge to the entity must still exist — no side effects.
9108        let after = rt
9109            .neighbors(
9110                &tok,
9111                note.id,
9112                Direction::Out,
9113                None,
9114                Some(vec![EdgeRelation::Annotates]),
9115            )
9116            .await
9117            .unwrap();
9118        assert_eq!(
9119            after.len(),
9120            1,
9121            "delete_edge with a non-edge UUID must not touch inbound annotates edges"
9122        );
9123        assert_eq!(
9124            after[0].edge_id, annotates_edge_id,
9125            "the original annotates edge must be unchanged"
9126        );
9127    }
9128
9129    // ---- create_note compensation branch ----
9130
9131    // This test injects a deterministic failure on the second `link` call inside
9132    // `create_note_inner` (the one that would create the second annotates edge).
9133    // It verifies that the compensation branch is wired — i.e. this test would
9134    // fail if the `Err(e)` rollback arm at operations.rs were deleted.
9135    //
9136    // Injection mechanism: LINK_FAIL_AFTER thread-local (ops.rs, cfg(test) only).
9137    // Setting it to 2 forces the 2nd link call to return an error.  The counter is
9138    // reset to 0 once triggered, so no other test is affected.
9139    #[tokio::test]
9140    async fn create_note_multi_annotates_second_link_failure_rolls_back_partial_write() {
9141        let rt = rt();
9142        let tok = NamespaceToken::local();
9143        let t1 = rt
9144            .create_entity(&tok, "concept", None, "T1", None, None, vec![])
9145            .await
9146            .unwrap();
9147        let t2 = rt
9148            .create_entity(&tok, "concept", None, "T2", None, None, vec![])
9149            .await
9150            .unwrap();
9151
9152        // Arm the injection: fail on the 2nd link (link_idx+1 == 2).
9153        LINK_FAIL_AFTER.with(|cell| cell.set(2));
9154
9155        let result = rt
9156            .create_note(
9157                &tok,
9158                "observation",
9159                None,
9160                "rollback target",
9161                Some(0.5),
9162                None,
9163                vec![t1.id, t2.id],
9164            )
9165            .await;
9166
9167        // The call must fail with the injected error.
9168        assert!(
9169            result.is_err(),
9170            "create_note must propagate the injected link failure"
9171        );
9172        let err_msg = result.unwrap_err().to_string();
9173        assert!(
9174            err_msg.contains("injected link failure"),
9175            "error must carry injection message; got: {err_msg}"
9176        );
9177
9178        // Compensation must have removed the note row.
9179        let notes = rt.list_notes(&tok, None, 1000, 0).await.unwrap();
9180        assert!(
9181            notes.is_empty(),
9182            "compensation must remove the note row; got {notes:?}"
9183        );
9184
9185        // FTS must have no hit for the content.
9186        let hits = rt
9187            .search_notes(&tok, "rollback target", None, 10, None, false, &[], None)
9188            .await
9189            .unwrap();
9190        assert!(
9191            hits.is_empty(),
9192            "compensation must clean FTS index; got {hits:?}"
9193        );
9194
9195        // No partial annotates edges must remain (first edge must have been deleted).
9196        let edges_from_t1 = rt
9197            .neighbors(
9198                &tok,
9199                t1.id,
9200                Direction::In,
9201                None,
9202                Some(vec![EdgeRelation::Annotates]),
9203            )
9204            .await
9205            .unwrap();
9206        let edges_from_t2 = rt
9207            .neighbors(
9208                &tok,
9209                t2.id,
9210                Direction::In,
9211                None,
9212                Some(vec![EdgeRelation::Annotates]),
9213            )
9214            .await
9215            .unwrap();
9216        assert!(
9217            edges_from_t1.is_empty(),
9218            "compensation must delete the first annotates edge; got {edges_from_t1:?}"
9219        );
9220        assert!(
9221            edges_from_t2.is_empty(),
9222            "no second annotates edge must exist; got {edges_from_t2:?}"
9223        );
9224    }
9225
9226    // Inject an FTS failure after the note row is committed and assert the note
9227    // row is removed (no stranded row).  arm_fts_fail() arms the flag before
9228    // the call and it resets automatically after one trigger.
9229    #[tokio::test]
9230    async fn create_note_fts_failure_rolls_back_note_row() {
9231        let rt = rt();
9232        // Unique namespace: the process-global FTS_FAIL_NS one-shot flag armed
9233        // below must be consumable only by THIS test's create_note. Sharing the
9234        // "local" namespace let a concurrent "local" create_note consume the
9235        // armed flag, flaking this test and its victim under parallel
9236        // `cargo test`.
9237        let ns = Namespace::parse("fault-fts-rollback").unwrap();
9238        let tok = NamespaceToken::for_namespace(ns.clone());
9239
9240        arm_fts_fail(ns.as_str());
9241
9242        let result = rt
9243            .create_note(
9244                &tok,
9245                "observation",
9246                None,
9247                "fts-fail rollback target",
9248                None,
9249                None,
9250                vec![],
9251            )
9252            .await;
9253
9254        assert!(
9255            result.is_err(),
9256            "create_note must propagate the injected FTS failure"
9257        );
9258        let err_msg = result.unwrap_err().to_string();
9259        assert!(
9260            err_msg.contains("injected FTS failure"),
9261            "error must carry injection message; got: {err_msg}"
9262        );
9263
9264        // Compensation must have removed the note row.
9265        let notes = rt.list_notes(&tok, None, 1000, 0).await.unwrap();
9266        assert!(
9267            notes.is_empty(),
9268            "compensation must remove the note row after FTS failure; got {notes:?}"
9269        );
9270    }
9271
9272    // Inject a vector insertion failure after note row + FTS commit and assert
9273    // both the note row and the FTS document are removed (no stranded rows).
9274    // Uses a unique namespace (see create_note_fts_failure_rolls_back_note_row)
9275    // so the process-global VECTOR_FAIL_NS flag is consumed only by this test.
9276    // Since the single registered provider fires embed_document before the
9277    // injection check, the injection converts the successful embedding into an
9278    // error just before the VectorStore insert, then disarms.
9279    #[tokio::test]
9280    async fn create_note_vector_failure_rolls_back_note_row_and_fts() {
9281        const MODEL: &str = "test-vec-inject";
9282        const DIMS: usize = 4;
9283
9284        let rt = KhiveRuntime::memory().unwrap();
9285        let (provider, _counter) = ConstVecProvider::new(MODEL, DIMS);
9286        rt.register_embedder(provider);
9287
9288        let ns = Namespace::parse("fault-vec-rollback").unwrap();
9289        let tok = NamespaceToken::for_namespace(ns.clone());
9290
9291        arm_vector_fail(ns.as_str());
9292
9293        let result = rt
9294            .create_note(
9295                &tok,
9296                "observation",
9297                None,
9298                "vec-fail rollback target",
9299                None,
9300                None,
9301                vec![],
9302            )
9303            .await;
9304
9305        assert!(
9306            result.is_err(),
9307            "create_note must propagate the injected vector failure"
9308        );
9309        let err_msg = result.unwrap_err().to_string();
9310        assert!(
9311            err_msg.contains("injected vector failure"),
9312            "error must carry injection message; got: {err_msg}"
9313        );
9314
9315        // Compensation must have removed the note row.
9316        let notes = rt.list_notes(&tok, None, 1000, 0).await.unwrap();
9317        assert!(
9318            notes.is_empty(),
9319            "compensation must remove note row after vector failure; got {notes:?}"
9320        );
9321    }
9322
9323    // The `embedding_content` override must not bypass the same
9324    // FTS/vector compensation the plain `create_note` path already has —
9325    // both use `create_note_inner` underneath, but these tests exercise it
9326    // through `create_note_with_embedding_content` with a real Some(head)
9327    // override to prove the override path shares the identical rollback.
9328    #[tokio::test]
9329    async fn create_note_with_embedding_content_fts_failure_rolls_back_note_row() {
9330        let rt = rt();
9331        let ns = Namespace::parse("fault-fts-rollback-embedding-content").unwrap();
9332        let tok = NamespaceToken::for_namespace(ns.clone());
9333
9334        arm_fts_fail(ns.as_str());
9335
9336        let full = "fts-fail rollback target with an embedding-content override";
9337        let head = &full[.."fts-fail rollback target".len()];
9338        let result = rt
9339            .create_note_with_embedding_content(
9340                &tok,
9341                "observation",
9342                None,
9343                full,
9344                Some(head),
9345                None,
9346                None,
9347                vec![],
9348            )
9349            .await;
9350
9351        assert!(
9352            result.is_err(),
9353            "create_note_with_embedding_content must propagate the injected FTS failure"
9354        );
9355        let err_msg = result.unwrap_err().to_string();
9356        assert!(
9357            err_msg.contains("injected FTS failure"),
9358            "error must carry injection message; got: {err_msg}"
9359        );
9360
9361        // Compensation must have removed the note row; a failed create must
9362        // never leave a stranded row behind just because it carried an
9363        // embedding_content override.
9364        let notes = rt.list_notes(&tok, None, 1000, 0).await.unwrap();
9365        assert!(
9366            notes.is_empty(),
9367            "compensation must remove the note row after FTS failure; got {notes:?}"
9368        );
9369    }
9370
9371    #[tokio::test]
9372    async fn create_note_with_embedding_content_vector_failure_rolls_back_note_row_and_fts() {
9373        const MODEL: &str = "test-vec-inject-embedding-content";
9374        const DIMS: usize = 4;
9375
9376        let rt = KhiveRuntime::memory().unwrap();
9377        let (provider, _counter) = ConstVecProvider::new(MODEL, DIMS);
9378        rt.register_embedder(provider);
9379
9380        let ns = Namespace::parse("fault-vec-rollback-embedding-content").unwrap();
9381        let tok = NamespaceToken::for_namespace(ns.clone());
9382
9383        arm_vector_fail(ns.as_str());
9384
9385        let full = "vec-fail rollback target with an embedding-content override";
9386        let head = &full[.."vec-fail rollback target".len()];
9387        let result = rt
9388            .create_note_with_embedding_content(
9389                &tok,
9390                "observation",
9391                None,
9392                full,
9393                Some(head),
9394                None,
9395                None,
9396                vec![],
9397            )
9398            .await;
9399
9400        assert!(
9401            result.is_err(),
9402            "create_note_with_embedding_content must propagate the injected vector failure"
9403        );
9404        let err_msg = result.unwrap_err().to_string();
9405        assert!(
9406            err_msg.contains("injected vector failure"),
9407            "error must carry injection message; got: {err_msg}"
9408        );
9409
9410        // Compensation must have removed the note row: the ingest-layer
9411        // truncation counter only increments in the successful-create arm,
9412        // so a failed create — with or without an embedding_content override
9413        // — can never cause a spurious truncation count on the caller side.
9414        let notes = rt.list_notes(&tok, None, 1000, 0).await.unwrap();
9415        assert!(
9416            notes.is_empty(),
9417            "compensation must remove note row after vector failure; got {notes:?}"
9418        );
9419    }
9420
9421    // ---- soft-delete index cleanup tests ----
9422
9423    #[tokio::test]
9424    async fn soft_delete_entity_removes_indexes() {
9425        let rt = rt();
9426        let tok = NamespaceToken::local();
9427        let entity = rt
9428            .create_entity(
9429                &tok,
9430                "concept",
9431                None,
9432                "QuantumEntanglement",
9433                Some("unique FTS term xzqjwv for soft delete test"),
9434                None,
9435                vec![],
9436            )
9437            .await
9438            .unwrap();
9439
9440        let ns = tok.namespace().as_str().to_string();
9441
9442        let before = rt
9443            .text(&tok)
9444            .unwrap()
9445            .search(TextSearchRequest {
9446                query: "xzqjwv".to_string(),
9447                mode: TextQueryMode::Plain,
9448                filter: Some(TextFilter {
9449                    namespaces: vec![ns.clone()],
9450                    ..Default::default()
9451                }),
9452                top_k: 10,
9453                snippet_chars: 100,
9454            })
9455            .await
9456            .unwrap();
9457        assert!(
9458            before.iter().any(|h| h.subject_id == entity.id),
9459            "entity must be in FTS before soft-delete"
9460        );
9461
9462        let deleted = rt.delete_entity(&tok, entity.id, false).await.unwrap();
9463        assert!(deleted, "soft delete must return true");
9464
9465        let after = rt
9466            .text(&tok)
9467            .unwrap()
9468            .search(TextSearchRequest {
9469                query: "xzqjwv".to_string(),
9470                mode: TextQueryMode::Plain,
9471                filter: Some(TextFilter {
9472                    namespaces: vec![ns],
9473                    ..Default::default()
9474                }),
9475                top_k: 10,
9476                snippet_chars: 100,
9477            })
9478            .await
9479            .unwrap();
9480        assert!(
9481            after.iter().all(|h| h.subject_id != entity.id),
9482            "soft-deleted entity must be removed from FTS index"
9483        );
9484    }
9485
9486    #[tokio::test]
9487    async fn soft_delete_note_removes_indexes() {
9488        let rt = rt();
9489        let tok = NamespaceToken::local();
9490        let note = rt
9491            .create_note(
9492                &tok,
9493                "observation",
9494                None,
9495                "SpectralDecomposition unique term yvwkqz for soft delete test",
9496                Some(0.7),
9497                None,
9498                vec![],
9499            )
9500            .await
9501            .unwrap();
9502
9503        let before = rt
9504            .search_notes(&tok, "yvwkqz", None, 10, None, false, &[], None)
9505            .await
9506            .unwrap();
9507        assert!(
9508            before.iter().any(|h| h.note_id == note.id),
9509            "note must be in FTS before soft-delete"
9510        );
9511
9512        let deleted = rt.delete_note(&tok, note.id, false).await.unwrap();
9513        assert!(deleted, "soft delete must return true");
9514
9515        let after = rt
9516            .search_notes(&tok, "yvwkqz", None, 10, None, false, &[], None)
9517            .await
9518            .unwrap();
9519        assert!(
9520            after.iter().all(|h| h.note_id != note.id),
9521            "soft-deleted note must be removed from FTS index"
9522        );
9523    }
9524
9525    // Base endpoint allowlist: unlisted triples must fail closed.
9526    // Document->Document Extends is not in the allowlist.
9527    #[tokio::test]
9528    async fn link_extends_document_to_document_returns_invalid_input() {
9529        let rt = rt();
9530        let tok = NamespaceToken::local();
9531        let d1 = rt
9532            .create_entity(&tok, "document", None, "DocA", None, None, vec![])
9533            .await
9534            .unwrap();
9535        let d2 = rt
9536            .create_entity(&tok, "document", None, "DocB", None, None, vec![])
9537            .await
9538            .unwrap();
9539        let result = rt
9540            .link(&tok, d1.id, d2.id, EdgeRelation::Extends, 1.0, None)
9541            .await;
9542        assert!(
9543            result.is_err(),
9544            "F010: document->document Extends must be rejected by the base allowlist; \
9545             current generic entity fallthrough incorrectly accepts it"
9546        );
9547    }
9548
9549    // Happy path: Concept->Concept Extends is in the base allowlist and must succeed.
9550    #[tokio::test]
9551    async fn link_extends_concept_to_concept_succeeds() {
9552        let rt = rt();
9553        let tok = NamespaceToken::local();
9554        let a = rt
9555            .create_entity(&tok, "concept", None, "CA", None, None, vec![])
9556            .await
9557            .unwrap();
9558        let b = rt
9559            .create_entity(&tok, "concept", None, "CB", None, None, vec![])
9560            .await
9561            .unwrap();
9562        let result = rt
9563            .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
9564            .await;
9565        assert!(
9566            result.is_ok(),
9567            "F010: concept->concept Extends must be allowed (base allowlist)"
9568        );
9569    }
9570
9571    // CompetesWith is symmetric; reversed pair must deduplicate to one canonical row.
9572    #[tokio::test]
9573    async fn link_symmetric_relation_canonicalizes_endpoint_order() {
9574        use khive_storage::EdgeFilter;
9575        let rt = rt();
9576        let tok = NamespaceToken::local();
9577        let a = rt
9578            .create_entity(&tok, "concept", None, "ConceptP", None, None, vec![])
9579            .await
9580            .unwrap();
9581        let b = rt
9582            .create_entity(&tok, "concept", None, "ConceptQ", None, None, vec![])
9583            .await
9584            .unwrap();
9585        // Link A->B then B->A with the same symmetric relation.
9586        rt.link(&tok, a.id, b.id, EdgeRelation::CompetesWith, 1.0, None)
9587            .await
9588            .unwrap();
9589        rt.link(&tok, b.id, a.id, EdgeRelation::CompetesWith, 1.0, None)
9590            .await
9591            .unwrap();
9592        let count = rt
9593            .graph(&tok)
9594            .unwrap()
9595            .count_edges(EdgeFilter::default())
9596            .await
9597            .unwrap();
9598        assert_eq!(
9599            count,
9600            1,
9601            "F012: CompetesWith is symmetric; A->B and B->A must deduplicate to one canonical row; \
9602             found {count} rows (canonicalization not yet implemented)"
9603        );
9604    }
9605
9606    // Supersedes: positive tests for all 5 allowed entity kinds.
9607    #[tokio::test]
9608    async fn f010_supersedes_document_to_document_allowed() {
9609        let rt = rt();
9610        let tok = NamespaceToken::local();
9611        let a = rt
9612            .create_entity(&tok, "document", None, "DocA", None, None, vec![])
9613            .await
9614            .unwrap();
9615        let b = rt
9616            .create_entity(&tok, "document", None, "DocB", None, None, vec![])
9617            .await
9618            .unwrap();
9619        let result = rt
9620            .link(&tok, b.id, a.id, EdgeRelation::Supersedes, 1.0, None)
9621            .await;
9622        assert!(
9623            result.is_ok(),
9624            "document->document Supersedes must be allowed (allowlist), got {result:?}"
9625        );
9626    }
9627
9628    #[tokio::test]
9629    async fn f010_supersedes_artifact_to_artifact_allowed() {
9630        let rt = rt();
9631        let tok = NamespaceToken::local();
9632        let a = rt
9633            .create_entity(&tok, "artifact", None, "ArtA", None, None, vec![])
9634            .await
9635            .unwrap();
9636        let b = rt
9637            .create_entity(&tok, "artifact", None, "ArtB", None, None, vec![])
9638            .await
9639            .unwrap();
9640        let result = rt
9641            .link(&tok, b.id, a.id, EdgeRelation::Supersedes, 1.0, None)
9642            .await;
9643        assert!(
9644            result.is_ok(),
9645            "artifact->artifact Supersedes must be allowed (allowlist), got {result:?}"
9646        );
9647    }
9648
9649    #[tokio::test]
9650    async fn f010_supersedes_service_to_service_allowed() {
9651        let rt = rt();
9652        let tok = NamespaceToken::local();
9653        let a = rt
9654            .create_entity(&tok, "service", None, "SvcA", None, None, vec![])
9655            .await
9656            .unwrap();
9657        let b = rt
9658            .create_entity(&tok, "service", None, "SvcB", None, None, vec![])
9659            .await
9660            .unwrap();
9661        let result = rt
9662            .link(&tok, b.id, a.id, EdgeRelation::Supersedes, 1.0, None)
9663            .await;
9664        assert!(
9665            result.is_ok(),
9666            "service->service Supersedes must be allowed (allowlist), got {result:?}"
9667        );
9668    }
9669
9670    #[tokio::test]
9671    async fn f010_supersedes_dataset_to_dataset_allowed() {
9672        let rt = rt();
9673        let tok = NamespaceToken::local();
9674        let a = rt
9675            .create_entity(&tok, "dataset", None, "DataA", None, None, vec![])
9676            .await
9677            .unwrap();
9678        let b = rt
9679            .create_entity(&tok, "dataset", None, "DataB", None, None, vec![])
9680            .await
9681            .unwrap();
9682        let result = rt
9683            .link(&tok, b.id, a.id, EdgeRelation::Supersedes, 1.0, None)
9684            .await;
9685        assert!(
9686            result.is_ok(),
9687            "dataset->dataset Supersedes must be allowed (allowlist), got {result:?}"
9688        );
9689    }
9690
9691    // Supersedes: negative tests for rejected entity kinds.
9692    #[tokio::test]
9693    async fn f010_supersedes_project_to_project_rejected() {
9694        let rt = rt();
9695        let tok = NamespaceToken::local();
9696        let a = rt
9697            .create_entity(&tok, "project", None, "ProjA", None, None, vec![])
9698            .await
9699            .unwrap();
9700        let b = rt
9701            .create_entity(&tok, "project", None, "ProjB", None, None, vec![])
9702            .await
9703            .unwrap();
9704        let result = rt
9705            .link(&tok, b.id, a.id, EdgeRelation::Supersedes, 1.0, None)
9706            .await;
9707        assert!(
9708            matches!(result, Err(RuntimeError::InvalidInput(_))),
9709            "project->project Supersedes must be rejected (not in allowlist), got {result:?}"
9710        );
9711    }
9712
9713    #[tokio::test]
9714    async fn f010_supersedes_person_to_person_rejected() {
9715        let rt = rt();
9716        let tok = NamespaceToken::local();
9717        let a = rt
9718            .create_entity(&tok, "person", None, "Alice", None, None, vec![])
9719            .await
9720            .unwrap();
9721        let b = rt
9722            .create_entity(&tok, "person", None, "Bob", None, None, vec![])
9723            .await
9724            .unwrap();
9725        let result = rt
9726            .link(&tok, b.id, a.id, EdgeRelation::Supersedes, 1.0, None)
9727            .await;
9728        assert!(
9729            matches!(result, Err(RuntimeError::InvalidInput(_))),
9730            "person->person Supersedes must be rejected (not in allowlist), got {result:?}"
9731        );
9732    }
9733
9734    #[tokio::test]
9735    async fn f010_supersedes_org_to_org_rejected() {
9736        let rt = rt();
9737        let tok = NamespaceToken::local();
9738        let a = rt
9739            .create_entity(&tok, "org", None, "OrgA", None, None, vec![])
9740            .await
9741            .unwrap();
9742        let b = rt
9743            .create_entity(&tok, "org", None, "OrgB", None, None, vec![])
9744            .await
9745            .unwrap();
9746        let result = rt
9747            .link(&tok, b.id, a.id, EdgeRelation::Supersedes, 1.0, None)
9748            .await;
9749        assert!(
9750            matches!(result, Err(RuntimeError::InvalidInput(_))),
9751            "org->org Supersedes must be rejected (not in allowlist), got {result:?}"
9752        );
9753    }
9754
9755    // Supersedes entity→entity: same kind (concept→concept) must be allowed.
9756    #[tokio::test]
9757    async fn f010_supersedes_same_kind_entity_allowed() {
9758        let rt = rt();
9759        let tok = NamespaceToken::local();
9760        let a = rt
9761            .create_entity(&tok, "concept", None, "OldV", None, None, vec![])
9762            .await
9763            .unwrap();
9764        let b = rt
9765            .create_entity(&tok, "concept", None, "NewV", None, None, vec![])
9766            .await
9767            .unwrap();
9768        let result = rt
9769            .link(&tok, b.id, a.id, EdgeRelation::Supersedes, 1.0, None)
9770            .await;
9771        assert!(
9772            result.is_ok(),
9773            "concept->concept Supersedes must be allowed by the base allowlist, got {result:?}"
9774        );
9775    }
9776
9777    // target_backend invariant: all edges written through link() must have
9778    // target_backend = None because validate_edge_relation_endpoints already ensured the
9779    // target exists locally.
9780    #[tokio::test]
9781    async fn f161_link_always_writes_null_target_backend() {
9782        let rt = rt();
9783        let tok = NamespaceToken::local();
9784        let a = rt
9785            .create_entity(&tok, "concept", None, "A", None, None, vec![])
9786            .await
9787            .unwrap();
9788        let b = rt
9789            .create_entity(&tok, "concept", None, "B", None, None, vec![])
9790            .await
9791            .unwrap();
9792        let edge = rt
9793            .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
9794            .await
9795            .unwrap();
9796        assert!(
9797            edge.target_backend.is_none(),
9798            "F161: target_backend must be None for locally-routed edges; got {:?}",
9799            edge.target_backend
9800        );
9801    }
9802
9803    // link_many must also write null target_backend for all local edges.
9804    #[tokio::test]
9805    async fn f161_link_many_always_writes_null_target_backend() {
9806        let rt = rt();
9807        let tok = NamespaceToken::local();
9808        let a = rt
9809            .create_entity(&tok, "concept", None, "A", None, None, vec![])
9810            .await
9811            .unwrap();
9812        let b = rt
9813            .create_entity(&tok, "concept", None, "B", None, None, vec![])
9814            .await
9815            .unwrap();
9816        let c = rt
9817            .create_entity(&tok, "concept", None, "C", None, None, vec![])
9818            .await
9819            .unwrap();
9820        let specs = vec![
9821            LinkSpec {
9822                namespace: None,
9823                source_id: a.id,
9824                target_id: b.id,
9825                relation: EdgeRelation::Extends,
9826                weight: 1.0,
9827                metadata: None,
9828            },
9829            LinkSpec {
9830                namespace: None,
9831                source_id: a.id,
9832                target_id: c.id,
9833                relation: EdgeRelation::Enables,
9834                weight: 1.0,
9835                metadata: None,
9836            },
9837        ];
9838        let edges = rt.link_many(&tok, specs).await.unwrap();
9839        for edge in &edges {
9840            assert!(
9841                edge.target_backend.is_none(),
9842                "F161: target_backend must be None for locally-routed edges in link_many; got {:?}",
9843                edge.target_backend
9844            );
9845        }
9846    }
9847
9848    // Symmetric relation neighbors: competes_with queried from the non-canonical
9849    // endpoint must still return results when direction=Out is requested.
9850    #[tokio::test]
9851    async fn f012_symmetric_neighbors_visible_from_both_endpoints() {
9852        let rt = rt();
9853        let tok = NamespaceToken::local();
9854        let a = rt
9855            .create_entity(&tok, "concept", None, "A", None, None, vec![])
9856            .await
9857            .unwrap();
9858        let b = rt
9859            .create_entity(&tok, "concept", None, "B", None, None, vec![])
9860            .await
9861            .unwrap();
9862        // Link A→B competes_with; if A.id > B.id the edge is stored as B→A (canonical).
9863        rt.link(&tok, a.id, b.id, EdgeRelation::CompetesWith, 1.0, None)
9864            .await
9865            .unwrap();
9866        // Both endpoints should see the edge regardless of direction=Out.
9867        let from_a = rt
9868            .neighbors(
9869                &tok,
9870                a.id,
9871                Direction::Out,
9872                None,
9873                Some(vec![EdgeRelation::CompetesWith]),
9874            )
9875            .await
9876            .unwrap();
9877        let from_b = rt
9878            .neighbors(
9879                &tok,
9880                b.id,
9881                Direction::Out,
9882                None,
9883                Some(vec![EdgeRelation::CompetesWith]),
9884            )
9885            .await
9886            .unwrap();
9887        assert_eq!(
9888            from_a.len(),
9889            1,
9890            "node A must see competes_with neighbor from Direction::Out (F012); got {from_a:?}"
9891        );
9892        assert_eq!(
9893            from_b.len(),
9894            1,
9895            "node B must see competes_with neighbor from Direction::Out (F012); got {from_b:?}"
9896        );
9897    }
9898
9899    // Fix 1: Supersedes entity→entity — cross-kind (concept→document) must be rejected.
9900    #[tokio::test]
9901    async fn f010_supersedes_cross_kind_entity_rejected() {
9902        let rt = rt();
9903        let tok = NamespaceToken::local();
9904        let concept = rt
9905            .create_entity(&tok, "concept", None, "MyConcept", None, None, vec![])
9906            .await
9907            .unwrap();
9908        let doc = rt
9909            .create_entity(&tok, "document", None, "MyDoc", None, None, vec![])
9910            .await
9911            .unwrap();
9912        let result = rt
9913            .link(
9914                &tok,
9915                concept.id,
9916                doc.id,
9917                EdgeRelation::Supersedes,
9918                1.0,
9919                None,
9920            )
9921            .await;
9922        assert!(
9923            matches!(result, Err(RuntimeError::InvalidInput(_))),
9924            "concept->document Supersedes must be rejected by the base allowlist, got {result:?}"
9925        );
9926    }
9927
9928    // Cross-namespace delete_note now succeeds (UUID v4 is globally unique,
9929    // no namespace isolation on by-ID ops).
9930    #[tokio::test]
9931    async fn delete_note_cross_namespace_succeeds() {
9932        let rt = rt();
9933        let ns_a = NamespaceToken::for_namespace(Namespace::parse("ns-a").unwrap());
9934        let ns_b = NamespaceToken::for_namespace(Namespace::parse("ns-b").unwrap());
9935        let note = rt
9936            .create_note(
9937                &ns_a,
9938                "observation",
9939                None,
9940                "note in ns-a",
9941                Some(0.8),
9942                None,
9943                vec![],
9944            )
9945            .await
9946            .unwrap();
9947
9948        // Delete from a different namespace must now SUCCEED.
9949        let result = rt.delete_note(&ns_b, note.id, false).await;
9950        assert!(
9951            result.unwrap(),
9952            "cross-namespace delete_note (soft) must return Ok(true)"
9953        );
9954
9955        // Note must be gone from ns-a storage after the cross-ns soft delete.
9956        let note_store = rt.notes(&ns_a).unwrap();
9957        let gone = note_store.get_note(note.id).await.unwrap();
9958        assert!(
9959            gone.is_none(),
9960            "note must be soft-deleted in its home namespace after cross-ns delete"
9961        );
9962
9963        // Hard-delete path: create a fresh note and hard-delete from foreign token.
9964        let note2 = rt
9965            .create_note(
9966                &ns_a,
9967                "observation",
9968                None,
9969                "note2 in ns-a",
9970                Some(0.5),
9971                None,
9972                vec![],
9973            )
9974            .await
9975            .unwrap();
9976        let hard_result = rt.delete_note(&ns_b, note2.id, true).await;
9977        assert!(
9978            hard_result.unwrap(),
9979            "cross-namespace hard delete_note must return Ok(true)"
9980        );
9981        let gone2 = rt
9982            .get_note_including_deleted(&ns_a, note2.id)
9983            .await
9984            .unwrap();
9985        assert!(
9986            gone2.is_none(),
9987            "hard-deleted note must not appear even in including_deleted query"
9988        );
9989    }
9990
9991    // Regression: parallel link_many calls with overlapping triples must
9992    // return the identical persisted edge ID, not locally-generated phantom IDs.
9993    //
9994    // Sequence:
9995    //   1. First link_many creates the A→B Extends edge (persisted with ID₁).
9996    //   2. Second link_many upserts the same triple (ON CONFLICT DO UPDATE keeps ID₁).
9997    //   3. Both callers must see ID₁ in their returned Vec<Edge>.
9998    #[tokio::test]
9999    async fn link_many_overlapping_triple_returns_persisted_ids() {
10000        let rt = rt();
10001        let tok = NamespaceToken::local();
10002        let a = rt
10003            .create_entity(&tok, "concept", None, "A", None, None, vec![])
10004            .await
10005            .unwrap();
10006        let b = rt
10007            .create_entity(&tok, "concept", None, "B", None, None, vec![])
10008            .await
10009            .unwrap();
10010
10011        let spec = || LinkSpec {
10012            namespace: None,
10013            source_id: a.id,
10014            target_id: b.id,
10015            relation: EdgeRelation::Extends,
10016            weight: 1.0,
10017            metadata: None,
10018        };
10019
10020        // First call — creates the edge.
10021        let first = rt.link_many(&tok, vec![spec()]).await.unwrap();
10022        assert_eq!(first.len(), 1);
10023        let persisted_id: Uuid = first[0].id.into();
10024
10025        // Second call — same natural-key triple; ON CONFLICT updates, preserving the
10026        // existing row ID. link_many must read back the row and return that same ID.
10027        let second = rt.link_many(&tok, vec![spec()]).await.unwrap();
10028        assert_eq!(second.len(), 1);
10029        let second_id: Uuid = second[0].id.into();
10030
10031        assert_eq!(
10032            persisted_id, second_id,
10033            "link_many with an existing triple must return the persisted row ID ({persisted_id}), \
10034             not a new phantom ID ({second_id})"
10035        );
10036
10037        // Confirm only one edge row exists in the graph store.
10038        let count = rt
10039            .count_edges(&tok, crate::curation::EdgeListFilter::default())
10040            .await
10041            .unwrap();
10042        assert_eq!(count, 1, "upsert must not duplicate the edge row");
10043    }
10044
10045    // ── create_many: batch entity creation ───────────────────────────────────
10046
10047    #[tokio::test]
10048    async fn create_many_persists_all_entities() {
10049        let rt = rt();
10050        let tok = NamespaceToken::local();
10051
10052        let specs: Vec<EntityCreateSpec> = (0..5)
10053            .map(|i| EntityCreateSpec {
10054                kind: "concept".into(),
10055                entity_type: None,
10056                name: format!("BulkConcept-{i}"),
10057                description: Some(format!("desc {i}")),
10058                properties: None,
10059                tags: vec!["bulk-test".into()],
10060            })
10061            .collect();
10062
10063        let entities = rt.create_many(&tok, specs).await.unwrap();
10064        assert_eq!(entities.len(), 5, "all 5 entities must be returned");
10065
10066        // Verify each one is retrievable from storage.
10067        for entity in &entities {
10068            let fetched = rt.get_entity(&tok, entity.id).await.unwrap();
10069            assert_eq!(fetched.id, entity.id);
10070        }
10071    }
10072
10073    #[tokio::test]
10074    async fn create_many_empty_name_rejects_atomically() {
10075        let rt = rt();
10076        let tok = NamespaceToken::local();
10077
10078        let specs = vec![
10079            EntityCreateSpec {
10080                kind: "concept".into(),
10081                entity_type: None,
10082                name: "ValidEntity".into(),
10083                description: None,
10084                properties: None,
10085                tags: vec![],
10086            },
10087            EntityCreateSpec {
10088                kind: "concept".into(),
10089                entity_type: None,
10090                name: "".into(), // invalid — triggers atomic rejection
10091                description: None,
10092                properties: None,
10093                tags: vec![],
10094            },
10095        ];
10096
10097        let result = rt.create_many(&tok, specs).await;
10098        assert!(
10099            matches!(result, Err(RuntimeError::InvalidInput(_))),
10100            "empty name must produce InvalidInput error"
10101        );
10102
10103        // Nothing must have been written — list_entities returns 0 items.
10104        let rows = rt.list_entities(&tok, None, None, 100, 0).await.unwrap();
10105        assert_eq!(
10106            rows.len(),
10107            0,
10108            "atomic rejection must leave storage unchanged"
10109        );
10110    }
10111
10112    // entity_type validated at runtime layer when validator is installed.
10113    #[tokio::test]
10114    async fn create_many_rejects_unknown_entity_type_when_validator_installed() {
10115        let rt = rt();
10116        let tok = NamespaceToken::local();
10117
10118        // Install a mock validator that only accepts "algorithm" for "concept".
10119        rt.install_entity_type_validator(Arc::new(|kind, entity_type| {
10120            let Some(raw) = entity_type else {
10121                return Ok(None);
10122            };
10123            if kind == "concept" && raw == "algorithm" {
10124                return Ok(Some("algorithm".to_string()));
10125            }
10126            Err(RuntimeError::InvalidInput(format!(
10127                "unknown entity_type {raw:?} for {kind:?}; valid: algorithm"
10128            )))
10129        }));
10130
10131        let bad_spec = vec![EntityCreateSpec {
10132            kind: "concept".into(),
10133            entity_type: Some("not_a_registered_type".into()),
10134            name: "ShouldNotLand".into(),
10135            description: None,
10136            properties: None,
10137            tags: vec![],
10138        }];
10139
10140        let result = rt.create_many(&tok, bad_spec).await;
10141        assert!(
10142            matches!(result, Err(RuntimeError::InvalidInput(_))),
10143            "unknown entity_type must be rejected by the runtime-layer validator; got {result:?}"
10144        );
10145
10146        // Zero rows written — validator fires before any storage call.
10147        let rows = rt.list_entities(&tok, None, None, 100, 0).await.unwrap();
10148        assert_eq!(
10149            rows.len(),
10150            0,
10151            "validator rejection must leave storage empty"
10152        );
10153    }
10154
10155    // Valid entity_type passes through and is normalised by the validator.
10156    #[tokio::test]
10157    async fn create_many_accepts_valid_entity_type_via_validator() {
10158        let rt = rt();
10159        let tok = NamespaceToken::local();
10160
10161        rt.install_entity_type_validator(Arc::new(|kind, entity_type| {
10162            let Some(raw) = entity_type else {
10163                return Ok(None);
10164            };
10165            if kind == "concept" && raw == "algorithm" {
10166                return Ok(Some("algorithm".to_string()));
10167            }
10168            Err(RuntimeError::InvalidInput(format!(
10169                "unknown entity_type {raw:?} for {kind:?}"
10170            )))
10171        }));
10172
10173        let specs = vec![EntityCreateSpec {
10174            kind: "concept".into(),
10175            entity_type: Some("algorithm".into()),
10176            name: "BubbleSort".into(),
10177            description: None,
10178            properties: None,
10179            tags: vec![],
10180        }];
10181
10182        let entities = rt.create_many(&tok, specs).await.unwrap();
10183        assert_eq!(entities.len(), 1, "valid entity_type must be accepted");
10184        assert_eq!(
10185            entities[0].entity_type.as_deref(),
10186            Some("algorithm"),
10187            "entity_type must be stored as returned by the validator"
10188        );
10189    }
10190
10191    // FTS failure in create_many rolls back both substrates.
10192    //
10193    // Arm `arm_fts_fail_many` before the call; the FTS phase returns an injected
10194    // error; the test asserts zero rows in both `entities` and `fts_entities`.
10195    #[tokio::test]
10196    async fn create_many_fts_failure_rolls_back_both_substrates() {
10197        // Use a unique namespace so the process-global one-shot is unaffected by
10198        // other concurrent tests.
10199        let ns = format!("fts-fail-many-{}", uuid::Uuid::new_v4().as_simple());
10200        let rt = rt();
10201        let tok = NamespaceToken::for_namespace(Namespace::parse(&ns).unwrap());
10202
10203        let specs = vec![
10204            EntityCreateSpec {
10205                kind: "concept".into(),
10206                entity_type: None,
10207                name: "FtsRollbackA".into(),
10208                description: None,
10209                properties: None,
10210                tags: vec![],
10211            },
10212            EntityCreateSpec {
10213                kind: "concept".into(),
10214                entity_type: None,
10215                name: "FtsRollbackB".into(),
10216                description: None,
10217                properties: None,
10218                tags: vec![],
10219            },
10220        ];
10221
10222        arm_fts_fail_many(&ns);
10223        let result = rt.create_many(&tok, specs).await;
10224
10225        assert!(
10226            result.is_err(),
10227            "create_many must return Err when FTS write fails"
10228        );
10229
10230        // Entity substrate must be empty — entity rows must have been rolled back.
10231        let entity_rows = rt.list_entities(&tok, None, None, 100, 0).await.unwrap();
10232        assert_eq!(
10233            entity_rows.len(),
10234            0,
10235            "entity rows must be rolled back on FTS failure; found {entity_rows:?}"
10236        );
10237
10238        // FTS substrate must be empty — no stale fts_entities rows.
10239        let fts = rt.text(&tok).unwrap();
10240        let fts_count = fts
10241            .count(TextFilter {
10242                ids: vec![],
10243                kinds: vec![],
10244                namespaces: vec![ns.clone()],
10245            })
10246            .await
10247            .unwrap();
10248        assert_eq!(
10249            fts_count, 0,
10250            "fts_entities must be empty after FTS-failure rollback; found {fts_count}"
10251        );
10252    }
10253
10254    // FTS partial-failure (Ok(summary) with summary.failed > 0) rolls back
10255    // both substrates.
10256    //
10257    // The production code has a distinct arm:
10258    //   Ok(summary) if summary.failed > 0 => return Err(...)
10259    // This test exercises that arm by arming `arm_fts_fail_many_partial`, which
10260    // returns Ok(BatchWriteSummary { failed: 1, ... }) instead of a hard Err.
10261    // Both entity rows and FTS rows must be empty after rollback.
10262    #[tokio::test]
10263    async fn create_many_fts_partial_failure_rolls_back_both_substrates() {
10264        let ns = format!("fts-fail-partial-{}", uuid::Uuid::new_v4().as_simple());
10265        let rt = rt();
10266        let tok = NamespaceToken::for_namespace(Namespace::parse(&ns).unwrap());
10267
10268        let specs = vec![
10269            EntityCreateSpec {
10270                kind: "concept".into(),
10271                entity_type: None,
10272                name: "PartialRollbackA".into(),
10273                description: None,
10274                properties: None,
10275                tags: vec![],
10276            },
10277            EntityCreateSpec {
10278                kind: "concept".into(),
10279                entity_type: None,
10280                name: "PartialRollbackB".into(),
10281                description: None,
10282                properties: None,
10283                tags: vec![],
10284            },
10285        ];
10286
10287        arm_fts_fail_many_partial(&ns);
10288        let result = rt.create_many(&tok, specs).await;
10289
10290        assert!(
10291            result.is_err(),
10292            "create_many must return Err when FTS summary.failed > 0"
10293        );
10294
10295        // Entity substrate must be empty — entity rows must have been rolled back.
10296        let entity_rows = rt.list_entities(&tok, None, None, 100, 0).await.unwrap();
10297        assert_eq!(
10298            entity_rows.len(),
10299            0,
10300            "entity rows must be rolled back when FTS summary.failed > 0; found {entity_rows:?}"
10301        );
10302
10303        // FTS substrate must be empty — no stale fts_entities rows.
10304        let fts = rt.text(&tok).unwrap();
10305        let fts_count = fts
10306            .count(TextFilter {
10307                ids: vec![],
10308                kinds: vec![],
10309                namespaces: vec![ns.clone()],
10310            })
10311            .await
10312            .unwrap();
10313        assert_eq!(
10314            fts_count, 0,
10315            "fts_entities must be empty after partial-FTS-failure rollback; found {fts_count}"
10316        );
10317    }
10318
10319    // ── Cross-namespace get_edge now succeeds (UUID v4 is globally unique) ──
10320
10321    #[tokio::test]
10322    async fn get_edge_cross_namespace_succeeds() {
10323        let rt = rt();
10324        let ns_a = NamespaceToken::for_namespace(Namespace::parse("ns-a").unwrap());
10325        let ns_b = NamespaceToken::for_namespace(Namespace::parse("ns-b").unwrap());
10326
10327        let src = rt
10328            .create_entity(&ns_a, "concept", None, "Src", None, None, vec![])
10329            .await
10330            .unwrap();
10331        let tgt = rt
10332            .create_entity(&ns_a, "concept", None, "Tgt", None, None, vec![])
10333            .await
10334            .unwrap();
10335        let edge = rt
10336            .link(&ns_a, src.id, tgt.id, EdgeRelation::Extends, 1.0, None)
10337            .await
10338            .unwrap();
10339
10340        // Visible from own namespace.
10341        let own_ns = rt.get_edge(&ns_a, Uuid::from(edge.id)).await;
10342        assert!(
10343            own_ns.is_ok() && own_ns.unwrap().is_some(),
10344            "edge must be visible in its own namespace"
10345        );
10346
10347        // Foreign namespace must now SUCCEED: by-ID get is namespace-agnostic.
10348        let cross_ns = rt.get_edge(&ns_b, Uuid::from(edge.id)).await;
10349        assert!(
10350            matches!(cross_ns, Ok(Some(_))),
10351            "cross-namespace get_edge must return Ok(Some(_)) after PR-A1, got {cross_ns:?}"
10352        );
10353
10354        // Absent edge UUID still returns None regardless of token namespace.
10355        let absent = rt.get_edge(&ns_b, Uuid::new_v4()).await;
10356        assert!(
10357            matches!(absent, Ok(None)),
10358            "absent edge must return Ok(None), got {absent:?}"
10359        );
10360    }
10361
10362    // ── Traversal across namespace labels now succeeds ────────────────────────
10363    //
10364    // Previously, traverse with ns_b token + ns_a root was silently empty
10365    // because substrate_exists_in_ns → get_entity rejected cross-namespace lookups.
10366    // Now: get_entity finds any entity by UUID; traverse finds the root and
10367    // returns paths scoped to the graph store's namespace filter for ns_b.
10368    #[tokio::test]
10369    async fn traverse_cross_namespace_root_is_accepted() {
10370        use khive_storage::types::TraversalOptions;
10371
10372        let rt = rt();
10373        let ns_a = NamespaceToken::for_namespace(Namespace::parse("ns-a").unwrap());
10374        let ns_b = NamespaceToken::for_namespace(Namespace::parse("ns-b").unwrap());
10375
10376        let a = rt
10377            .create_entity(&ns_a, "concept", None, "A", None, None, vec![])
10378            .await
10379            .unwrap();
10380        rt.create_entity(&ns_a, "concept", None, "B", None, None, vec![])
10381            .await
10382            .unwrap();
10383        rt.link(&ns_a, a.id, a.id, EdgeRelation::Extends, 1.0, None)
10384            .await
10385            .ok(); // may conflict with self-loop check; we just need an entity
10386
10387        // substrate_exists_in_ns finds the ns_a root via get_entity
10388        // (UUID-global lookup). The traverse proceeds; no panic.
10389        let result = rt
10390            .traverse(
10391                &ns_b,
10392                TraversalRequest {
10393                    roots: vec![a.id],
10394                    options: TraversalOptions {
10395                        max_depth: 1,
10396                        direction: Direction::Out,
10397                        ..Default::default()
10398                    },
10399                    include_roots: true,
10400                    include_properties: false,
10401                },
10402            )
10403            .await;
10404        assert!(result.is_ok(), "traverse must not error; got {:?}", result);
10405    }
10406
10407    // ---- purge cascade must include already-soft-deleted edges ----
10408    //
10409    // Hard delete must cascade ALL incident edges synchronously. A cascade driven
10410    // through `neighbors()`, which filters `deleted_at IS NULL`, would let incident
10411    // edges that were already soft-deleted survive endpoint purge as dangling rows.
10412    // `purge_incident_edges` issues a single DELETE without a `deleted_at` guard.
10413
10414    /// Count ALL `graph_edges` rows for a given UUID (source OR target), including soft-deleted.
10415    async fn count_all_incident_edges(rt: &KhiveRuntime, node_id: Uuid, ns: &str) -> u64 {
10416        let mut reader = rt.sql().reader().await.expect("sql reader must open");
10417        let row = reader
10418            .query_scalar(SqlStatement {
10419                sql: "SELECT COUNT(*) FROM graph_edges \
10420                      WHERE namespace = ?1 AND (source_id = ?2 OR target_id = ?2)"
10421                    .into(),
10422                params: vec![
10423                    SqlValue::Text(ns.to_string()),
10424                    SqlValue::Text(node_id.to_string()),
10425                ],
10426                label: Some("count_all_incident_edges".into()),
10427            })
10428            .await
10429            .expect("count query must succeed");
10430        match row {
10431            Some(SqlValue::Integer(n)) => n as u64,
10432            _ => panic!("count must return an integer"),
10433        }
10434    }
10435
10436    #[tokio::test]
10437    async fn hard_delete_entity_purges_already_soft_deleted_incident_edge() {
10438        let rt = rt();
10439        let tok = NamespaceToken::local();
10440        let ns = tok.namespace().to_string();
10441
10442        let a = rt
10443            .create_entity(&tok, "concept", None, "SrcA", None, None, vec![])
10444            .await
10445            .unwrap();
10446        let b = rt
10447            .create_entity(&tok, "concept", None, "TgtB", None, None, vec![])
10448            .await
10449            .unwrap();
10450
10451        rt.link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
10452            .await
10453            .unwrap();
10454
10455        // Soft-delete the edge — it is now invisible to `neighbors` but still in storage.
10456        let edge_hit = rt
10457            .neighbors(&tok, a.id, Direction::Out, None, None)
10458            .await
10459            .unwrap();
10460        assert_eq!(edge_hit.len(), 1, "edge must exist before soft-delete");
10461        let edge_uuid = edge_hit[0].edge_id;
10462        rt.delete_edge(&tok, edge_uuid, false).await.unwrap();
10463
10464        // Confirm the edge is invisible to normal read paths but present in raw storage.
10465        let visible = rt
10466            .neighbors(&tok, a.id, Direction::Out, None, None)
10467            .await
10468            .unwrap();
10469        assert!(visible.is_empty(), "soft-deleted edge must be invisible");
10470        let raw_before = count_all_incident_edges(&rt, a.id, &ns).await;
10471        assert_eq!(
10472            raw_before, 1,
10473            "soft-deleted edge must still be a physical row"
10474        );
10475
10476        // Hard-delete (purge) the source entity — cascade must also remove the soft-deleted edge.
10477        rt.delete_entity(&tok, a.id, true).await.unwrap();
10478
10479        let raw_after = count_all_incident_edges(&rt, a.id, &ns).await;
10480        assert_eq!(
10481            raw_after, 0,
10482            "purge_incident_edges must physically remove soft-deleted edge rows (ADR-002)"
10483        );
10484    }
10485
10486    #[tokio::test]
10487    async fn hard_delete_note_purges_already_soft_deleted_incident_edge() {
10488        let rt = rt();
10489        let tok = NamespaceToken::local();
10490        let ns = tok.namespace().to_string();
10491
10492        let target = rt
10493            .create_note(
10494                &tok,
10495                "observation",
10496                None,
10497                "purge-cascade target note",
10498                Some(0.5),
10499                None,
10500                vec![],
10501            )
10502            .await
10503            .unwrap();
10504        let annotating = rt
10505            .create_note(
10506                &tok,
10507                "insight",
10508                None,
10509                "annotator note",
10510                Some(0.5),
10511                None,
10512                vec![target.id],
10513            )
10514            .await
10515            .unwrap();
10516
10517        // Soft-delete the annotates edge.
10518        let edge_hit = rt
10519            .neighbors(
10520                &tok,
10521                annotating.id,
10522                Direction::Out,
10523                None,
10524                Some(vec![EdgeRelation::Annotates]),
10525            )
10526            .await
10527            .unwrap();
10528        assert_eq!(edge_hit.len(), 1, "annotates edge must exist");
10529        let edge_uuid = edge_hit[0].edge_id;
10530        rt.delete_edge(&tok, edge_uuid, false).await.unwrap();
10531
10532        let raw_before = count_all_incident_edges(&rt, target.id, &ns).await;
10533        assert_eq!(
10534            raw_before, 1,
10535            "soft-deleted edge must still be a physical row before note purge"
10536        );
10537
10538        // Hard-delete the target note — cascade must remove the soft-deleted edge row.
10539        rt.delete_note(&tok, target.id, true).await.unwrap();
10540
10541        let raw_after = count_all_incident_edges(&rt, target.id, &ns).await;
10542        assert_eq!(
10543            raw_after, 0,
10544            "purge_incident_edges must physically remove soft-deleted edge rows on note purge (ADR-002)"
10545        );
10546    }
10547
10548    // ---- cross-namespace entity hard-delete purges ALL incident edges ----
10549    //
10550    // `purge_incident_edges` must not scope its DELETE by `WHERE namespace = caller_ns`,
10551    // or a foreign-namespace entity's incident edges in ITS namespace would survive
10552    // the cascade as dangling rows.
10553
10554    /// Count ALL `graph_edges` rows for a given node UUID, across every namespace.
10555    async fn count_all_incident_edges_global(rt: &KhiveRuntime, node_id: Uuid) -> u64 {
10556        let mut reader = rt.sql().reader().await.expect("sql reader must open");
10557        let row = reader
10558            .query_scalar(SqlStatement {
10559                sql: "SELECT COUNT(*) FROM graph_edges WHERE source_id = ?1 OR target_id = ?1"
10560                    .into(),
10561                params: vec![SqlValue::Text(node_id.to_string())],
10562                label: Some("count_all_incident_edges_global".into()),
10563            })
10564            .await
10565            .expect("count query must succeed");
10566        match row {
10567            Some(SqlValue::Integer(n)) => n as u64,
10568            _ => panic!("count must return an integer"),
10569        }
10570    }
10571
10572    #[tokio::test]
10573    async fn cross_namespace_hard_delete_entity_purges_all_incident_edges() {
10574        // Entity lives in ns-owner. Edges live in ns-owner.
10575        // Delete is driven from ns-caller (a different namespace).
10576        // Assertion: after hard delete, no incident edges remain in ANY namespace.
10577        let rt = rt();
10578        let ns_owner = NamespaceToken::for_namespace(Namespace::parse("ns-owner").unwrap());
10579        let ns_caller = NamespaceToken::for_namespace(Namespace::parse("ns-caller").unwrap());
10580
10581        let entity = rt
10582            .create_entity(
10583                &ns_owner,
10584                "concept",
10585                None,
10586                "ForeignEntity",
10587                None,
10588                None,
10589                vec![],
10590            )
10591            .await
10592            .unwrap();
10593        let peer = rt
10594            .create_entity(&ns_owner, "concept", None, "Peer", None, None, vec![])
10595            .await
10596            .unwrap();
10597        // Create two incident edges in ns_owner. concept->Extends->concept is in the allowlist.
10598        rt.link(
10599            &ns_owner,
10600            entity.id,
10601            peer.id,
10602            EdgeRelation::Extends,
10603            1.0,
10604            None,
10605        )
10606        .await
10607        .unwrap();
10608        rt.link(
10609            &ns_owner,
10610            peer.id,
10611            entity.id,
10612            EdgeRelation::Extends,
10613            1.0,
10614            None,
10615        )
10616        .await
10617        .unwrap();
10618
10619        let before = count_all_incident_edges_global(&rt, entity.id).await;
10620        assert_eq!(before, 2, "two incident edges must exist before delete");
10621
10622        // Hard-delete entity from a DIFFERENT namespace token.
10623        let deleted = rt.delete_entity(&ns_caller, entity.id, true).await.unwrap();
10624        assert!(deleted, "cross-ns hard delete must return true");
10625
10626        // All incident edges must be gone regardless of namespace.
10627        let after = count_all_incident_edges_global(&rt, entity.id).await;
10628        assert_eq!(
10629            after, 0,
10630            "purge_incident_edges must remove all incident edges across namespaces (ADR-002, ADR-007)"
10631        );
10632    }
10633
10634    // ---- edge-ID hard-delete path ----
10635    //
10636    // Bug class: delete_edge drove the primary-edge guard through get_edge()
10637    // (live-only) and the cascade through neighbors() (live-only). Two reachable holes:
10638    // (a) soft-deleted primary edge cannot be hard-purged via its own ID;
10639    // (b) an already-soft-deleted annotates edge targeting a base edge survives that
10640    //     edge's hard delete as a dangling row with target_id = physically-gone edge id.
10641
10642    /// Count graph_edges rows matching the given edge ID, including soft-deleted rows.
10643    async fn count_edge_rows_by_id(rt: &KhiveRuntime, edge_id: Uuid, ns: &str) -> u64 {
10644        let mut reader = rt.sql().reader().await.expect("sql reader must open");
10645        let row = reader
10646            .query_scalar(SqlStatement {
10647                sql: "SELECT COUNT(*) FROM graph_edges WHERE namespace = ?1 AND id = ?2".into(),
10648                params: vec![
10649                    SqlValue::Text(ns.to_string()),
10650                    SqlValue::Text(edge_id.to_string()),
10651                ],
10652                label: Some("count_edge_rows_by_id".into()),
10653            })
10654            .await
10655            .expect("count query must succeed");
10656        match row {
10657            Some(SqlValue::Integer(n)) => n as u64,
10658            _ => panic!("count must return an integer"),
10659        }
10660    }
10661
10662    #[tokio::test]
10663    async fn hard_delete_edge_purges_already_soft_deleted_primary_edge() {
10664        let rt = rt();
10665        let tok = NamespaceToken::local();
10666        let ns = tok.namespace().to_string();
10667
10668        let a = rt
10669            .create_entity(&tok, "concept", None, "EA", None, None, vec![])
10670            .await
10671            .unwrap();
10672        let b = rt
10673            .create_entity(&tok, "concept", None, "EB", None, None, vec![])
10674            .await
10675            .unwrap();
10676
10677        let edge = rt
10678            .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
10679            .await
10680            .unwrap();
10681        let edge_uuid: Uuid = edge.id.into();
10682
10683        // Soft-delete the edge first.
10684        let soft = rt.delete_edge(&tok, edge_uuid, false).await.unwrap();
10685        assert!(soft, "soft delete must succeed");
10686
10687        // Edge is now invisible to normal reads but still a physical row.
10688        assert!(
10689            rt.get_edge(&tok, edge_uuid).await.unwrap().is_none(),
10690            "soft-deleted edge must be invisible to get_edge"
10691        );
10692        assert_eq!(
10693            count_edge_rows_by_id(&rt, edge_uuid, &ns).await,
10694            1,
10695            "soft-deleted edge must still be a physical row"
10696        );
10697
10698        // Hard-delete (purge) via the edge ID — must succeed and remove the row.
10699        let purged = rt.delete_edge(&tok, edge_uuid, true).await.unwrap();
10700        assert!(
10701            purged,
10702            "hard delete of a soft-deleted edge must return true"
10703        );
10704
10705        assert_eq!(
10706            count_edge_rows_by_id(&rt, edge_uuid, &ns).await,
10707            0,
10708            "hard-delete must physically remove the soft-deleted edge row (ADR-002)"
10709        );
10710    }
10711
10712    #[tokio::test]
10713    async fn hard_delete_base_edge_purges_already_soft_deleted_annotates_edge() {
10714        let rt = rt();
10715        let tok = NamespaceToken::local();
10716        let ns = tok.namespace().to_string();
10717
10718        let a = rt
10719            .create_entity(&tok, "concept", None, "CA", None, None, vec![])
10720            .await
10721            .unwrap();
10722        let b = rt
10723            .create_entity(&tok, "concept", None, "CB", None, None, vec![])
10724            .await
10725            .unwrap();
10726
10727        // Create the base edge to be annotated.
10728        let base_edge = rt
10729            .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
10730            .await
10731            .unwrap();
10732        let base_edge_uuid: Uuid = base_edge.id.into();
10733
10734        // Create a note that annotates the base edge.
10735        let note = rt
10736            .create_note(
10737                &tok,
10738                "observation",
10739                None,
10740                "note about base edge",
10741                Some(0.5),
10742                None,
10743                vec![base_edge_uuid],
10744            )
10745            .await
10746            .unwrap();
10747
10748        // Find the annotates edge.
10749        let ann_hits = rt
10750            .neighbors(
10751                &tok,
10752                note.id,
10753                Direction::Out,
10754                None,
10755                Some(vec![EdgeRelation::Annotates]),
10756            )
10757            .await
10758            .unwrap();
10759        assert_eq!(ann_hits.len(), 1, "annotates edge must exist");
10760        let ann_edge_uuid = ann_hits[0].edge_id;
10761
10762        // Soft-delete the annotates edge — now invisible but still a physical row.
10763        rt.delete_edge(&tok, ann_edge_uuid, false).await.unwrap();
10764        assert_eq!(
10765            count_edge_rows_by_id(&rt, ann_edge_uuid, &ns).await,
10766            1,
10767            "soft-deleted annotates edge must still be a physical row"
10768        );
10769
10770        // Hard-delete the base edge — cascade must also remove the soft-deleted annotates row.
10771        let purged = rt.delete_edge(&tok, base_edge_uuid, true).await.unwrap();
10772        assert!(purged, "hard delete of base edge must return true");
10773
10774        assert_eq!(
10775            count_edge_rows_by_id(&rt, ann_edge_uuid, &ns).await,
10776            0,
10777            "hard-delete of base edge must purge already-soft-deleted annotates edge row (ADR-002)"
10778        );
10779        assert_eq!(
10780            count_edge_rows_by_id(&rt, base_edge_uuid, &ns).await,
10781            0,
10782            "hard-delete must physically remove the base edge row"
10783        );
10784    }
10785
10786    // ---- entity create/update multi-model embed fan-out tests ----
10787
10788    // FTS failure after entity row commit rolls back the entity row.
10789    // Mirrors create_note_fts_failure_rolls_back_note_row but for entities.
10790    // Uses a unique namespace so the process-global FTS_FAIL_NS one-shot is
10791    // consumed only by this test's create_entity call.
10792    #[tokio::test]
10793    async fn create_entity_fts_failure_rolls_back_entity_row() {
10794        let rt = KhiveRuntime::memory().unwrap();
10795        let ns = Namespace::parse("fault-entity-fts").unwrap();
10796        let tok = NamespaceToken::for_namespace(ns.clone());
10797
10798        arm_fts_fail(ns.as_str());
10799
10800        let result = rt
10801            .create_entity(
10802                &tok,
10803                "concept",
10804                None,
10805                "fts-fail rollback target",
10806                None,
10807                None,
10808                vec![],
10809            )
10810            .await;
10811
10812        assert!(
10813            result.is_err(),
10814            "create_entity must propagate the injected FTS failure"
10815        );
10816        let err_msg = result.unwrap_err().to_string();
10817        assert!(
10818            err_msg.contains("injected FTS failure"),
10819            "error must carry injection message; got: {err_msg}"
10820        );
10821
10822        let entities = rt.list_entities(&tok, None, None, 1000, 0).await.unwrap();
10823        assert!(
10824            entities.is_empty(),
10825            "compensation must remove the entity row after FTS failure; got {entities:?}"
10826        );
10827    }
10828
10829    // Vector insert failure after entity row + FTS commit rolls back both.
10830    // Uses a unique namespace to avoid consuming the VECTOR_FAIL_NS flag from
10831    // a concurrent test's create_entity or create_note.
10832    #[tokio::test]
10833    async fn create_entity_vector_failure_rolls_back_entity_row_and_fts() {
10834        const MODEL: &str = "test-entity-vec-inject";
10835        const DIMS: usize = 4;
10836
10837        let rt = KhiveRuntime::memory().unwrap();
10838        let (provider, _counter) = ConstVecProvider::new(MODEL, DIMS);
10839        rt.register_embedder(provider);
10840
10841        let ns = Namespace::parse("fault-entity-vec").unwrap();
10842        let tok = NamespaceToken::for_namespace(ns.clone());
10843
10844        arm_vector_fail(ns.as_str());
10845
10846        let result = rt
10847            .create_entity(
10848                &tok,
10849                "concept",
10850                None,
10851                "vec-fail rollback target",
10852                Some("description so embed body is non-empty"),
10853                None,
10854                vec![],
10855            )
10856            .await;
10857
10858        assert!(
10859            result.is_err(),
10860            "create_entity must propagate the injected vector failure"
10861        );
10862        let err_msg = result.unwrap_err().to_string();
10863        assert!(
10864            err_msg.contains("injected vector failure"),
10865            "error must carry injection message; got: {err_msg}"
10866        );
10867
10868        let entities = rt.list_entities(&tok, None, None, 1000, 0).await.unwrap();
10869        assert!(
10870            entities.is_empty(),
10871            "compensation must remove entity row after vector failure; got {entities:?}"
10872        );
10873
10874        // FTS document must also be removed.
10875        use khive_storage::types::{TextFilter, TextQueryMode, TextSearchRequest};
10876        let fts_hits = rt
10877            .text(&tok)
10878            .unwrap()
10879            .search(TextSearchRequest {
10880                query: "vec-fail rollback target".to_string(),
10881                mode: TextQueryMode::Plain,
10882                filter: Some(TextFilter {
10883                    namespaces: vec![ns.as_str().to_string()],
10884                    ..Default::default()
10885                }),
10886                top_k: 10,
10887                snippet_chars: 100,
10888            })
10889            .await
10890            .unwrap();
10891        assert!(
10892            fts_hits.is_empty(),
10893            "compensation must remove FTS document after vector failure; got {fts_hits:?}"
10894        );
10895    }
10896
10897    // Multi-model create_entity: second model's vector INSERT fails after the
10898    // first model's insert succeeds, triggering inserted_models rollback.
10899    // Uses arm_vector_fail_after(1) so the first insert passes and the second fails,
10900    // exercising the inserted_models compensation path in create_entity.
10901    // Thread-local VECTOR_FAIL_AFTER is per-thread isolated (current-thread tokio runtime),
10902    // so this test does not race with namespace-targeted VECTOR_FAIL_NS tests.
10903    #[tokio::test]
10904    async fn create_entity_multi_model_second_vector_failure_rolls_back_all() {
10905        const DIMS: usize = 4;
10906
10907        let rt = KhiveRuntime::memory().unwrap();
10908        let (provider_a, _ca) = ConstVecProvider::new("model-a", DIMS);
10909        let (provider_b, _cb) = ConstVecProvider::new("model-b", DIMS);
10910        rt.register_embedder(provider_a);
10911        rt.register_embedder(provider_b);
10912
10913        let ns = Namespace::parse("fault-entity-multi").unwrap();
10914        let tok = NamespaceToken::for_namespace(ns.clone());
10915
10916        // Let the first vector insert succeed, fail on the second.
10917        arm_vector_fail_after(1);
10918
10919        let result = rt
10920            .create_entity(
10921                &tok,
10922                "concept",
10923                None,
10924                "multi-model rollback target",
10925                Some("description for embedding"),
10926                None,
10927                vec![],
10928            )
10929            .await;
10930
10931        assert!(
10932            result.is_err(),
10933            "create_entity must propagate the injected multi-model vector failure"
10934        );
10935
10936        let entities = rt.list_entities(&tok, None, None, 1000, 0).await.unwrap();
10937        assert!(
10938            entities.is_empty(),
10939            "compensation must remove entity row; got {entities:?}"
10940        );
10941
10942        // Both model-a and model-b vector stores must be empty for the entity id.
10943        // (The entity was never returned so we can't get its id from the result;
10944        // list_entities returning empty is the primary assertion. Additionally confirm
10945        // both stores have zero rows via a broad vector search.)
10946        use khive_storage::types::VectorSearchRequest;
10947        let query_vec = vec![1.0_f32; DIMS];
10948        let hits_a = rt
10949            .vectors_for_model(&tok, "model-a")
10950            .unwrap()
10951            .search(VectorSearchRequest {
10952                query_vectors: vec![query_vec.clone()],
10953                top_k: 100,
10954                namespace: Some(ns.as_str().to_string()),
10955                kind: Some(khive_types::SubstrateKind::Entity),
10956                embedding_model: Some("model-a".to_string()),
10957                filter: None,
10958                backend_hints: None,
10959            })
10960            .await
10961            .unwrap();
10962        assert!(
10963            hits_a.is_empty(),
10964            "model-a vector store must be empty after rollback; got {hits_a:?}"
10965        );
10966        let hits_b = rt
10967            .vectors_for_model(&tok, "model-b")
10968            .unwrap()
10969            .search(VectorSearchRequest {
10970                query_vectors: vec![query_vec],
10971                top_k: 100,
10972                namespace: Some(ns.as_str().to_string()),
10973                kind: Some(khive_types::SubstrateKind::Entity),
10974                embedding_model: Some("model-b".to_string()),
10975                filter: None,
10976                backend_hints: None,
10977            })
10978            .await
10979            .unwrap();
10980        assert!(
10981            hits_b.is_empty(),
10982            "model-b vector store must be empty after rollback; got {hits_b:?}"
10983        );
10984    }
10985
10986    // update_entity fans out to ALL registered models.
10987    // After create + update with a changed description, both model-a and model-b
10988    // vector stores hold a row for the entity id.
10989    #[tokio::test]
10990    async fn update_entity_fans_out_to_all_registered_models() {
10991        const DIMS: usize = 4;
10992
10993        let rt = KhiveRuntime::memory().unwrap();
10994        let (provider_a, _ca) = ConstVecProvider::new("embed-a", DIMS);
10995        let (provider_b, _cb) = ConstVecProvider::new("embed-b", DIMS);
10996        rt.register_embedder(provider_a);
10997        rt.register_embedder(provider_b);
10998
10999        let ns = Namespace::parse("update-entity-fanout").unwrap();
11000        let tok = NamespaceToken::for_namespace(ns.clone());
11001
11002        let entity = rt
11003            .create_entity(
11004                &tok,
11005                "concept",
11006                None,
11007                "FanOutEntity",
11008                Some("initial description"),
11009                None,
11010                vec![],
11011            )
11012            .await
11013            .expect("create_entity must succeed");
11014
11015        use crate::curation::EntityPatch;
11016        let patch = EntityPatch {
11017            description: Some(Some("updated description after fan-out fix".to_string())),
11018            ..Default::default()
11019        };
11020        rt.update_entity(&tok, entity.id, patch)
11021            .await
11022            .expect("update_entity must succeed");
11023
11024        use khive_storage::types::VectorSearchRequest;
11025        let query_vec = vec![1.0_f32; DIMS];
11026
11027        let hits_a = rt
11028            .vectors_for_model(&tok, "embed-a")
11029            .unwrap()
11030            .search(VectorSearchRequest {
11031                query_vectors: vec![query_vec.clone()],
11032                top_k: 10,
11033                namespace: Some(ns.as_str().to_string()),
11034                kind: Some(khive_types::SubstrateKind::Entity),
11035                embedding_model: Some("embed-a".to_string()),
11036                filter: None,
11037                backend_hints: None,
11038            })
11039            .await
11040            .unwrap();
11041        assert!(
11042            hits_a.iter().any(|h| h.subject_id == entity.id),
11043            "embed-a must hold a vector for the entity after update; got {hits_a:?}"
11044        );
11045
11046        let hits_b = rt
11047            .vectors_for_model(&tok, "embed-b")
11048            .unwrap()
11049            .search(VectorSearchRequest {
11050                query_vectors: vec![query_vec],
11051                top_k: 10,
11052                namespace: Some(ns.as_str().to_string()),
11053                kind: Some(khive_types::SubstrateKind::Entity),
11054                embedding_model: Some("embed-b".to_string()),
11055                filter: None,
11056                backend_hints: None,
11057            })
11058            .await
11059            .unwrap();
11060        assert!(
11061            hits_b.iter().any(|h| h.subject_id == entity.id),
11062            "embed-b must hold a vector for the entity after update; got {hits_b:?}"
11063        );
11064    }
11065
11066    // update_note fans out to ALL registered models.
11067    // After create + update with changed content, both embed-a and embed-b
11068    // vector stores hold a row for the note id.
11069    #[tokio::test]
11070    async fn update_note_fans_out_to_all_registered_models() {
11071        const DIMS: usize = 4;
11072
11073        let rt = KhiveRuntime::memory().unwrap();
11074        let (provider_a, _ca) = ConstVecProvider::new("embed-a", DIMS);
11075        let (provider_b, _cb) = ConstVecProvider::new("embed-b", DIMS);
11076        rt.register_embedder(provider_a);
11077        rt.register_embedder(provider_b);
11078
11079        let ns = Namespace::parse("update-note-fanout").unwrap();
11080        let tok = NamespaceToken::for_namespace(ns.clone());
11081
11082        let note = rt
11083            .create_note(
11084                &tok,
11085                "observation",
11086                None,
11087                "initial note content for fan-out test",
11088                None,
11089                None,
11090                vec![],
11091            )
11092            .await
11093            .expect("create_note must succeed");
11094
11095        use crate::curation::NotePatch;
11096        let patch = NotePatch {
11097            content: Some("updated content after fan-out fix".to_string()),
11098            ..Default::default()
11099        };
11100        rt.update_note(&tok, note.id, patch)
11101            .await
11102            .expect("update_note must succeed");
11103
11104        use khive_storage::types::VectorSearchRequest;
11105        let query_vec = vec![1.0_f32; DIMS];
11106
11107        let hits_a = rt
11108            .vectors_for_model(&tok, "embed-a")
11109            .unwrap()
11110            .search(VectorSearchRequest {
11111                query_vectors: vec![query_vec.clone()],
11112                top_k: 10,
11113                namespace: Some(ns.as_str().to_string()),
11114                kind: Some(khive_types::SubstrateKind::Note),
11115                embedding_model: Some("embed-a".to_string()),
11116                filter: None,
11117                backend_hints: None,
11118            })
11119            .await
11120            .unwrap();
11121        assert!(
11122            hits_a.iter().any(|h| h.subject_id == note.id),
11123            "embed-a must hold a vector for the note after update; got {hits_a:?}"
11124        );
11125
11126        let hits_b = rt
11127            .vectors_for_model(&tok, "embed-b")
11128            .unwrap()
11129            .search(VectorSearchRequest {
11130                query_vectors: vec![query_vec],
11131                top_k: 10,
11132                namespace: Some(ns.as_str().to_string()),
11133                kind: Some(khive_types::SubstrateKind::Note),
11134                embedding_model: Some("embed-b".to_string()),
11135                filter: None,
11136                backend_hints: None,
11137            })
11138            .await
11139            .unwrap();
11140        assert!(
11141            hits_b.iter().any(|h| h.subject_id == note.id),
11142            "embed-b must hold a vector for the note after update; got {hits_b:?}"
11143        );
11144    }
11145
11146    // ── By-ID ops must not filter by namespace ──────────────────────────────
11147    //
11148    // A namespace-gated by-ID op on an entity stamped "foreign" from a "local"
11149    // token would return NotFound, causing gtd.complete / update blindness.
11150    // UUID is globally unique; by-ID ops find the record regardless of
11151    // which namespace the caller's token carries.
11152
11153    #[tokio::test]
11154    async fn get_entity_cross_namespace_succeeds() {
11155        let rt = rt();
11156        // Create under "lambda:leo".
11157        let leo_tok = NamespaceToken::for_namespace(Namespace::parse("lambda:leo").unwrap());
11158        let entity = rt
11159            .create_entity(&leo_tok, "concept", None, "Peer-Entity", None, None, vec![])
11160            .await
11161            .unwrap();
11162        assert_eq!(entity.namespace, "lambda:leo");
11163
11164        // Read from "local" — must succeed (no namespace gate on by-ID get).
11165        let local_tok = NamespaceToken::local();
11166        let fetched = rt.get_entity(&local_tok, entity.id).await;
11167        assert!(
11168            fetched.is_ok(),
11169            "get_entity from local token must find lambda:leo entity; got {:?}",
11170            fetched
11171        );
11172        assert_eq!(fetched.unwrap().id, entity.id);
11173    }
11174
11175    #[tokio::test]
11176    async fn update_entity_cross_namespace_succeeds() {
11177        let rt = rt();
11178        let leo_tok = NamespaceToken::for_namespace(Namespace::parse("lambda:leo").unwrap());
11179        let entity = rt
11180            .create_entity(
11181                &leo_tok,
11182                "concept",
11183                None,
11184                "Peer-Entity-Update",
11185                None,
11186                None,
11187                vec![],
11188            )
11189            .await
11190            .unwrap();
11191
11192        // Update from "local" token — must not error with NotFound.
11193        let local_tok = NamespaceToken::local();
11194        let patch = crate::curation::EntityPatch {
11195            name: Some("Peer-Entity-Updated".to_string()),
11196            ..Default::default()
11197        };
11198        let result = rt.update_entity(&local_tok, entity.id, patch).await;
11199        assert!(
11200            result.is_ok(),
11201            "update_entity from local token must succeed on lambda:leo entity; got {:?}",
11202            result
11203        );
11204        assert_eq!(result.unwrap().name, "Peer-Entity-Updated");
11205    }
11206
11207    #[tokio::test]
11208    async fn delete_entity_cross_namespace_succeeds() {
11209        let rt = rt();
11210        let leo_tok = NamespaceToken::for_namespace(Namespace::parse("lambda:leo").unwrap());
11211        let entity = rt
11212            .create_entity(
11213                &leo_tok,
11214                "concept",
11215                None,
11216                "Peer-Entity-Delete",
11217                None,
11218                None,
11219                vec![],
11220            )
11221            .await
11222            .unwrap();
11223
11224        // Delete from "local" token — must succeed.
11225        let local_tok = NamespaceToken::local();
11226        let deleted = rt.delete_entity(&local_tok, entity.id, false).await;
11227        assert!(
11228            deleted.is_ok(),
11229            "delete_entity from local token must succeed on lambda:leo entity; got {:?}",
11230            deleted
11231        );
11232        assert!(
11233            deleted.unwrap(),
11234            "delete must return true when entity existed"
11235        );
11236    }
11237
11238    #[tokio::test]
11239    async fn namespace_preserved_on_entity_after_cross_namespace_get() {
11240        let rt = rt();
11241        let leo_tok = NamespaceToken::for_namespace(Namespace::parse("lambda:leo").unwrap());
11242        let entity = rt
11243            .create_entity(
11244                &leo_tok,
11245                "concept",
11246                None,
11247                "NS-Preserved",
11248                None,
11249                None,
11250                vec![],
11251            )
11252            .await
11253            .unwrap();
11254
11255        // The namespace column on the fetched record must still say "lambda:leo".
11256        let local_tok = NamespaceToken::local();
11257        let fetched = rt.get_entity(&local_tok, entity.id).await.unwrap();
11258        assert_eq!(
11259            fetched.namespace, "lambda:leo",
11260            "namespace column must be preserved; not overwritten with caller's namespace"
11261        );
11262    }
11263
11264    // ── PackByIdResolver unit tests ──────────────────────────────────────────
11265
11266    use crate::pack::PackByIdResolver;
11267    use tokio::sync::Mutex as TokioMutex;
11268
11269    #[derive(Debug, Default)]
11270    struct MockResolverState {
11271        owned: Vec<Uuid>,
11272        deleted: Vec<Uuid>,
11273        delete_calls: Vec<(Uuid, bool)>,
11274    }
11275
11276    struct MockPackResolver(TokioMutex<MockResolverState>);
11277
11278    impl MockPackResolver {
11279        fn new() -> Self {
11280            Self(TokioMutex::new(MockResolverState::default()))
11281        }
11282    }
11283
11284    #[async_trait::async_trait]
11285    impl crate::pack::PackByIdResolver for MockPackResolver {
11286        async fn resolve_by_id(&self, id: Uuid) -> Result<Option<Resolved>, RuntimeError> {
11287            let state = self.0.lock().await;
11288            if state.owned.contains(&id) && !state.deleted.contains(&id) {
11289                Ok(Some(Resolved::PackRecord {
11290                    pack: "mock".into(),
11291                    kind: "widget".into(),
11292                    data: serde_json::json!({ "id": id.to_string(), "name": "test-widget" }),
11293                }))
11294            } else {
11295                Ok(None)
11296            }
11297        }
11298
11299        async fn resolve_by_id_including_deleted(
11300            &self,
11301            id: Uuid,
11302        ) -> Result<Option<Resolved>, RuntimeError> {
11303            let state = self.0.lock().await;
11304            if state.owned.contains(&id) {
11305                Ok(Some(Resolved::PackRecord {
11306                    pack: "mock".into(),
11307                    kind: "widget".into(),
11308                    data: serde_json::json!({ "id": id.to_string(), "name": "test-widget" }),
11309                }))
11310            } else {
11311                Ok(None)
11312            }
11313        }
11314
11315        async fn delete_by_id(
11316            &self,
11317            id: Uuid,
11318            hard: bool,
11319        ) -> Result<serde_json::Value, RuntimeError> {
11320            let mut state = self.0.lock().await;
11321            if !state.owned.contains(&id) {
11322                return Err(RuntimeError::NotFound(format!(
11323                    "mock widget not found: {id}"
11324                )));
11325            }
11326            state.delete_calls.push((id, hard));
11327            if hard {
11328                state.owned.retain(|&x| x != id);
11329                state.deleted.retain(|&x| x != id);
11330            } else {
11331                state.deleted.push(id);
11332            }
11333            Ok(
11334                serde_json::json!({ "deleted": true, "id": id.to_string(), "kind": "widget", "hard": hard }),
11335            )
11336        }
11337    }
11338
11339    fn registry_with_mock_resolver(
11340        rt: KhiveRuntime,
11341        resolver: Box<dyn crate::pack::PackByIdResolver>,
11342    ) -> crate::VerbRegistry {
11343        use crate::pack::{PackRuntime, VerbRegistryBuilder};
11344        use khive_types::{HandlerDef, VerbCategory, Visibility};
11345
11346        static MINIMAL_HANDLERS: &[HandlerDef] = &[HandlerDef {
11347            name: "minimal.noop",
11348            description: "noop",
11349            visibility: Visibility::Verb,
11350            category: VerbCategory::Commissive,
11351            params: &[],
11352        }];
11353
11354        struct MinimalPack;
11355        impl khive_types::Pack for MinimalPack {
11356            const NAME: &'static str = "minimal";
11357            const NOTE_KINDS: &'static [&'static str] = &[];
11358            const ENTITY_KINDS: &'static [&'static str] = &[];
11359            const HANDLERS: &'static [HandlerDef] = MINIMAL_HANDLERS;
11360        }
11361        #[async_trait::async_trait]
11362        impl PackRuntime for MinimalPack {
11363            fn name(&self) -> &str {
11364                "minimal"
11365            }
11366            fn note_kinds(&self) -> &'static [&'static str] {
11367                &[]
11368            }
11369            fn entity_kinds(&self) -> &'static [&'static str] {
11370                &[]
11371            }
11372            fn handlers(&self) -> &'static [HandlerDef] {
11373                MINIMAL_HANDLERS
11374            }
11375            async fn dispatch(
11376                &self,
11377                _verb: &str,
11378                _params: serde_json::Value,
11379                _registry: &crate::VerbRegistry,
11380                _token: &NamespaceToken,
11381            ) -> Result<serde_json::Value, RuntimeError> {
11382                Err(RuntimeError::InvalidInput("stub".into()))
11383            }
11384        }
11385
11386        let _ = rt;
11387        let mut builder = VerbRegistryBuilder::new();
11388        builder.register(MinimalPack);
11389        builder.register_resolver("mock", resolver);
11390        builder.build().expect("registry build failed")
11391    }
11392
11393    #[tokio::test]
11394    async fn pack_record_resolved_pair_returns_none() {
11395        let pr = Resolved::PackRecord {
11396            pack: "knowledge".into(),
11397            kind: "atom".into(),
11398            data: serde_json::json!({}),
11399        };
11400        assert!(
11401            resolved_pair(Some(&pr)).is_none(),
11402            "PackRecord must not be a valid edge endpoint"
11403        );
11404    }
11405
11406    #[test]
11407    fn resolved_pair_surfaces_entity_type() {
11408        let e = Resolved::Entity(
11409            Entity::new("mathlib", "concept", "Nat.add_comm").with_entity_type(Some("theorem")),
11410        );
11411        assert_eq!(
11412            resolved_pair(Some(&e)),
11413            Some(("entity", "concept", Some("theorem"))),
11414            "entity_type subtype must be surfaced alongside base kind"
11415        );
11416    }
11417
11418    #[test]
11419    fn endpoint_of_type_matches_subtype_not_base_kind() {
11420        // An entity whose base kind is "concept" and subtype is "theorem".
11421        let kind = "concept";
11422        let et = Some("theorem");
11423
11424        // EntityOfType matches only when BOTH base kind and subtype match.
11425        assert!(endpoint_matches(
11426            &EndpointKind::EntityOfType {
11427                kind: "concept",
11428                entity_type: "theorem",
11429            },
11430            "entity",
11431            kind,
11432            et
11433        ));
11434        assert!(!endpoint_matches(
11435            &EndpointKind::EntityOfType {
11436                kind: "concept",
11437                entity_type: "definition",
11438            },
11439            "entity",
11440            kind,
11441            et
11442        ));
11443
11444        // The silently-inert trap: EntityOfKind sees only the BASE
11445        // kind, so EntityOfKind("theorem") never matches a concept/theorem.
11446        assert!(!endpoint_matches(
11447            &EndpointKind::EntityOfKind("theorem"),
11448            "entity",
11449            kind,
11450            et
11451        ));
11452        // EntityOfKind still matches the base kind.
11453        assert!(endpoint_matches(
11454            &EndpointKind::EntityOfKind("concept"),
11455            "entity",
11456            kind,
11457            et
11458        ));
11459
11460        // EntityOfType rejects non-entity substrates and entities with no subtype.
11461        assert!(!endpoint_matches(
11462            &EndpointKind::EntityOfType {
11463                kind: "concept",
11464                entity_type: "theorem",
11465            },
11466            "note",
11467            "task",
11468            None
11469        ));
11470        assert!(!endpoint_matches(
11471            &EndpointKind::EntityOfType {
11472                kind: "concept",
11473                entity_type: "theorem",
11474            },
11475            "entity",
11476            kind,
11477            None
11478        ));
11479    }
11480
11481    #[test]
11482    fn endpoint_of_type_requires_base_kind_match() {
11483        // Regression: an entity with entity_type="theorem" but base kind != "concept"
11484        // must NOT match a formal concept rule. This was the exact bypass:
11485        // before the fix, EntityOfType("theorem") ignored the base kind entirely.
11486        let wrong_base_kind = "project"; // not "concept"
11487        let et = Some("theorem");
11488
11489        // The formal rule requires kind="concept". A "project" entity with
11490        // entity_type="theorem" must not match — even though the subtype string
11491        // matches — because the base kind differs.
11492        assert!(
11493            !endpoint_matches(
11494                &EndpointKind::EntityOfType {
11495                    kind: "concept",
11496                    entity_type: "theorem",
11497                },
11498                "entity",
11499                wrong_base_kind,
11500                et
11501            ),
11502            "EntityOfType must reject an entity whose base kind != rule.kind \
11503             even when entity_type matches — the pre-fix bug admitted this"
11504        );
11505
11506        // The correct concept entity with the same subtype still matches.
11507        assert!(endpoint_matches(
11508            &EndpointKind::EntityOfType {
11509                kind: "concept",
11510                entity_type: "theorem",
11511            },
11512            "entity",
11513            "concept",
11514            et
11515        ));
11516    }
11517
11518    #[tokio::test]
11519    async fn registry_resolvers_accessor_returns_registered() {
11520        let resolver = Box::new(MockPackResolver::new());
11521        let registry = registry_with_mock_resolver(rt(), resolver);
11522        assert_eq!(registry.resolvers().len(), 1);
11523        assert_eq!(registry.resolvers()[0].0, "mock");
11524    }
11525
11526    #[tokio::test]
11527    async fn mock_resolver_resolve_by_id_returns_pack_record() {
11528        let id = Uuid::new_v4();
11529        let resolver: Box<dyn PackByIdResolver> = Box::new(MockPackResolver::new());
11530        // We need interior access — downcast first, then use via trait.
11531        let inner = MockPackResolver::new();
11532        inner.0.lock().await.owned.push(id);
11533        let result: Result<Option<Resolved>, RuntimeError> = inner.resolve_by_id(id).await;
11534        match result.unwrap() {
11535            Some(Resolved::PackRecord { pack, kind, data }) => {
11536                assert_eq!(pack, "mock");
11537                assert_eq!(kind, "widget");
11538                assert_eq!(data["id"].as_str().unwrap(), id.to_string());
11539            }
11540            other => panic!("expected PackRecord, got {:?}", other),
11541        }
11542        let _ = resolver;
11543    }
11544
11545    #[tokio::test]
11546    async fn mock_resolver_resolve_unknown_uuid_returns_none() {
11547        let inner = MockPackResolver::new();
11548        let id = Uuid::new_v4();
11549        let result: Result<Option<Resolved>, RuntimeError> = inner.resolve_by_id(id).await;
11550        assert!(result.unwrap().is_none());
11551    }
11552
11553    #[tokio::test]
11554    async fn mock_resolver_delete_soft_records_call() {
11555        let id = Uuid::new_v4();
11556        let inner = MockPackResolver::new();
11557        inner.0.lock().await.owned.push(id);
11558
11559        let result: Result<serde_json::Value, RuntimeError> = inner.delete_by_id(id, false).await;
11560        let result = result.unwrap();
11561        assert_eq!(result["deleted"], serde_json::json!(true));
11562        assert_eq!(result["hard"], serde_json::json!(false));
11563
11564        // After soft-delete: resolve_by_id returns None, but including_deleted returns Some.
11565        let live: Result<Option<Resolved>, RuntimeError> = inner.resolve_by_id(id).await;
11566        assert!(live.unwrap().is_none());
11567        let incl: Result<Option<Resolved>, RuntimeError> =
11568            inner.resolve_by_id_including_deleted(id).await;
11569        assert!(incl.unwrap().is_some());
11570    }
11571
11572    #[tokio::test]
11573    async fn mock_resolver_delete_hard_removes_record() {
11574        let id = Uuid::new_v4();
11575        let inner = MockPackResolver::new();
11576        inner.0.lock().await.owned.push(id);
11577
11578        let result: Result<serde_json::Value, RuntimeError> = inner.delete_by_id(id, true).await;
11579        assert_eq!(result.unwrap()["hard"], serde_json::json!(true));
11580
11581        // After hard-delete: neither probe finds the record.
11582        let incl: Result<Option<Resolved>, RuntimeError> =
11583            inner.resolve_by_id_including_deleted(id).await;
11584        assert!(incl.unwrap().is_none());
11585    }
11586
11587    #[tokio::test]
11588    async fn pack_record_not_valid_context_entity() {
11589        // Validates the GTD handler arm compiles and returns InvalidInput.
11590        // We exercise the match logic directly by constructing a PackRecord Resolved.
11591        let pr = Resolved::PackRecord {
11592            pack: "knowledge".into(),
11593            kind: "atom".into(),
11594            data: serde_json::json!({}),
11595        };
11596        // The match in GTD handlers.rs now handles PackRecord → InvalidInput.
11597        // We can verify the enum variant is reachable.
11598        assert!(matches!(pr, Resolved::PackRecord { .. }));
11599    }
11600
11601    // ── Batched enrich_neighbor_hits / enrich_path_nodes ────────────────────
11602
11603    fn neighbor_hit(node_id: Uuid) -> NeighborHit {
11604        NeighborHit {
11605            node_id,
11606            edge_id: Uuid::new_v4(),
11607            relation: EdgeRelation::Extends,
11608            weight: 1.0,
11609            name: None,
11610            kind: None,
11611            entity_type: None,
11612        }
11613    }
11614
11615    fn path_node(node_id: Uuid, depth: usize) -> PathNode {
11616        PathNode {
11617            node_id,
11618            via_edge: None,
11619            depth,
11620            name: None,
11621            kind: None,
11622            properties: None,
11623        }
11624    }
11625
11626    /// enrich_neighbor_hits: entity hit resolved, note hit resolved with
11627    /// name-fallback to "[kind]", bogus UUID left as None.  Order preserved.
11628    #[tokio::test]
11629    async fn enrich_neighbor_hits_batch_entity_note_and_bogus() {
11630        let rt = rt();
11631        let tok = NamespaceToken::local();
11632
11633        // Create an entity neighbor.
11634        let entity = rt
11635            .create_entity(&tok, "concept", None, "MyEntity", None, None, vec![])
11636            .await
11637            .unwrap();
11638
11639        // Nameless note — name falls back to "[observation]".
11640        let note = rt
11641            .create_note(&tok, "observation", None, "body", Some(0.5), None, vec![])
11642            .await
11643            .unwrap();
11644
11645        let bogus_id = Uuid::new_v4();
11646
11647        let mut hits = vec![
11648            neighbor_hit(entity.id),
11649            neighbor_hit(note.id),
11650            neighbor_hit(bogus_id),
11651        ];
11652
11653        rt.enrich_neighbor_hits(&tok, &mut hits).await;
11654
11655        assert_eq!(hits[0].name.as_deref(), Some("MyEntity"));
11656        assert_eq!(hits[0].kind.as_deref(), Some("concept"));
11657
11658        assert_eq!(hits[1].name.as_deref(), Some("[observation]"));
11659        assert_eq!(hits[1].kind.as_deref(), Some("observation"));
11660
11661        assert!(hits[2].name.is_none());
11662        assert!(hits[2].kind.is_none());
11663    }
11664
11665    /// enrich_neighbor_hits: note with a non-empty name uses the actual name.
11666    #[tokio::test]
11667    async fn enrich_neighbor_hits_note_with_name_uses_name() {
11668        let rt = rt();
11669        let tok = NamespaceToken::local();
11670
11671        let note = rt
11672            .create_note(
11673                &tok,
11674                "insight",
11675                Some("NoteTitle"),
11676                "body",
11677                Some(0.5),
11678                None,
11679                vec![],
11680            )
11681            .await
11682            .unwrap();
11683
11684        let mut hits = vec![neighbor_hit(note.id)];
11685        rt.enrich_neighbor_hits(&tok, &mut hits).await;
11686
11687        assert_eq!(hits[0].name.as_deref(), Some("NoteTitle"));
11688        assert_eq!(hits[0].kind.as_deref(), Some("insight"));
11689    }
11690
11691    /// enrich_path_nodes: two paths sharing a repeated node_id; each node
11692    /// enriched from a single batch; unresolved node stays None.
11693    #[tokio::test]
11694    async fn enrich_path_nodes_batch_dedup_and_unresolved() {
11695        let rt = rt();
11696        let tok = NamespaceToken::local();
11697
11698        let ea = rt
11699            .create_entity(&tok, "concept", None, "Alpha", None, None, vec![])
11700            .await
11701            .unwrap();
11702        let eb = rt
11703            .create_entity(&tok, "document", None, "Beta", None, None, vec![])
11704            .await
11705            .unwrap();
11706        let bogus_id = Uuid::new_v4();
11707
11708        // Path 1: ea → eb → bogus  |  Path 2: eb → ea  (shared nodes, reversed)
11709        let mut paths = vec![
11710            GraphPath {
11711                root_id: ea.id,
11712                nodes: vec![
11713                    path_node(ea.id, 0),
11714                    path_node(eb.id, 1),
11715                    path_node(bogus_id, 2),
11716                ],
11717                total_weight: 1.0,
11718            },
11719            GraphPath {
11720                root_id: eb.id,
11721                nodes: vec![path_node(eb.id, 0), path_node(ea.id, 1)],
11722                total_weight: 1.0,
11723            },
11724        ];
11725
11726        rt.enrich_path_nodes(&tok, &mut paths, false).await;
11727
11728        assert_eq!(paths[0].nodes[0].name.as_deref(), Some("Alpha"));
11729        assert_eq!(paths[0].nodes[0].kind.as_deref(), Some("concept"));
11730        assert_eq!(paths[0].nodes[1].name.as_deref(), Some("Beta"));
11731        assert_eq!(paths[0].nodes[1].kind.as_deref(), Some("document"));
11732        assert!(paths[0].nodes[2].name.is_none());
11733        assert!(paths[0].nodes[2].kind.is_none());
11734
11735        // Shared nodes resolve from the same HashMap — order within each path is preserved.
11736        assert_eq!(paths[1].nodes[0].name.as_deref(), Some("Beta"));
11737        assert_eq!(paths[1].nodes[0].kind.as_deref(), Some("document"));
11738        assert_eq!(paths[1].nodes[1].name.as_deref(), Some("Alpha"));
11739        assert_eq!(paths[1].nodes[1].kind.as_deref(), Some("concept"));
11740    }
11741
11742    /// enrich_neighbor_hits and enrich_path_nodes must resolve entities whose
11743    /// namespace is in the token's extra-visible set (not only the primary).
11744    ///
11745    /// Regression: the old `get_entities_by_ids`
11746    /// call left `filter.namespaces` unset, which collapses to
11747    /// `namespace = primary` in `build_entity_where`.  Graph expansion already
11748    /// crosses visible namespaces, so enrichment must match that scope.
11749    #[tokio::test]
11750    async fn enrich_resolves_entities_in_extra_visible_namespace() {
11751        let rt = KhiveRuntime::memory().unwrap();
11752
11753        let ns_a = Namespace::parse("enrich-ns-a").unwrap();
11754        let ns_b = Namespace::parse("enrich-ns-b").unwrap();
11755
11756        let tok_b = rt.authorize(ns_b.clone()).unwrap();
11757
11758        // Entity lives in ns-b.
11759        let entity_b = rt
11760            .create_entity(&tok_b, "concept", None, "EntityInB", None, None, vec![])
11761            .await
11762            .unwrap();
11763        assert_eq!(entity_b.namespace, "enrich-ns-b");
11764
11765        // Token whose primary is ns-a but ns-b is in the visible set.
11766        let vis_tok = rt
11767            .authorize_with_visibility(ns_a.clone(), vec![ns_b.clone()])
11768            .unwrap();
11769
11770        // ── neighbor hits ──────────────────────────────────────────────────
11771        let mut hits = vec![neighbor_hit(entity_b.id)];
11772        rt.enrich_neighbor_hits(&vis_tok, &mut hits).await;
11773
11774        assert_eq!(
11775            hits[0].name.as_deref(),
11776            Some("EntityInB"),
11777            "entity in extra-visible ns must be enriched by enrich_neighbor_hits"
11778        );
11779        assert_eq!(hits[0].kind.as_deref(), Some("concept"));
11780
11781        // ── path nodes ─────────────────────────────────────────────────────
11782        let mut paths = vec![GraphPath {
11783            root_id: entity_b.id,
11784            nodes: vec![path_node(entity_b.id, 0)],
11785            total_weight: 1.0,
11786        }];
11787        rt.enrich_path_nodes(&vis_tok, &mut paths, false).await;
11788
11789        assert_eq!(
11790            paths[0].nodes[0].name.as_deref(),
11791            Some("EntityInB"),
11792            "entity in extra-visible ns must be enriched by enrich_path_nodes"
11793        );
11794        assert_eq!(paths[0].nodes[0].kind.as_deref(), Some("concept"));
11795    }
11796
11797    /// enrich_neighbor_hits populates entity_type from the already-fetched entity
11798    /// batch when the entity has a non-null entity_type.  Entities without one and
11799    /// note nodes leave entity_type as None.
11800    #[tokio::test]
11801    async fn enrich_neighbor_hits_populates_entity_type() {
11802        let rt = rt();
11803        let tok = NamespaceToken::local();
11804
11805        let props = serde_json::json!({"domain": "attention"});
11806        let entity = rt
11807            .create_entity(
11808                &tok,
11809                "concept",
11810                Some("algorithm"),
11811                "FlashAttn",
11812                None,
11813                Some(props),
11814                vec![],
11815            )
11816            .await
11817            .unwrap();
11818
11819        let entity_no_type = rt
11820            .create_entity(&tok, "concept", None, "PlainConcept", None, None, vec![])
11821            .await
11822            .unwrap();
11823
11824        let mut hits = vec![neighbor_hit(entity.id), neighbor_hit(entity_no_type.id)];
11825        rt.enrich_neighbor_hits(&tok, &mut hits).await;
11826
11827        assert_eq!(hits[0].entity_type.as_deref(), Some("algorithm"));
11828        assert!(
11829            hits[1].entity_type.is_none(),
11830            "entity without entity_type must leave the field as None"
11831        );
11832    }
11833
11834    /// enrich_path_nodes populates properties from the already-fetched entity
11835    /// batch when the entity has a non-null properties blob.  Entities without
11836    /// properties leave the field as None.
11837    #[tokio::test]
11838    async fn enrich_path_nodes_populates_properties() {
11839        let rt = rt();
11840        let tok = NamespaceToken::local();
11841
11842        let props = serde_json::json!({"year": 2024, "venue": "NeurIPS"});
11843        let entity_with_props = rt
11844            .create_entity(
11845                &tok,
11846                "document",
11847                None,
11848                "AttentionPaper",
11849                None,
11850                Some(props.clone()),
11851                vec![],
11852            )
11853            .await
11854            .unwrap();
11855
11856        let entity_no_props = rt
11857            .create_entity(&tok, "concept", None, "BareConceptNode", None, None, vec![])
11858            .await
11859            .unwrap();
11860
11861        let mut paths = vec![GraphPath {
11862            root_id: entity_with_props.id,
11863            nodes: vec![
11864                path_node(entity_with_props.id, 0),
11865                path_node(entity_no_props.id, 1),
11866            ],
11867            total_weight: 1.0,
11868        }];
11869
11870        rt.enrich_path_nodes(&tok, &mut paths, true).await;
11871
11872        assert_eq!(
11873            paths[0].nodes[0].properties.as_ref(),
11874            Some(&props),
11875            "properties must be filled when entity has a non-null properties blob"
11876        );
11877        assert!(
11878            paths[0].nodes[1].properties.is_none(),
11879            "entity without properties must leave the field as None"
11880        );
11881    }
11882
11883    /// Regression: GraphStore::traverse must not fail with "too many SQL variables"
11884    /// or "too many terms in compound SELECT" when the root set exceeds the chunk
11885    /// boundary (400 roots per CTE VALUES clause after the fix).
11886    ///
11887    /// Graph: 1 000 roots, each with one distinct outgoing edge to a unique child.
11888    /// The graph store's `traverse` is exercised directly (bypassing the runtime-level
11889    /// entity-existence filter) to keep the test fast and targeted.
11890    ///
11891    /// Correctness: every root must appear in the result with exactly one reachable node.
11892    #[tokio::test]
11893    async fn traverse_chunks_root_binds_over_host_param_limit() {
11894        use khive_storage::types::TraversalOptions;
11895
11896        let rt = rt();
11897        let tok = NamespaceToken::local();
11898        let graph = rt.graph(&tok).unwrap();
11899
11900        const N: usize = 1_000;
11901        let now = chrono::Utc::now();
11902
11903        let mut roots: Vec<uuid::Uuid> = Vec::with_capacity(N);
11904        let mut expected_children: std::collections::HashMap<uuid::Uuid, uuid::Uuid> =
11905            std::collections::HashMap::with_capacity(N);
11906
11907        for _ in 0..N {
11908            let root = uuid::Uuid::new_v4();
11909            let child = uuid::Uuid::new_v4();
11910            graph
11911                .upsert_edge(Edge {
11912                    id: LinkId::from(uuid::Uuid::new_v4()),
11913                    namespace: "local".to_string(),
11914                    source_id: root,
11915                    target_id: child,
11916                    relation: EdgeRelation::Extends,
11917                    weight: 1.0,
11918                    created_at: now,
11919                    updated_at: now,
11920                    deleted_at: None,
11921                    metadata: None,
11922                    target_backend: None,
11923                })
11924                .await
11925                .unwrap();
11926            roots.push(root);
11927            expected_children.insert(root, child);
11928        }
11929
11930        // Must return Ok: no "too many SQL variables" or "too many terms in compound SELECT".
11931        let paths = graph
11932            .traverse(TraversalRequest {
11933                roots: roots.clone(),
11934                options: TraversalOptions {
11935                    max_depth: 1,
11936                    direction: Direction::Out,
11937                    relations: None,
11938                    min_weight: None,
11939                    limit: None,
11940                },
11941                include_roots: false,
11942                include_properties: false,
11943            })
11944            .await
11945            .unwrap();
11946
11947        assert_eq!(
11948            paths.len(),
11949            N,
11950            "traverse over {N} roots must return one GraphPath per root"
11951        );
11952
11953        for path in &paths {
11954            let expected_child = expected_children[&path.root_id];
11955            assert_eq!(
11956                path.nodes.len(),
11957                1,
11958                "root {:?} must reach exactly 1 node",
11959                path.root_id
11960            );
11961            assert_eq!(
11962                path.nodes[0].node_id, expected_child,
11963                "root {:?} must reach its direct child",
11964                path.root_id
11965            );
11966        }
11967    }
11968
11969    // ── Additive EDGE_RULES composition: pack EntityOfType rules must not shadow
11970    // the base EntityOfKind contract for the same relation. ──────────────────────
11971    //
11972    // When a pack contributes EntityOfType rules for a relation (e.g. variant_of:
11973    // goal -> theorem and goal -> definition), the base contract's EntityOfKind
11974    // rule for the same relation (concept -> concept) must still fire for entities
11975    // whose base kind is "concept" but whose EntityOfType pair is not in any pack rule.
11976    //
11977    // Specifically: a goal entity resolves to base kind "concept". A goal -> goal
11978    // variant_of edge has no matching pack rule (goal -> goal is not declared), so
11979    // pack_rule_allows returns false. The validator then extracts the base kind
11980    // ("concept") and checks base_entity_rule_allows, which returns true.
11981    // The edge is therefore allowed: additive composition holds.
11982
11983    #[test]
11984    fn pack_entity_of_type_rules_do_not_shadow_base_entity_of_kind_rule() {
11985        // Formal-style EntityOfType rules for variant_of: goal -> theorem, goal -> definition.
11986        // goal -> goal is deliberately absent — that case must fall through to the base rule.
11987        let pack_rules: Vec<EdgeEndpointRule> = vec![
11988            EdgeEndpointRule {
11989                relation: EdgeRelation::VariantOf,
11990                source: EndpointKind::EntityOfType {
11991                    kind: "concept",
11992                    entity_type: "goal",
11993                },
11994                target: EndpointKind::EntityOfType {
11995                    kind: "concept",
11996                    entity_type: "theorem",
11997                },
11998            },
11999            EdgeEndpointRule {
12000                relation: EdgeRelation::VariantOf,
12001                source: EndpointKind::EntityOfType {
12002                    kind: "concept",
12003                    entity_type: "goal",
12004                },
12005                target: EndpointKind::EntityOfType {
12006                    kind: "concept",
12007                    entity_type: "definition",
12008                },
12009            },
12010        ];
12011
12012        let goal_a =
12013            Resolved::Entity(Entity::new("local", "concept", "G-a").with_entity_type(Some("goal")));
12014        let goal_b =
12015            Resolved::Entity(Entity::new("local", "concept", "G-b").with_entity_type(Some("goal")));
12016
12017        // Pack rules do not cover goal -> goal, so pack_rule_allows must return false.
12018        assert!(
12019            !pack_rule_allows(
12020                &pack_rules,
12021                EdgeRelation::VariantOf,
12022                Some(&goal_a),
12023                Some(&goal_b)
12024            ),
12025            "pack rules must not cover goal->goal variant_of (no such rule declared)"
12026        );
12027
12028        // The base contract allows concept -> concept for variant_of.
12029        // A goal entity's base kind is "concept", so this must return true.
12030        assert!(
12031            base_entity_rule_allows("concept", EdgeRelation::VariantOf, "concept"),
12032            "base contract must allow concept->concept variant_of regardless of pack EntityOfType rules"
12033        );
12034    }
12035
12036    // Integration path: pack EntityOfType rules installed on the runtime must not
12037    // block a goal->goal variant_of link that the base contract already permits.
12038    // Exercises validate_edge_relation_endpoints lines 1173-1223:
12039    //   pack miss -> extract e.kind ("concept") -> base_entity_rule_allows -> Ok.
12040    #[tokio::test]
12041    async fn link_variant_of_goal_to_goal_allowed_when_pack_has_entity_of_type_rules() {
12042        let rt = rt();
12043        let tok = NamespaceToken::local();
12044
12045        // Install formal-style EntityOfType rules for variant_of (goal -> theorem/definition).
12046        // goal -> goal is absent so the base concept->concept rule must carry this case.
12047        rt.install_edge_rules(vec![
12048            EdgeEndpointRule {
12049                relation: EdgeRelation::VariantOf,
12050                source: EndpointKind::EntityOfType {
12051                    kind: "concept",
12052                    entity_type: "goal",
12053                },
12054                target: EndpointKind::EntityOfType {
12055                    kind: "concept",
12056                    entity_type: "theorem",
12057                },
12058            },
12059            EdgeEndpointRule {
12060                relation: EdgeRelation::VariantOf,
12061                source: EndpointKind::EntityOfType {
12062                    kind: "concept",
12063                    entity_type: "goal",
12064                },
12065                target: EndpointKind::EntityOfType {
12066                    kind: "concept",
12067                    entity_type: "definition",
12068                },
12069            },
12070        ]);
12071
12072        let a = rt
12073            .create_entity(
12074                &tok,
12075                "concept",
12076                Some("goal"),
12077                "Goal Alpha",
12078                None,
12079                None,
12080                vec![],
12081            )
12082            .await
12083            .unwrap();
12084        let b = rt
12085            .create_entity(
12086                &tok,
12087                "concept",
12088                Some("goal"),
12089                "Goal Beta",
12090                None,
12091                None,
12092                vec![],
12093            )
12094            .await
12095            .unwrap();
12096
12097        // Neither endpoint matches any pack rule (no goal->goal rule).
12098        // The base concept->concept rule must fire and allow the edge.
12099        let result = rt
12100            .link(&tok, a.id, b.id, EdgeRelation::VariantOf, 1.0, None)
12101            .await;
12102        assert!(
12103            result.is_ok(),
12104            "goal->goal variant_of must be allowed via the base concept->concept rule \
12105             even when EntityOfType rules for variant_of are installed; got {result:?}"
12106        );
12107
12108        // Fail-closed check: additive rules must not make validation fail-open.
12109        // A goal(concept) -> project variant_of edge has no matching pack rule
12110        // (project is not in the installed variant_of rules) and no matching base
12111        // rule (no (concept, VariantOf, project) row). It must be rejected.
12112        let p = rt
12113            .create_entity(&tok, "project", None, "Proj", None, None, vec![])
12114            .await
12115            .unwrap();
12116        let bad = rt
12117            .link(&tok, a.id, p.id, EdgeRelation::VariantOf, 1.0, None)
12118            .await;
12119        assert!(
12120            bad.is_err(),
12121            "additive pack rules must not make validation fail-open; \
12122             goal(concept)->project variant_of must be rejected (pack miss + base miss); \
12123             got {bad:?}"
12124        );
12125    }
12126
12127    // Load-bearing positive: a pack EntityOfType rule adds an endpoint the base contract
12128    // does not cover. The base contract has no (concept, DependsOn, concept) row (the
12129    // DependsOn rows are project/service/artifact only). A theorem->definition DependsOn
12130    // edge can therefore ONLY pass through the pack rule, proving the union is load-bearing.
12131    #[tokio::test]
12132    async fn link_depends_on_theorem_to_definition_allowed_only_via_pack_rule() {
12133        let rt = rt();
12134        let tok = NamespaceToken::local();
12135
12136        // Confirm the base contract does NOT allow concept->concept DependsOn.
12137        // (Documented here so the assertion below is not a tautology.)
12138        assert!(
12139            !base_entity_rule_allows("concept", EdgeRelation::DependsOn, "concept"),
12140            "base contract must not allow concept->concept DependsOn; \
12141             test would be vacuous if this precondition fails"
12142        );
12143
12144        // Install a single EntityOfType rule: theorem depends_on definition.
12145        // With no rules, the link would be rejected by the base contract.
12146        // With this rule, it must be accepted via the pack path (lines 1173-1179).
12147        rt.install_edge_rules(vec![EdgeEndpointRule {
12148            relation: EdgeRelation::DependsOn,
12149            source: EndpointKind::EntityOfType {
12150                kind: "concept",
12151                entity_type: "theorem",
12152            },
12153            target: EndpointKind::EntityOfType {
12154                kind: "concept",
12155                entity_type: "definition",
12156            },
12157        }]);
12158
12159        let thm = rt
12160            .create_entity(&tok, "concept", Some("theorem"), "T1", None, None, vec![])
12161            .await
12162            .unwrap();
12163        let def = rt
12164            .create_entity(
12165                &tok,
12166                "concept",
12167                Some("definition"),
12168                "D1",
12169                None,
12170                None,
12171                vec![],
12172            )
12173            .await
12174            .unwrap();
12175
12176        // This can only pass through the pack rule — the base contract rejects it.
12177        let result = rt
12178            .link(&tok, thm.id, def.id, EdgeRelation::DependsOn, 1.0, None)
12179            .await;
12180        assert!(
12181            result.is_ok(),
12182            "theorem->definition DependsOn must be allowed by the installed pack rule; \
12183             the base contract has no concept->concept DependsOn row; got {result:?}"
12184        );
12185    }
12186
12187    // ── Provenance endpoint pairs ────────────────────────────────────────────
12188    // Four base endpoint pairs: document->person and document->org (document
12189    // authorship), concept->org (concept origination by an org), and
12190    // document->document (normative document dependency). Positive links for
12191    // each pair, plus a direction-matters negative guard.
12192
12193    #[tokio::test]
12194    async fn link_document_introduced_by_person_allowed() {
12195        let rt = rt();
12196        let tok = NamespaceToken::local();
12197
12198        let doc = rt
12199            .create_entity(&tok, "document", None, "Paper", None, None, vec![])
12200            .await
12201            .unwrap();
12202        let author = rt
12203            .create_entity(&tok, "person", None, "Author", None, None, vec![])
12204            .await
12205            .unwrap();
12206
12207        let result = rt
12208            .link(
12209                &tok,
12210                doc.id,
12211                author.id,
12212                EdgeRelation::IntroducedBy,
12213                1.0,
12214                None,
12215            )
12216            .await;
12217        assert!(
12218            result.is_ok(),
12219            "document->person introduced_by must be allowed by the ADR-002 \
12220             endpoint amendment; got {result:?}"
12221        );
12222    }
12223
12224    #[tokio::test]
12225    async fn link_document_introduced_by_org_allowed() {
12226        let rt = rt();
12227        let tok = NamespaceToken::local();
12228
12229        let doc = rt
12230            .create_entity(&tok, "document", None, "Whitepaper", None, None, vec![])
12231            .await
12232            .unwrap();
12233        let org = rt
12234            .create_entity(&tok, "org", None, "Publisher", None, None, vec![])
12235            .await
12236            .unwrap();
12237
12238        let result = rt
12239            .link(&tok, doc.id, org.id, EdgeRelation::IntroducedBy, 1.0, None)
12240            .await;
12241        assert!(
12242            result.is_ok(),
12243            "document->org introduced_by must be allowed by the ADR-002 \
12244             endpoint amendment; got {result:?}"
12245        );
12246    }
12247
12248    #[tokio::test]
12249    async fn link_concept_introduced_by_org_allowed() {
12250        let rt = rt();
12251        let tok = NamespaceToken::local();
12252
12253        let concept = rt
12254            .create_entity(&tok, "concept", None, "Architecture", None, None, vec![])
12255            .await
12256            .unwrap();
12257        let org = rt
12258            .create_entity(&tok, "org", None, "Originator", None, None, vec![])
12259            .await
12260            .unwrap();
12261
12262        let result = rt
12263            .link(
12264                &tok,
12265                concept.id,
12266                org.id,
12267                EdgeRelation::IntroducedBy,
12268                1.0,
12269                None,
12270            )
12271            .await;
12272        assert!(
12273            result.is_ok(),
12274            "concept->org introduced_by must be allowed by the ADR-002 \
12275             endpoint amendment; got {result:?}"
12276        );
12277    }
12278
12279    #[tokio::test]
12280    async fn link_document_depends_on_document_allowed() {
12281        let rt = rt();
12282        let tok = NamespaceToken::local();
12283
12284        let doc_a = rt
12285            .create_entity(&tok, "document", None, "Spec A", None, None, vec![])
12286            .await
12287            .unwrap();
12288        let doc_b = rt
12289            .create_entity(&tok, "document", None, "Spec B", None, None, vec![])
12290            .await
12291            .unwrap();
12292
12293        let result = rt
12294            .link(&tok, doc_a.id, doc_b.id, EdgeRelation::DependsOn, 1.0, None)
12295            .await;
12296        assert!(
12297            result.is_ok(),
12298            "document->document depends_on must be allowed by the ADR-002 \
12299             endpoint amendment; got {result:?}"
12300        );
12301        let edge = result.unwrap();
12302        let dk = edge
12303            .metadata
12304            .as_ref()
12305            .and_then(|m| m.get("dependency_kind"))
12306            .and_then(|v| v.as_str());
12307        assert_eq!(
12308            dk,
12309            Some("normative"),
12310            "document->document depends_on must infer dependency_kind=normative"
12311        );
12312    }
12313
12314    #[tokio::test]
12315    async fn link_org_introduced_by_document_rejected_direction_matters() {
12316        let rt = rt();
12317        let tok = NamespaceToken::local();
12318
12319        let org = rt
12320            .create_entity(&tok, "org", None, "Publisher", None, None, vec![])
12321            .await
12322            .unwrap();
12323        let doc = rt
12324            .create_entity(&tok, "document", None, "Paper", None, None, vec![])
12325            .await
12326            .unwrap();
12327
12328        // The amendment adds document->org, not org->document. Direction matters:
12329        // an org is not "introduced by" a document it published.
12330        let result = rt
12331            .link(&tok, org.id, doc.id, EdgeRelation::IntroducedBy, 1.0, None)
12332            .await;
12333        assert!(
12334            result.is_err(),
12335            "org->document introduced_by must remain rejected; only \
12336             document->org is permitted, not the reverse; got {result:?}"
12337        );
12338    }
12339
12340    // ── create_note_with_embedding_content ──────────────────────────────────
12341
12342    /// Like `ConstVecService`/`ConstVecProvider` above, but records every text
12343    /// it is asked to embed so a test can assert exactly what reached the
12344    /// "provider" — used to verify the effective embed text is the capped
12345    /// override, not the full note content.
12346    struct CapturingVecService {
12347        dims: usize,
12348        captured: Arc<std::sync::Mutex<Vec<String>>>,
12349    }
12350
12351    #[async_trait]
12352    impl EmbeddingService for CapturingVecService {
12353        async fn embed(
12354            &self,
12355            texts: &[String],
12356            _model: EmbeddingModel,
12357        ) -> std::result::Result<Vec<Vec<f32>>, EmbedError> {
12358            self.captured.lock().unwrap().extend(texts.iter().cloned());
12359            Ok(texts.iter().map(|_| vec![1.0_f32; self.dims]).collect())
12360        }
12361
12362        fn supports_model(&self, _model: EmbeddingModel) -> bool {
12363            true
12364        }
12365
12366        fn name(&self) -> &'static str {
12367            "capturing-vec"
12368        }
12369    }
12370
12371    struct CapturingVecProvider {
12372        provider_name: String,
12373        dims: usize,
12374        captured: Arc<std::sync::Mutex<Vec<String>>>,
12375    }
12376
12377    #[async_trait]
12378    impl EmbedderProvider for CapturingVecProvider {
12379        fn name(&self) -> &str {
12380            &self.provider_name
12381        }
12382
12383        fn dimensions(&self) -> usize {
12384            self.dims
12385        }
12386
12387        async fn build(&self) -> crate::error::RuntimeResult<Arc<dyn EmbeddingService>> {
12388            Ok(Arc::new(CapturingVecService {
12389                dims: self.dims,
12390                captured: Arc::clone(&self.captured),
12391            }))
12392        }
12393    }
12394
12395    #[tokio::test]
12396    async fn create_note_with_embedding_content_none_matches_create_note() {
12397        let rt = rt();
12398        let tok = NamespaceToken::local();
12399        let captured = Arc::new(std::sync::Mutex::new(Vec::new()));
12400        rt.register_embedder(CapturingVecProvider {
12401            provider_name: "capturing-vec".into(),
12402            dims: 4,
12403            captured: Arc::clone(&captured),
12404        });
12405
12406        let note = rt
12407            .create_note_with_embedding_content(
12408                &tok,
12409                "observation",
12410                None,
12411                "full content, no override",
12412                None,
12413                None,
12414                None,
12415                vec![],
12416            )
12417            .await
12418            .expect("create with None override must behave like create_note");
12419        assert_eq!(note.content, "full content, no override");
12420        let seen = captured.lock().unwrap().clone();
12421        assert_eq!(
12422            seen,
12423            vec!["full content, no override".to_string()],
12424            "with no override the embedder must see the full content"
12425        );
12426    }
12427
12428    #[tokio::test]
12429    async fn create_note_with_embedding_content_embeds_capped_override_and_stores_full_content() {
12430        let rt = rt();
12431        let tok = NamespaceToken::local();
12432        let captured = Arc::new(std::sync::Mutex::new(Vec::new()));
12433        rt.register_embedder(CapturingVecProvider {
12434            provider_name: "capturing-vec".into(),
12435            dims: 4,
12436            captured: Arc::clone(&captured),
12437        });
12438
12439        let full = "head-term and then a very long tail-term that exceeds any cap";
12440        let head = &full[.."head-term and then a very long".len()];
12441
12442        let note = rt
12443            .create_note_with_embedding_content(
12444                &tok,
12445                "observation",
12446                None,
12447                full,
12448                Some(head),
12449                None,
12450                None,
12451                vec![],
12452            )
12453            .await
12454            .expect("proper-prefix override must be accepted");
12455        assert_eq!(note.content, full, "stored content must be the full text");
12456
12457        let seen = captured.lock().unwrap().clone();
12458        assert_eq!(
12459            seen,
12460            vec![head.to_string()],
12461            "embedder must see only the capped override"
12462        );
12463    }
12464
12465    #[tokio::test]
12466    async fn create_note_with_embedding_content_fans_out_identical_override_to_multiple_models() {
12467        let rt = rt();
12468        let tok = NamespaceToken::local();
12469        let captured_a = Arc::new(std::sync::Mutex::new(Vec::new()));
12470        let captured_b = Arc::new(std::sync::Mutex::new(Vec::new()));
12471        rt.register_embedder(CapturingVecProvider {
12472            provider_name: "capturing-vec-a".into(),
12473            dims: 4,
12474            captured: Arc::clone(&captured_a),
12475        });
12476        rt.register_embedder(CapturingVecProvider {
12477            provider_name: "capturing-vec-b".into(),
12478            dims: 4,
12479            captured: Arc::clone(&captured_b),
12480        });
12481
12482        let full = "head-only-embedded plus a long discarded tail";
12483        let head = &full[.."head-only-embedded".len()];
12484
12485        rt.create_note_with_embedding_content(
12486            &tok,
12487            "observation",
12488            None,
12489            full,
12490            Some(head),
12491            None,
12492            None,
12493            vec![],
12494        )
12495        .await
12496        .expect("create ok");
12497
12498        assert_eq!(
12499            captured_a.lock().unwrap().clone(),
12500            vec![head.to_string()],
12501            "model A must receive the identical capped override"
12502        );
12503        assert_eq!(
12504            captured_b.lock().unwrap().clone(),
12505            vec![head.to_string()],
12506            "model B must receive the identical capped override"
12507        );
12508
12509        // Both models actually persisted a vector row for the note (not just
12510        // an embed call that was discarded before insertion).
12511        let vs_a = rt
12512            .vectors_for_model(&tok, "capturing-vec-a")
12513            .expect("vector store for model A");
12514        assert_eq!(
12515            vs_a.count().await.expect("vector count A"),
12516            1,
12517            "model A must have exactly one vector row for the note"
12518        );
12519        let vs_b = rt
12520            .vectors_for_model(&tok, "capturing-vec-b")
12521            .expect("vector store for model B");
12522        assert_eq!(
12523            vs_b.count().await.expect("vector count B"),
12524            1,
12525            "model B must have exactly one vector row for the note"
12526        );
12527    }
12528
12529    #[tokio::test]
12530    async fn create_note_with_embedding_content_rejects_empty_override() {
12531        let rt = rt();
12532        let tok = NamespaceToken::local();
12533
12534        let err = rt
12535            .create_note_with_embedding_content(
12536                &tok,
12537                "observation",
12538                None,
12539                "some content",
12540                Some(""),
12541                None,
12542                None,
12543                vec![],
12544            )
12545            .await
12546            .expect_err("empty override must be rejected");
12547        assert!(matches!(err, RuntimeError::InvalidInput(_)));
12548    }
12549
12550    #[tokio::test]
12551    async fn create_note_with_embedding_content_rejects_non_prefix_override() {
12552        let rt = rt();
12553        let tok = NamespaceToken::local();
12554
12555        let err = rt
12556            .create_note_with_embedding_content(
12557                &tok,
12558                "observation",
12559                None,
12560                "the actual content",
12561                Some("an unrelated string"),
12562                None,
12563                None,
12564                vec![],
12565            )
12566            .await
12567            .expect_err("non-prefix override must be rejected");
12568        assert!(matches!(err, RuntimeError::InvalidInput(ref m) if m.contains("prefix")));
12569    }
12570
12571    #[tokio::test]
12572    async fn create_note_with_embedding_content_rejects_equal_length_override() {
12573        let rt = rt();
12574        let tok = NamespaceToken::local();
12575
12576        // Same length and same text as `content` is not a *proper* prefix.
12577        let err = rt
12578            .create_note_with_embedding_content(
12579                &tok,
12580                "observation",
12581                None,
12582                "identical text",
12583                Some("identical text"),
12584                None,
12585                None,
12586                vec![],
12587            )
12588            .await
12589            .expect_err("an equal-length override must be rejected as not a proper prefix");
12590        assert!(matches!(err, RuntimeError::InvalidInput(ref m) if m.contains("prefix")));
12591    }
12592
12593    #[tokio::test]
12594    async fn create_note_with_embedding_content_rejects_secret_bearing_override() {
12595        let rt = rt();
12596        let tok = NamespaceToken::local();
12597
12598        let token_span = "ghp_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
12599        let content = format!("{token_span} plus extra trailing content beyond the override");
12600        let embedding_content = format!("{token_span} plus extra");
12601
12602        let err = rt
12603            .create_note_with_embedding_content(
12604                &tok,
12605                "observation",
12606                None,
12607                &content,
12608                Some(&embedding_content),
12609                None,
12610                None,
12611                vec![],
12612            )
12613            .await
12614            .expect_err("a credential-shaped override must fail the secret gate");
12615        assert!(
12616            matches!(err, RuntimeError::SecretDetected(_)),
12617            "expected SecretDetected, got {err:?}"
12618        );
12619
12620        // Fail-closed: no note survives the rejected create.
12621        let count = rt
12622            .notes(&tok)
12623            .unwrap()
12624            .count_notes(tok.namespace().as_str(), None)
12625            .await
12626            .unwrap();
12627        assert_eq!(count, 0, "a rejected create must leave no note behind");
12628    }
12629}