Skip to main content

khive_runtime/
operations.rs

1// See docs/operations.md#why-this-file-is-not-split-into-submodules.
2//! High-level operations composing storage capabilities into user-facing verbs.
3//!
4//! Fault-injection arming uses scoped guards only — see docs/operations.md#fault-injection-arm-migration.
5
6use std::collections::HashMap;
7use std::str::FromStr;
8
9use chrono::Utc;
10use serde::Serialize;
11use uuid::Uuid;
12
13use khive_score::DeterministicScore;
14use khive_storage::note::Note;
15use khive_storage::types::{
16    DeleteMode, DirectedNeighborHit, Direction, EdgeSortField, EdgeUpsertDisposition,
17    EdgeUpsertRefusal, EdgeUpsertRequest, EdgeUpsertResult, GraphPath, GuardedEdgeUpsertOutcome,
18    LinkId, NeighborCursor, NeighborHit, NeighborQuery, Page, PageRequest, SeekCursor, SortOrder,
19    SqlRow, SqlStatement, SqlValue, TextFilter, TextQueryMode, TextSearchRequest, TraversalRequest,
20};
21use khive_storage::{
22    Attachment, AttachmentSubstrate, Edge, EdgeRelation, Entity, EntityFilter, Event, EventFilter,
23    NewAttachment,
24};
25use khive_types::{EdgeEndpointRule, EndpointKind, EventKind, KhiveError, SubstrateKind};
26
27use khive_db::stores::entity::{entity_hard_delete_statement, entity_upsert_statement};
28use khive_db::stores::event::hard_delete_lineage_warning_statements;
29use khive_db::stores::graph::{edge_hard_delete_statement, purge_incident_edges_statement};
30use khive_db::stores::note::note_hard_delete_statement;
31use khive_db::stores::text::insert_document_statements;
32use khive_db::{pool::RuntimeWriteOperation, SqliteError};
33use rusqlite::OptionalExtension;
34
35/// The restore unit committed the row and its text index; only the
36/// post-commit embedding rebuild failed. Name that, so the caller does not
37/// read an ordinary restore failure over a record that is already live.
38fn restore_reindex_failed(kind: &str, id: Uuid, error: RuntimeError) -> RuntimeError {
39    RuntimeError::Internal(format!(
40        "{kind} {id} is restored and text-indexed, but its embedding rebuild failed \
41         and will be retried by the next reindex: {error}"
42    ))
43}
44
45fn merge_tombstone_restore_refused(id: Uuid, kept_id: impl std::fmt::Display) -> RuntimeError {
46    KhiveError::conflict(format!(
47        "merge_tombstone: {id} was merged into {kept_id}; a merge tombstone is not restorable, query the kept id"
48    ))
49    .with_details(khive_types::Details::new_owned([
50        ("reason", "merge_tombstone".into()),
51        ("merged_into", kept_id.to_string()),
52    ]))
53    .into()
54}
55
56fn live_merged_entity_refused(id: Uuid, kept_id: impl std::fmt::Display) -> RuntimeError {
57    KhiveError::conflict(format!(
58        "live_merged_entity: {id} is live but still carries merged_into {kept_id}; a row an \
59         earlier restore left live over its merge is not restorable, re-tombstone it or query the \
60         kept id"
61    ))
62    .with_details(khive_types::Details::new_owned([
63        ("reason", "live_merged_entity".into()),
64        ("merged_into", kept_id.to_string()),
65    ]))
66    .into()
67}
68
69fn restore_key_conflict(key: &str, holder: &Note) -> RuntimeError {
70    KhiveError::conflict(format!(
71        "restore_key_conflict: key {key:?} is already held by live note {}",
72        holder.id
73    ))
74    .with_details(khive_types::Details::new_owned([
75        ("reason", "restore_key_conflict".into()),
76        ("key", key.to_owned()),
77        ("existing_id", holder.id.to_string()),
78    ]))
79    .into()
80}
81
82use crate::atomic_plan::{
83    AddEntityPlan, AffectedRowGuard, DeletePlan, PlanStatement, PostCommitEffect, UpdatePlan,
84};
85use crate::atomic_runner::{run_atomic_unit, AtomicOpFailure, AtomicOpPlan, AtomicRunOutcome};
86use crate::curation::{entity_fts_document, note_embedding_text_ref, note_fts_document};
87use crate::error::{GuardedWriteFailure, RuntimeError, RuntimeResult};
88use crate::runtime::{KhiveRuntime, NamespaceToken};
89
90// Test-only fault-injection state; see docs/operations.md#fault-injection-static-state.
91#[cfg(test)]
92std::thread_local! {
93    static LINK_FAIL_AFTER: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
94}
95
96#[cfg(any(test, feature = "fault-injection"))]
97std::thread_local! {
98    static VECTOR_FAIL_AFTER: std::cell::Cell<Option<usize>> =
99        const { std::cell::Cell::new(None) };
100}
101
102/// Arm the count-targetable vector-INSERT fault: let `n` inserts succeed, then fail
103/// the next one. See docs/operations.md#fault-injection-static-state.
104#[cfg(any(test, feature = "fault-injection"))]
105pub fn arm_vector_fail_after(n: usize) {
106    VECTOR_FAIL_AFTER.with(|cell| cell.set(Some(n)));
107}
108
109// Namespace-keyed one-shot arm sets — see docs/operations.md#fault-injection-static-state
110// (rationale for keying by namespace instead of a single Option<String> slot, #1095).
111#[cfg(any(test, feature = "fault-injection"))]
112type FaultArmSet = std::sync::Mutex<std::collections::HashMap<String, std::sync::Arc<()>>>;
113#[cfg(any(test, feature = "fault-injection"))]
114const MAX_FAULT_ARMS: usize = 64;
115#[cfg(any(test, feature = "fault-injection"))]
116static FTS_FAIL_NS: std::sync::LazyLock<FaultArmSet> =
117    std::sync::LazyLock::new(|| std::sync::Mutex::new(std::collections::HashMap::new()));
118#[cfg(any(test, feature = "fault-injection"))]
119static VECTOR_FAIL_NS: std::sync::LazyLock<FaultArmSet> =
120    std::sync::LazyLock::new(|| std::sync::Mutex::new(std::collections::HashMap::new()));
121/// Entity-create compensation failure injection; see docs/operations.md#fault-injection-static-state.
122#[cfg(any(test, feature = "fault-injection"))]
123static ENTITY_COMPENSATION_FAIL_NS: std::sync::LazyLock<FaultArmSet> =
124    std::sync::LazyLock::new(|| std::sync::Mutex::new(std::collections::HashMap::new()));
125/// `create_many` FTS failure injection, kept separate from `FTS_FAIL_NS` (#1263); see
126/// docs/operations.md#fault-injection-static-state.
127#[cfg(any(test, feature = "fault-injection"))]
128static FTS_FAIL_MANY_NS: std::sync::LazyLock<FaultArmSet> =
129    std::sync::LazyLock::new(|| std::sync::Mutex::new(std::collections::HashMap::new()));
130/// `create_many` FTS partial-failure injection (exercises the `summary.failed > 0` rollback
131/// branch); see docs/operations.md#fault-injection-static-state.
132#[cfg(any(test, feature = "fault-injection"))]
133static FTS_FAIL_MANY_PARTIAL_NS: std::sync::LazyLock<FaultArmSet> =
134    std::sync::LazyLock::new(|| std::sync::Mutex::new(std::collections::HashMap::new()));
135/// `resolve_prefix_inner` storage-failure injection, keyed by the scanned prefix string
136/// rather than a namespace (the `resolve_prefix_unfiltered*` entry points pass
137/// `namespaces: None` by contract, so there is no namespace to key on); see
138/// docs/operations.md#fault-injection-static-state.
139#[cfg(any(test, feature = "fault-injection"))]
140static PREFIX_RESOLVE_FAIL_NS: std::sync::LazyLock<FaultArmSet> =
141    std::sync::LazyLock::new(|| std::sync::Mutex::new(std::collections::HashMap::new()));
142
143/// Scoped ownership of a process-wide fault-injection arm.
144#[cfg(any(test, feature = "fault-injection"))]
145#[must_use = "the fault injection is disarmed when this guard is dropped"]
146pub struct FaultInjectionArm {
147    namespace: String,
148    token: std::sync::Arc<()>,
149    arms: &'static FaultArmSet,
150}
151
152#[cfg(any(test, feature = "fault-injection"))]
153impl Drop for FaultInjectionArm {
154    fn drop(&mut self) {
155        let mut arms = self.arms.lock().unwrap();
156        if arms
157            .get(&self.namespace)
158            .is_some_and(|token| std::sync::Arc::ptr_eq(token, &self.token))
159        {
160            arms.remove(&self.namespace);
161        }
162    }
163}
164
165#[cfg(any(test, feature = "fault-injection"))]
166fn arm_fault(arms: &'static FaultArmSet, namespace: &str, max_arms: usize) -> FaultInjectionArm {
167    let token = std::sync::Arc::new(());
168    let refusal = {
169        let mut active = arms.lock().unwrap();
170        if active.contains_key(namespace) {
171            Some("the namespace is already armed")
172        } else if active.len() >= max_arms {
173            Some("the arm set is at capacity")
174        } else {
175            active.insert(namespace.to_string(), std::sync::Arc::clone(&token));
176            None
177        }
178    };
179    if let Some(reason) = refusal {
180        panic!("cannot arm fault injection for namespace `{namespace}`: {reason}");
181    }
182    FaultInjectionArm {
183        namespace: namespace.to_string(),
184        token,
185        arms,
186    }
187}
188
189#[cfg(any(test, feature = "fault-injection"))]
190fn consume_fault(arms: &FaultArmSet, namespace: &str) -> bool {
191    arms.lock().unwrap().remove(namespace).is_some()
192}
193/// Non-parser FTS *search*-leg failure injection for `search_notes`: distinct
194/// from `FTS_FAIL_NS` (which injects at the FTS *upsert*/write step of
195/// `create_note_inner`). Injects a `StorageError::Timeout` at the `search()`
196/// call the FTS fail-open arm guards, so the arm's `is_fts5_syntax_error()`
197/// gate can be exercised against a genuine non-parser failure and asserted to
198/// propagate rather than degrade.
199#[cfg(any(test, feature = "fault-injection"))]
200static FTS_SEARCH_FAIL_NS: std::sync::Mutex<Option<String>> = std::sync::Mutex::new(None);
201
202/// Arm a one-shot FTS failure injection for `create_note_inner`/`create_entity_inner`
203/// targeting namespace `ns`. `restore_note`/`restore_entity` consume the same
204/// arm at their post-commit reindex step, after the row and its FTS document
205/// are already committed in one unit.
206///
207/// The next `create_note` or `create_entity` call whose namespace equals `ns` returns
208/// an injected error at the FTS upsert step (after the row is committed), then disarms
209/// — only that namespace's entry is consumed. The arm is process-wide and thread
210/// independent: it may be set from one OS thread and consumed by a `create_note`/
211/// `create_entity` call running on another (e.g. inside `tokio::spawn`). Concurrent
212/// arms of distinct namespaces do not interfere with each other.
213/// Keep the returned guard alive until the triggering call completes; dropping it
214/// disarms an unconsumed injection.
215/// Available when compiled with `cfg(test)` or `feature = "fault-injection"`.
216#[cfg(any(test, feature = "fault-injection"))]
217pub fn arm_fts_fail_scoped(ns: &str) -> FaultInjectionArm {
218    arm_fault(&FTS_FAIL_NS, ns, MAX_FAULT_ARMS)
219}
220
221/// Arm the FTS failure injection for `create_many` targeting namespace `ns`.
222///
223/// The next `create_many` call whose namespace equals `ns` returns an injected
224/// error at the first FTS statement inside the atomic batch, then disarms.
225/// Calls on other namespaces are unaffected, and concurrent arms of distinct
226/// namespaces do not overwrite each other.
227/// Keep the returned guard alive until the triggering call completes; dropping it
228/// disarms an unconsumed injection.
229/// Available when compiled with `cfg(test)` or `feature = "fault-injection"`.
230#[cfg(any(test, feature = "fault-injection"))]
231pub fn arm_fts_fail_many_scoped(ns: &str) -> FaultInjectionArm {
232    arm_fault(&FTS_FAIL_MANY_NS, ns, MAX_FAULT_ARMS)
233}
234
235/// Arm a mid-batch FTS failure for `create_many` targeting namespace `ns`.
236///
237/// The next matching call fails the second FTS statement when the batch contains at
238/// least two entities, after one entity/FTS pair has executed in the transaction.
239/// A one-entity batch fails its first FTS statement. Then disarms only that namespace.
240/// Keep the returned guard alive until the triggering call completes; dropping it
241/// disarms an unconsumed injection.
242/// Available when compiled with `cfg(test)` or `feature = "fault-injection"`.
243#[cfg(any(test, feature = "fault-injection"))]
244pub fn arm_fts_fail_many_partial_scoped(ns: &str) -> FaultInjectionArm {
245    arm_fault(&FTS_FAIL_MANY_PARTIAL_NS, ns, MAX_FAULT_ARMS)
246}
247
248/// Arm a non-parser FTS *search*-leg failure injection for `search_notes` targeting
249/// any call whose visible namespaces include `ns`.
250///
251/// The next `search_notes` call touching `ns` returns `StorageError::Timeout` from
252/// the FTS leg instead of calling the real `TextSearch::search`, then disarms.
253/// Used to prove the fail-open arm in `search_notes` propagates non-parser
254/// `StorageError`s instead of silently degrading them the way a genuine FTS5
255/// parser syntax error is degraded.
256/// Available when compiled with `cfg(test)` or `feature = "fault-injection"`.
257#[cfg(any(test, feature = "fault-injection"))]
258pub fn arm_fts_search_fail(ns: &str) {
259    *FTS_SEARCH_FAIL_NS.lock().unwrap() = Some(ns.to_string());
260}
261
262/// Arm the vector insertion failure injection for `create_note_inner` targeting `ns`.
263///
264/// The next `create_note` call whose note namespace equals `ns` returns an injected
265/// error at the first vector insert step, then disarms.  Calls on other namespaces
266/// are unaffected, and concurrent arms of distinct namespaces do not overwrite
267/// each other.
268/// Keep the returned guard alive until the triggering call completes; dropping it
269/// disarms an unconsumed injection.
270/// Available when compiled with `cfg(test)` or `feature = "fault-injection"`.
271#[cfg(any(test, feature = "fault-injection"))]
272pub fn arm_vector_fail_scoped(ns: &str) -> FaultInjectionArm {
273    arm_fault(&VECTOR_FAIL_NS, ns, MAX_FAULT_ARMS)
274}
275
276/// Arm a one-shot entity-row cleanup failure for `create_entity`
277/// compensation in namespace `ns`.
278#[cfg(any(test, feature = "fault-injection"))]
279pub fn arm_entity_compensation_fail_scoped(ns: &str) -> FaultInjectionArm {
280    arm_fault(&ENTITY_COMPENSATION_FAIL_NS, ns, MAX_FAULT_ARMS)
281}
282
283/// Arm a one-shot storage failure injection for `resolve_prefix_inner` targeting the
284/// exact `prefix` string.
285///
286/// The next `resolve_prefix`/`resolve_prefix_unfiltered`/`resolve_prefix_including_deleted`/
287/// `resolve_prefix_unfiltered_including_deleted` call scanning this `prefix` returns an
288/// injected `StorageError::Timeout` instead of performing the table scan, then disarms.
289/// Keyed by prefix rather than namespace because the unfiltered entry points pass no
290/// namespace at all.
291/// Keep the returned guard alive until the triggering call completes; dropping it
292/// disarms an unconsumed injection.
293/// Available when compiled with `cfg(test)` or `feature = "fault-injection"`.
294#[cfg(any(test, feature = "fault-injection"))]
295pub fn arm_prefix_resolve_fail_scoped(prefix: &str) -> FaultInjectionArm {
296    arm_fault(&PREFIX_RESOLVE_FAIL_NS, prefix, MAX_FAULT_ARMS)
297}
298
299/// Failure injection for `delete_note_row_first_for_compensation`'s post-row-removal
300/// cleanup step: distinct from `FTS_FAIL_NS`/`VECTOR_FAIL_NS`, which target
301/// `create_note_inner`. Lets tests prove that a rollback compensation's cleanup
302/// failure still leaves the note row (and thus the live message) gone.
303#[cfg(any(test, feature = "fault-injection"))]
304static ROLLBACK_CLEANUP_FAIL_NS: std::sync::Mutex<Option<String>> = std::sync::Mutex::new(None);
305
306/// Arm the rollback-compensation cleanup failure injection targeting `ns`.
307///
308/// The next `delete_note_row_first_for_compensation` call whose note namespace
309/// equals `ns` removes the row as usual, then returns an injected cleanup error
310/// instead of running the real graph/FTS/vector cleanup, then disarms.
311/// Available when compiled with `cfg(test)` or `feature = "fault-injection"`.
312#[cfg(any(test, feature = "fault-injection"))]
313pub fn arm_rollback_cleanup_fail(ns: &str) {
314    *ROLLBACK_CLEANUP_FAIL_NS.lock().unwrap() = Some(ns.to_string());
315}
316
317/// `atomic_message::create_notes_atomic` equivalents of the `FTS_FAIL_NS`/
318/// `VECTOR_FAIL_NS` checks above, reusing the SAME arm sets (and thus the
319/// SAME `arm_fts_fail_scoped`/`arm_vector_fail_scoped` test API) so a test
320/// can arm one call and exercise either write path. `create_notes_atomic`
321/// builds raw `PlanStatement`s instead of calling `text_for_notes()`/
322/// `vectors_for_model().insert()`, so it cannot reuse `consume_fault`
323/// in-line the way `create_note_inner` does above; these wrappers are the
324/// seam that lets it check the same arms.
325#[cfg(any(test, feature = "fault-injection"))]
326pub(crate) fn consume_fts_fail_fault(ns: &str) -> bool {
327    consume_fault(&FTS_FAIL_NS, ns)
328}
329#[cfg(any(test, feature = "fault-injection"))]
330pub(crate) fn consume_vector_fail_fault(ns: &str) -> bool {
331    consume_fault(&VECTOR_FAIL_NS, ns)
332}
333
334/// A note search result with UUID, salience-weighted RRF score, and display text.
335#[derive(Clone, Debug)]
336pub struct NoteSearchHit {
337    pub note_id: Uuid,
338    pub score: DeterministicScore,
339    pub rank_score_kind: crate::RankScoreKind,
340    pub signals: crate::SearchSignals,
341    pub source: crate::SearchSource,
342    pub title: Option<String>,
343    pub snippet: Option<String>,
344}
345
346fn salience_weighted_rank(score: DeterministicScore, salience: Option<f64>) -> DeterministicScore {
347    const SCALE_RAW: i128 = 1_i128 << 32;
348    let salience = DeterministicScore::from_f64(salience.unwrap_or(0.5));
349    let weight_raw = SCALE_RAW / 2 + i128::from(salience.to_raw()) / 2;
350    // Match khive-score's fixed-point multiplication and saturation without
351    // converting the derived weight or ranking score back to floating point.
352    let weighted_raw = i128::from(score.to_raw()) * weight_raw / SCALE_RAW;
353    DeterministicScore::from_raw(weighted_raw.clamp(
354        i128::from(DeterministicScore::NEG_INF.to_raw()),
355        i128::from(DeterministicScore::MAX.to_raw()),
356    ) as i64)
357}
358
359/// Result of [`KhiveRuntime::search_notes_outcome`]: the fused hits — text
360/// hits alone when the vector arm failed — plus the vector arm's error, if
361/// any. Mirrors [`crate::HybridSearchOutcome`] for the note substrate.
362#[derive(Clone, Debug)]
363pub struct NoteSearchOutcome {
364    pub hits: Vec<NoteSearchHit>,
365    pub vector_error: Option<String>,
366}
367
368/// Re-insert hyphens at canonical UUID positions (8-4-4-4-12) into a
369/// hyphen-free hex prefix, so a `LIKE '<pattern>%'` scan against the
370/// hyphenated `id` column matches correctly. Prefixes that already
371/// contain a hyphen are passed through unchanged. No-op for len <= 8
372/// (already correct). Input longer than 32 hex chars is NOT truncated: the
373/// extra hex chars are appended past the canonical 12-char final segment
374/// with no further hyphen, so the resulting `LIKE` pattern requires literal
375/// characters beyond position 36 that no real (36-char) UUID string can
376/// ever have — the scan naturally fails closed instead of silently
377/// resolving `<valid-32-hex><extra-hex>` to the valid UUID.
378pub fn hex_prefix_to_uuid_pattern(prefix: &str) -> String {
379    if prefix.contains('-') {
380        return prefix.to_string();
381    }
382    const BOUNDARIES: [usize; 4] = [8, 13, 18, 23]; // post-hyphen-insertion offsets
383    let mut out = String::with_capacity(36);
384    for c in prefix.chars() {
385        if BOUNDARIES.contains(&out.len()) {
386            out.push('-');
387        }
388        out.push(c);
389    }
390    out
391}
392
393/// Return the inclusive lower and exclusive upper bounds for a UUID prefix
394/// stored as a canonical lowercase UUID string under SQLite's BINARY collation.
395///
396/// Compact hexadecimal prefixes and prefixes of the canonical dashed spelling
397/// are accepted. The returned bounds are canonicalized to lowercase; malformed
398/// dashed spellings and prefixes longer than one UUID fail closed. The upper
399/// bound is the shortest lexicographic successor, so a trailing run of `f`
400/// digits carries into the preceding digit. `g` is the exclusive sentinel when
401/// the prefix is all `f`, because canonical UUID strings contain only `0`-`f`.
402pub fn uuid_prefix_bounds(prefix: &str) -> Option<(String, String)> {
403    const HYPHEN_POSITIONS: [usize; 4] = [8, 13, 18, 23];
404
405    let compact = if prefix.contains('-') {
406        if prefix.len() > 36 {
407            return None;
408        }
409        let mut compact = String::with_capacity(32);
410        for (index, byte) in prefix.bytes().enumerate() {
411            if HYPHEN_POSITIONS.contains(&index) {
412                if byte != b'-' {
413                    return None;
414                }
415            } else if byte.is_ascii_hexdigit() {
416                compact.push(char::from(byte.to_ascii_lowercase()));
417            } else {
418                return None;
419            }
420        }
421        compact
422    } else {
423        if prefix.is_empty()
424            || prefix.len() > 32
425            || !prefix.bytes().all(|byte| byte.is_ascii_hexdigit())
426        {
427            return None;
428        }
429        prefix.to_ascii_lowercase()
430    };
431
432    if compact.is_empty() || compact.len() > 32 {
433        return None;
434    }
435
436    let lower = hex_prefix_to_uuid_pattern(&compact);
437    let mut successor = compact.into_bytes();
438    let mut carried_past_start = true;
439    for index in (0..successor.len()).rev() {
440        let next = match successor[index] {
441            b'0'..=b'8' | b'a'..=b'e' => Some(successor[index] + 1),
442            b'9' => Some(b'a'),
443            b'f' => None,
444            _ => return None,
445        };
446        if let Some(next) = next {
447            successor[index] = next;
448            successor.truncate(index + 1);
449            carried_past_start = false;
450            break;
451        }
452    }
453
454    let upper = if carried_past_start {
455        "g".to_string()
456    } else {
457        let compact_upper = String::from_utf8(successor).ok()?;
458        hex_prefix_to_uuid_pattern(&compact_upper)
459    };
460    Some((lower, upper))
461}
462
463fn resolve_prefix_statement(
464    table: &str,
465    has_deleted_at: bool,
466    include_deleted: bool,
467    namespaces: Option<&[String]>,
468    lower: &str,
469    upper: &str,
470) -> SqlStatement {
471    let namespace_clause = namespaces.map(|namespaces| {
472        let placeholders: Vec<String> = (0..namespaces.len())
473            .map(|index| format!("?{}", index + 3))
474            .collect();
475        format!(" AND namespace IN ({})", placeholders.join(", "))
476    });
477    let deleted_filter = if has_deleted_at && !include_deleted {
478        " AND deleted_at IS NULL"
479    } else {
480        ""
481    };
482    let mut params = vec![
483        SqlValue::Text(lower.to_owned()),
484        SqlValue::Text(upper.to_owned()),
485    ];
486    if let Some(namespaces) = namespaces {
487        params.extend(
488            namespaces
489                .iter()
490                .map(|namespace| SqlValue::Text(namespace.clone())),
491        );
492    }
493
494    SqlStatement {
495        sql: format!(
496            "SELECT id FROM {table} \
497             WHERE id >= ?1 AND id < ?2{namespace_clause}{deleted_filter} LIMIT 2",
498            namespace_clause = namespace_clause.as_deref().unwrap_or("")
499        ),
500        params,
501        label: Some("resolve_prefix".into()),
502    }
503}
504
505fn text_preview(text: &str, max_chars: usize) -> Option<String> {
506    let trimmed = text.trim();
507    if trimmed.is_empty() {
508        None
509    } else {
510        Some(trimmed.chars().take(max_chars).collect())
511    }
512}
513
514/// Symmetric relations (`competes_with`, `composed_with`) are stored with a
515/// canonical source (lower UUID wins), so a directed `Out` or `In` query may
516/// miss results. When the relations filter is non-empty and contains **only**
517/// symmetric relations, override direction to `Both` so callers always see all
518/// edges for these relations regardless of storage canonicalization.
519fn normalize_symmetric_direction(
520    direction: Direction,
521    relations: Option<&[EdgeRelation]>,
522) -> Direction {
523    let Some(rels) = relations else {
524        return direction;
525    };
526    if rels.is_empty() {
527        return direction;
528    }
529    let all_symmetric = rels
530        .iter()
531        .all(|r| matches!(r, EdgeRelation::CompetesWith | EdgeRelation::ComposedWith));
532    if all_symmetric {
533        Direction::Both
534    } else {
535        direction
536    }
537}
538
539/// Stable tie-break rank for [`Direction`] — `Out` before `In` — used to make
540/// the both-direction sort/dedup key total over self-loop edges. A self-loop
541/// (`source_id == target_id == node_id`) produces two `UNION ALL` rows with
542/// the same `(node_id, edge_id)` but opposite directions; without direction in
543/// the key, sort-then-dedup collapses them to one and drops the direction
544/// parity a separate `Out` call plus a separate `In` call would preserve.
545fn direction_sort_rank(direction: &Direction) -> u8 {
546    match direction {
547        Direction::Out => 0,
548        Direction::In => 1,
549        Direction::Both => 2,
550    }
551}
552
553fn note_title(note: &Note) -> Option<String> {
554    note.name
555        .clone()
556        .filter(|s| !s.trim().is_empty())
557        .or_else(|| Some(format!("[{}]", note.kind.as_str())))
558}
559
560fn note_snippet(note: &Note) -> Option<String> {
561    text_preview(&note.content, 200)
562}
563
564/// Message properties established only by the trusted channel-ingest path.
565///
566/// Mirrors `khive-pack-comm`'s `TRANSPORT_OWNED_MESSAGE_PROPERTIES`. Duplicated
567/// here rather than imported because `khive-runtime` sits below `khive-pack-comm`
568/// in the dependency chain (`runtime → packs`); this list is the one place in
569/// the runtime layer that needs to know the shape of comm's trust boundary,
570/// guarding [`KhiveRuntime::try_create_note`]'s fast path.
571const TRANSPORT_OWNED_MESSAGE_PROPERTIES: &[&str] =
572    &["quarantined", "channel_kind", "channel_slug"];
573
574fn transport_owned_message_property_named_in(
575    properties: &serde_json::Map<String, serde_json::Value>,
576) -> Option<&'static str> {
577    TRANSPORT_OWNED_MESSAGE_PROPERTIES
578        .iter()
579        .copied()
580        .find(|key| properties.contains_key(*key))
581}
582
583/// Result of resolving a UUID to its substrate kind.
584#[derive(Clone, Debug)]
585pub enum Resolved {
586    Entity(Entity),
587    Note(Note),
588    Event(Event),
589    /// A record owned by a pack's private tables.
590    ///
591    /// `pack` identifies the owning pack by name, `kind` is the pack-local
592    /// record type (e.g. "domain", "atom"), and `data` is the full record as
593    /// a JSON Value. Pack-private records are not valid edge endpoints,
594    /// annotates sources, or task context entities.
595    PackRecord {
596        pack: String,
597        kind: String,
598        data: serde_json::Value,
599    },
600}
601
602/// A by-ID edge-endpoint substrate kind, including `Edge` itself.
603///
604/// Unlike [`Resolved`], this carries no record data — it is used where only
605/// the substrate classification is needed (coordinator locate/link parity
606/// with `get`, ADR-002 rule 1: `annotates` target may be entity, note, edge,
607/// or event).
608#[derive(Clone, Copy, Debug, Eq, PartialEq)]
609pub enum EdgeEndpointKind {
610    Entity,
611    Note,
612    Event,
613    Edge,
614}
615
616/// Map a resolved endpoint to its `(substrate, kind, entity_type)` triple, or
617/// `None` if the substrate is not a valid edge endpoint (events, edges).
618///
619/// `entity_type` carries the pack-owned granular subtype (`Entity::entity_type`,
620/// e.g. `"theorem"`); it is `None` for notes and for entities with no subtype.
621fn resolved_pair(r: Option<&Resolved>) -> Option<(&'static str, &str, Option<&str>)> {
622    match r? {
623        Resolved::Entity(e) => Some(("entity", e.kind.as_str(), e.entity_type.as_deref())),
624        Resolved::Note(n) => Some(("note", n.kind.as_str(), None)),
625        Resolved::Event(_) => None,
626        Resolved::PackRecord { .. } => None,
627    }
628}
629
630/// `true` if `spec` matches the given substrate + kind + entity_type triple.
631///
632/// Pure and DB-free — exposed so offline consumers (e.g. `kkernel kg
633/// validate`, which parses `(substrate, kind, entity_type)` straight out of
634/// NDJSON with no live record to resolve) can apply the exact same
635/// `EdgeEndpointRule` matching semantics `pack_rule_allows` uses internally,
636/// instead of re-deriving a parallel matcher that could drift out of sync.
637pub fn endpoint_matches(
638    spec: &EndpointKind,
639    substrate: &str,
640    kind: &str,
641    entity_type: Option<&str>,
642) -> bool {
643    match spec {
644        EndpointKind::EntityOfKind(k) => substrate == "entity" && *k == kind,
645        EndpointKind::NoteOfKind(k) => substrate == "note" && *k == kind,
646        EndpointKind::EntityOfType {
647            kind: k,
648            entity_type: t,
649        } => substrate == "entity" && *k == kind && entity_type == Some(*t),
650    }
651}
652
653/// `true` if `spec` matches the given substrate + kind + entity_type triple,
654/// treating an *absent* `entity_type` on the query side as unconstrained
655/// rather than an exact match against "no subtype".
656///
657/// Used only by the static GQL impossibility hint (`static_impossible_edge_pattern_warnings`,
658/// `accepted_entity_kind_pairs_for_relation`), which reasons over a *pattern*
659/// endpoint, not a resolved entity. A pattern endpoint that names a kind but
660/// no `entity_type` (`(a:concept)-[:depends_on]->(b:concept)`) has not ruled
661/// out any subtype, so an `EntityOfType` rule for that kind still makes the
662/// triple possible — unlike `endpoint_matches`, which the live link
663/// validator applies to *resolved* entities, where a `None` `entity_type`
664/// means the entity genuinely has no subtype and must be an exact miss
665/// against a typed rule. Do not use this for validation.
666fn pattern_endpoint_matches(
667    spec: &EndpointKind,
668    substrate: &str,
669    kind: &str,
670    entity_type: Option<&str>,
671) -> bool {
672    match spec {
673        EndpointKind::EntityOfType {
674            kind: k,
675            entity_type: t,
676        } => substrate == "entity" && *k == kind && entity_type.is_none_or(|et| et == *t),
677        _ => endpoint_matches(spec, substrate, kind, entity_type),
678    }
679}
680
681/// Relations that a composed pack `EDGE_RULES` set accepts for a given
682/// `(entity_kind, entity_type)` endpoint pair, using the EXACT SAME
683/// `endpoint_matches` semantics `pack_rule_allows` applies internally
684/// (`EntityOfKind`, `EntityOfType`, `NoteOfKind`) — never a re-filtered copy.
685///
686/// Both endpoints are treated as entities (substrate `"entity"`), matching
687/// the only case pack-layer error-hint code needs (issue #543): a rejected
688/// `link` between two already-resolved entities. `entity_type` is the
689/// pack-owned granular subtype (e.g. `"theorem"`); pass `None` for
690/// untyped entities. Exposed so `khive-pack-kg`'s hint derivation cannot
691/// silently diverge from the validator by only matching `EntityOfKind` and
692/// missing pack rules declared via `EntityOfType` (e.g. `khive-pack-formal`'s
693/// typed `theorem -> definition` `depends_on` rules).
694pub fn accepted_pack_relations_for_entities(
695    rules: &[EdgeEndpointRule],
696    src_kind: &str,
697    src_entity_type: Option<&str>,
698    tgt_kind: &str,
699    tgt_entity_type: Option<&str>,
700) -> Vec<EdgeRelation> {
701    let mut relations: Vec<EdgeRelation> = rules
702        .iter()
703        .filter(|r| {
704            endpoint_matches(&r.source, "entity", src_kind, src_entity_type)
705                && endpoint_matches(&r.target, "entity", tgt_kind, tgt_entity_type)
706        })
707        .map(|r| r.relation)
708        .collect();
709    relations.sort_by_key(|r| r.as_str());
710    relations.dedup();
711    relations
712}
713
714/// Relations accepted for one resolved entity endpoint pair under the full
715/// live contract: the base allowlist plus the loaded packs' additive rules.
716///
717/// This is the pair-oriented counterpart to the private
718/// `accepted_entity_kind_pairs_for_relation` helper. It is shared by validation
719/// errors and pack-layer hints so every write path can tell a caller which
720/// relations would be legal without maintaining a second endpoint table.
721/// Pack declarations for relations with dedicated substrate branches are
722/// excluded because the live validator resolves `annotates` and the three
723/// same-substrate special relations before pack rules are consulted.
724pub fn accepted_entity_relations_for_entities(
725    rules: &[EdgeEndpointRule],
726    src_kind: &str,
727    src_entity_type: Option<&str>,
728    tgt_kind: &str,
729    tgt_entity_type: Option<&str>,
730) -> Vec<EdgeRelation> {
731    let mut relations: Vec<EdgeRelation> = BASE_ENTITY_ENDPOINT_RULES
732        .iter()
733        .filter(|(src, _relation, tgt)| (*src == "*" || *src == src_kind) && *tgt == tgt_kind)
734        .map(|(_src, relation, _tgt)| *relation)
735        .collect();
736    relations.extend(
737        accepted_pack_relations_for_entities(
738            rules,
739            src_kind,
740            src_entity_type,
741            tgt_kind,
742            tgt_entity_type,
743        )
744        .into_iter()
745        .filter(|relation| {
746            *relation != EdgeRelation::Annotates && !crate::pack::is_special_relation(*relation)
747        }),
748    );
749    relations.sort_by_key(|relation| relation.as_str());
750    relations.dedup();
751    relations
752}
753
754fn accepted_entity_relations_description(
755    rules: &[EdgeEndpointRule],
756    src_kind: &str,
757    src_entity_type: Option<&str>,
758    tgt_kind: &str,
759    tgt_entity_type: Option<&str>,
760) -> String {
761    let relations = accepted_entity_relations_for_entities(
762        rules,
763        src_kind,
764        src_entity_type,
765        tgt_kind,
766        tgt_entity_type,
767    );
768    if relations.is_empty() {
769        "none".to_string()
770    } else {
771        relations
772            .iter()
773            .map(EdgeRelation::as_str)
774            .collect::<Vec<_>>()
775            .join(", ")
776    }
777}
778
779/// Hint-only counterpart to [`accepted_pack_relations_for_entities`] that
780/// matches via [`pattern_endpoint_matches`] instead of [`endpoint_matches`],
781/// so an absent `entity_type` is treated as unconstrained rather than an
782/// exact-match miss against `EntityOfType` rules. Used exclusively by the
783/// static GQL impossibility hint — never by validation.
784fn accepted_pack_relations_for_pattern_entities(
785    rules: &[EdgeEndpointRule],
786    src_kind: &str,
787    src_entity_type: Option<&str>,
788    tgt_kind: &str,
789    tgt_entity_type: Option<&str>,
790) -> Vec<EdgeRelation> {
791    let mut relations: Vec<EdgeRelation> = rules
792        .iter()
793        .filter(|r| {
794            pattern_endpoint_matches(&r.source, "entity", src_kind, src_entity_type)
795                && pattern_endpoint_matches(&r.target, "entity", tgt_kind, tgt_entity_type)
796        })
797        .map(|r| r.relation)
798        .collect();
799    relations.sort_by_key(|r| r.as_str());
800    relations.dedup();
801    relations
802}
803
804/// All `(source_kind, target_kind)` entity-kind pairs — restricted to the closed
805/// 8-kind base [`khive_types::EntityKind`] taxonomy — that accept `relation`
806/// under the composed base allowlist plus pack `EDGE_RULES`. Reuses
807/// [`base_entity_rule_allows`] and [`accepted_pack_relations_for_pattern_entities`]
808/// over the closed kind set rather than re-deriving a parallel table (for GQL
809/// query-pattern hint derivation). Pack rules are skipped when
810/// `crate::pack::is_special_relation` is true (supersedes/supports/refutes):
811/// those relations are resolved by the live validator's special-relation branch
812/// before `pack_rule_allows` is ever reached — see `pack.rs`'s
813/// `edge_endpoint_table` doc comment.
814fn accepted_entity_kind_pairs_for_relation(
815    pack_rules: &[EdgeEndpointRule],
816    relation: EdgeRelation,
817) -> Vec<(&'static str, &'static str)> {
818    let mut pairs = Vec::new();
819    for src in khive_types::EntityKind::ALL {
820        for tgt in khive_types::EntityKind::ALL {
821            let allowed = base_entity_rule_allows(src.name(), relation, tgt.name())
822                || (!crate::pack::is_special_relation(relation)
823                    && accepted_pack_relations_for_pattern_entities(
824                        pack_rules,
825                        src.name(),
826                        None,
827                        tgt.name(),
828                        None,
829                    )
830                    .contains(&relation));
831            if allowed {
832                pairs.push((src.name(), tgt.name()));
833            }
834        }
835    }
836    pairs
837}
838
839/// Scans a GQL `MATCH` pattern for edges that name an explicit relation and
840/// explicit entity kinds on both endpoints — a single mandatory hop, a fixed
841/// direction — where the `(source_kind, relation, target_kind)` triple can
842/// never match under the composed edge endpoint contract. Returns one warning
843/// per statically-impossible edge.
844///
845/// Deliberately conservative: unlabeled nodes, unlabeled/multi-relation edges,
846/// undirected edges, variable-length hops, and note-kind endpoints are left
847/// unchecked, since none of those name a single static triple to test against
848/// the validator (issue #593).
849///
850/// Mirrors the validator's special-relation precedence for `supersedes` /
851/// `supports` / `refutes` (see [`accepted_entity_kind_pairs_for_relation`]):
852/// pack rules never make those triples possible, only the base allowlist does.
853fn static_impossible_edge_pattern_warnings(
854    language: khive_query::QueryLanguage,
855    pattern: &khive_query::ast::MatchPattern,
856    pack_rules: &[EdgeEndpointRule],
857) -> Vec<String> {
858    use khive_query::ast::{EdgeDirection, PatternElement};
859
860    if language != khive_query::QueryLanguage::Gql {
861        return Vec::new();
862    }
863
864    let elements = &pattern.elements;
865    let mut warnings = Vec::new();
866
867    for (i, el) in elements.iter().enumerate() {
868        let PatternElement::Edge(edge) = el else {
869            continue;
870        };
871        if edge.relations.len() != 1 || edge.min_hops != 1 || edge.max_hops != 1 {
872            continue;
873        }
874        let (left, right) = match (elements.get(i.wrapping_sub(1)), elements.get(i + 1)) {
875            (Some(PatternElement::Node(l)), Some(PatternElement::Node(r))) => (l, r),
876            _ => continue,
877        };
878        let (src_node, tgt_node) = match edge.direction {
879            EdgeDirection::Out => (left, right),
880            EdgeDirection::In => (right, left),
881            EdgeDirection::Both => continue,
882        };
883        let (Some(src_raw), Some(tgt_raw)) = (src_node.kind.as_deref(), tgt_node.kind.as_deref())
884        else {
885            continue;
886        };
887        let (Ok(src_kind), Ok(tgt_kind)) = (
888            src_raw.parse::<khive_types::EntityKind>(),
889            tgt_raw.parse::<khive_types::EntityKind>(),
890        ) else {
891            continue;
892        };
893        let Ok(relation) = edge.relations[0].parse::<EdgeRelation>() else {
894            continue;
895        };
896
897        let possible = base_entity_rule_allows(src_kind.name(), relation, tgt_kind.name())
898            || (!crate::pack::is_special_relation(relation)
899                && accepted_pack_relations_for_pattern_entities(
900                    pack_rules,
901                    src_kind.name(),
902                    src_node.entity_type.as_deref(),
903                    tgt_kind.name(),
904                    tgt_node.entity_type.as_deref(),
905                )
906                .contains(&relation));
907        if possible {
908            continue;
909        }
910
911        let accepted = accepted_entity_kind_pairs_for_relation(pack_rules, relation);
912        let accepted_str = if accepted.is_empty() {
913            "none".to_string()
914        } else {
915            accepted
916                .iter()
917                .map(|(s, t)| format!("{s}->{t}"))
918                .collect::<Vec<_>>()
919                .join(", ")
920        };
921        warnings.push(format!(
922            "pattern ({src})-[:{relation}]->({tgt}) can never match: '{relation}' does not accept \
923             {src}->{tgt} endpoints; accepted source->target kinds for '{relation}': {accepted_str}",
924            src = src_kind.name(),
925            tgt = tgt_kind.name(),
926        ));
927    }
928
929    warnings
930}
931
932/// `true` if any pack-declared edge endpoint rule allows the
933/// `(source, relation, target)` triple. Pack rules are additive only.
934fn pack_rule_allows(
935    rules: &[EdgeEndpointRule],
936    relation: EdgeRelation,
937    src: Option<&Resolved>,
938    tgt: Option<&Resolved>,
939) -> bool {
940    let Some((src_sub, src_kind, src_type)) = resolved_pair(src) else {
941        return false;
942    };
943    let Some((tgt_sub, tgt_kind, tgt_type)) = resolved_pair(tgt) else {
944        return false;
945    };
946    rules.iter().any(|r| {
947        r.relation == relation
948            && endpoint_matches(&r.source, src_sub, src_kind, src_type)
949            && endpoint_matches(&r.target, tgt_sub, tgt_kind, tgt_type)
950    })
951}
952
953/// Base entity endpoint allowlist — the closed set of permitted entity→entity
954/// relation triples.
955///
956/// Each entry `(src_kind, relation, tgt_kind)` explicitly allows that combination.
957/// `"*"` as `src_kind` means "any entity kind" (used by `instance_of` whose source
958/// is unrestricted).
959///
960/// Pack rules (via `EDGE_RULES`) are additive — they cannot remove rows here.
961/// Exposed via `base_entity_endpoint_rules()` for the ADR-076 certificate tests.
962pub const BASE_ENTITY_ENDPOINT_RULES: &[(&str, EdgeRelation, &str)] = &[
963    // Structure
964    ("concept", EdgeRelation::Contains, "concept"),
965    ("project", EdgeRelation::Contains, "project"),
966    ("project", EdgeRelation::Contains, "artifact"),
967    ("org", EdgeRelation::Contains, "project"),
968    ("org", EdgeRelation::Contains, "service"),
969    ("concept", EdgeRelation::PartOf, "concept"),
970    ("project", EdgeRelation::PartOf, "project"),
971    ("project", EdgeRelation::PartOf, "org"),
972    ("*", EdgeRelation::InstanceOf, "concept"),
973    ("service", EdgeRelation::InstanceOf, "project"),
974    // ADR-002 amendment (ADR-191): web hyperlink — a document points at
975    // another document it links to. No qualifier inference (unlike
976    // depends_on); the endpoint pair is intentionally narrow (document only,
977    // no service/concept targets — see ADR-191 D2/F10).
978    ("document", EdgeRelation::LinksTo, "document"),
979    // Derivation
980    ("concept", EdgeRelation::Extends, "concept"),
981    ("concept", EdgeRelation::VariantOf, "concept"),
982    ("artifact", EdgeRelation::VariantOf, "artifact"),
983    ("concept", EdgeRelation::IntroducedBy, "document"),
984    ("concept", EdgeRelation::IntroducedBy, "person"),
985    ("artifact", EdgeRelation::IntroducedBy, "document"),
986    ("project", EdgeRelation::IntroducedBy, "document"),
987    // ADR-002 amendment (ADR-167): service provenance — the document that
988    // introduced a service (its ADR or design record).
989    ("service", EdgeRelation::IntroducedBy, "document"),
990    ("document", EdgeRelation::IntroducedBy, "person"),
991    ("document", EdgeRelation::IntroducedBy, "org"),
992    ("concept", EdgeRelation::IntroducedBy, "org"),
993    // Provenance
994    ("artifact", EdgeRelation::DerivedFrom, "dataset"),
995    ("artifact", EdgeRelation::DerivedFrom, "document"),
996    ("artifact", EdgeRelation::DerivedFrom, "project"),
997    ("artifact", EdgeRelation::DerivedFrom, "artifact"),
998    // ADR-002 amendment 2026-07-27: publication provenance — a curated or
999    // filtered publication copy points at the canonical source document.
1000    ("document", EdgeRelation::DerivedFrom, "document"),
1001    // Temporal
1002    ("document", EdgeRelation::Precedes, "document"),
1003    ("dataset", EdgeRelation::Precedes, "dataset"),
1004    ("artifact", EdgeRelation::Precedes, "artifact"),
1005    ("service", EdgeRelation::Precedes, "service"),
1006    ("project", EdgeRelation::Precedes, "project"),
1007    // Dependency
1008    ("project", EdgeRelation::DependsOn, "project"),
1009    ("service", EdgeRelation::DependsOn, "project"),
1010    ("service", EdgeRelation::DependsOn, "service"),
1011    ("service", EdgeRelation::DependsOn, "artifact"),
1012    ("service", EdgeRelation::DependsOn, "dataset"),
1013    ("artifact", EdgeRelation::DependsOn, "project"),
1014    ("artifact", EdgeRelation::DependsOn, "service"),
1015    ("document", EdgeRelation::DependsOn, "document"),
1016    ("concept", EdgeRelation::Enables, "concept"),
1017    ("service", EdgeRelation::Enables, "concept"),
1018    ("dataset", EdgeRelation::Enables, "concept"),
1019    // Implementation
1020    ("project", EdgeRelation::Implements, "concept"),
1021    ("service", EdgeRelation::Implements, "concept"),
1022    // Lateral
1023    ("concept", EdgeRelation::CompetesWith, "concept"),
1024    ("project", EdgeRelation::CompetesWith, "project"),
1025    ("service", EdgeRelation::CompetesWith, "service"),
1026    ("concept", EdgeRelation::ComposedWith, "concept"),
1027    ("project", EdgeRelation::ComposedWith, "project"),
1028    // Versioning (Supersedes — Concept/Document/Artifact/Service/Dataset only)
1029    ("concept", EdgeRelation::Supersedes, "concept"),
1030    ("document", EdgeRelation::Supersedes, "document"),
1031    ("artifact", EdgeRelation::Supersedes, "artifact"),
1032    ("service", EdgeRelation::Supersedes, "service"),
1033    ("dataset", EdgeRelation::Supersedes, "dataset"),
1034    // Epistemic (Supports/Refutes — evidence sources → Concept claim only)
1035    ("concept", EdgeRelation::Supports, "concept"),
1036    ("document", EdgeRelation::Supports, "concept"),
1037    ("dataset", EdgeRelation::Supports, "concept"),
1038    ("artifact", EdgeRelation::Supports, "concept"),
1039    ("concept", EdgeRelation::Refutes, "concept"),
1040    ("document", EdgeRelation::Refutes, "concept"),
1041    ("dataset", EdgeRelation::Refutes, "concept"),
1042    ("artifact", EdgeRelation::Refutes, "concept"),
1043];
1044
1045/// Returns the base entity endpoint allowlist.
1046///
1047/// The returned slice is the same data that `base_entity_rule_allows` consults at
1048/// runtime. Exposed for the ADR-076 certificate tests in `khive-pack-kg`, which
1049/// must audit live rules rather than hand-copied snapshots.
1050pub fn base_entity_endpoint_rules() -> &'static [(&'static str, EdgeRelation, &'static str)] {
1051    BASE_ENTITY_ENDPOINT_RULES
1052}
1053
1054/// `true` if `(src_kind, relation, tgt_kind)` is in the base entity endpoint
1055/// allowlist. Pure and DB-free — exposed alongside [`base_entity_endpoint_rules`]
1056/// so offline consumers (e.g. `kkernel kg validate`) can apply the exact same
1057/// base-table membership test the live validator uses, instead of re-deriving
1058/// a parallel `.any()` predicate over a hand-copied allowlist.
1059pub fn base_entity_rule_allows(src_kind: &str, relation: EdgeRelation, tgt_kind: &str) -> bool {
1060    BASE_ENTITY_ENDPOINT_RULES.iter().any(|(src, rel, tgt)| {
1061        *rel == relation && (*src == "*" || *src == src_kind) && *tgt == tgt_kind
1062    })
1063}
1064
1065/// Canonical endpoint order for symmetric relations (F012).
1066///
1067/// For `competes_with` and `composed_with`, normalises direction so that
1068/// `source_uuid < target_uuid` (lexicographic on the UUID bytes). This
1069/// collapses A→B and B→A into a single canonical row, preventing duplicates.
1070pub(crate) fn canonical_edge_endpoints(
1071    relation: EdgeRelation,
1072    source_id: Uuid,
1073    target_id: Uuid,
1074) -> (Uuid, Uuid) {
1075    if relation.is_symmetric() && target_id < source_id {
1076        (target_id, source_id)
1077    } else {
1078        (source_id, target_id)
1079    }
1080}
1081
1082/// Infer the default `dependency_kind` from endpoint entity kinds.
1083///
1084/// `pub(crate)` so `crate::atomic_prepare::prepare_link` can reuse this exact
1085/// inference table, keeping `--atomic link` byte-for-byte consistent with the
1086/// non-atomic `link()` rather than re-deriving the table.
1087pub(crate) fn infer_dependency_kind(src_kind: &str, tgt_kind: &str) -> Option<&'static str> {
1088    match (src_kind, tgt_kind) {
1089        ("project", "project") => Some("build"),
1090        ("service", "service") => Some("runtime"),
1091        ("service", "dataset") => Some("data"),
1092        ("service", "artifact") => Some("artifact"),
1093        ("artifact", "project") | ("artifact", "service") => Some("tooling"),
1094        ("document", "document") => Some("normative"),
1095        _ => None,
1096    }
1097}
1098
1099/// Merge an inferred `dependency_kind` into `depends_on` edge metadata.
1100///
1101/// If `metadata` already carries a `dependency_kind` key the existing value is
1102/// preserved. If the key is absent and the endpoint pair has a known default,
1103/// the inferred value is added. Returns `metadata` unchanged for all other
1104/// cases (no matching default, or metadata already has the key).
1105///
1106/// `pub(crate)` so `crate::atomic_prepare::prepare_link` can reuse it for
1107/// atomic/non-atomic parity.
1108pub(crate) fn merge_dependency_kind(
1109    src_kind: &str,
1110    tgt_kind: &str,
1111    metadata: Option<serde_json::Value>,
1112) -> Option<serde_json::Value> {
1113    if let Some(ref m) = metadata {
1114        if m.get("dependency_kind").is_some() {
1115            return metadata;
1116        }
1117    }
1118    let Some(inferred) = infer_dependency_kind(src_kind, tgt_kind) else {
1119        return metadata;
1120    };
1121    let mut obj = metadata.unwrap_or_else(|| serde_json::json!({}));
1122    if let Some(o) = obj.as_object_mut() {
1123        o.insert("dependency_kind".to_string(), serde_json::json!(inferred));
1124    }
1125    Some(obj)
1126}
1127
1128/// Merge a caller-supplied top-level `dependency_kind` param into an edge's
1129/// `metadata` object, filling the key only if `metadata` doesn't already
1130/// carry one. This is distinct from `merge_dependency_kind` above (which
1131/// infers a default from endpoint entity kinds when no explicit value was
1132/// given at all) — this one folds in an EXPLICIT `dependency_kind` argument
1133/// the caller passed alongside `metadata`.
1134///
1135/// `pub`: the single source both `khive-pack-kg::handlers::link::handle_link`
1136/// (via `khive_runtime::merge_entry_metadata`) and
1137/// `crate::atomic_prepare::prepare_link` call. Lives in `khive-runtime` (not
1138/// pack-kg) because packs depend on `khive-runtime`, never the reverse: the
1139/// only direction that lets both call sites share one copy instead of a
1140/// hand-duplicated block.
1141pub fn merge_entry_metadata(
1142    metadata: Option<serde_json::Value>,
1143    dependency_kind: Option<String>,
1144) -> RuntimeResult<Option<serde_json::Value>> {
1145    let Some(dk) = dependency_kind else {
1146        return Ok(metadata);
1147    };
1148    let mut obj = metadata.unwrap_or_else(|| serde_json::json!({}));
1149    let map = obj
1150        .as_object_mut()
1151        .ok_or_else(|| RuntimeError::InvalidInput("metadata must be a JSON object".into()))?;
1152    map.entry("dependency_kind".to_string())
1153        .or_insert_with(|| serde_json::json!(dk));
1154    Ok(Some(obj))
1155}
1156
1157/// Valid `dependency_kind` values for `depends_on` edges.
1158const VALID_DEPENDENCY_KINDS: &[&str] = &[
1159    "build",
1160    "runtime",
1161    "data",
1162    "artifact",
1163    "tooling",
1164    "normative",
1165];
1166
1167/// Validate that an edge weight is finite and within `[0.0, 1.0]`.
1168///
1169/// Rejects NaN, infinities, negative values, and values exceeding 1.0.
1170/// Used by `link` and `import_kg` to enforce the weight invariant consistently
1171/// across all edge creation paths.
1172pub(crate) fn validate_edge_weight(weight: f64) -> RuntimeResult<()> {
1173    if !weight.is_finite() || !(0.0..=1.0).contains(&weight) {
1174        return Err(RuntimeError::InvalidInput(format!(
1175            "edge weight must be finite and in [0.0, 1.0], got {weight}"
1176        )));
1177    }
1178    Ok(())
1179}
1180
1181/// Validate governed edge metadata keys.
1182///
1183/// Currently enforces:
1184/// - `dependency_kind` is only valid on `depends_on` edges.
1185/// - `dependency_kind`, when present, must be one of the governed values.
1186pub(crate) fn validate_edge_metadata(
1187    relation: EdgeRelation,
1188    metadata: Option<&serde_json::Value>,
1189) -> RuntimeResult<()> {
1190    let Some(meta) = metadata else {
1191        return Ok(());
1192    };
1193    if let Some(dk) = meta.get("dependency_kind") {
1194        if relation != EdgeRelation::DependsOn {
1195            return Err(RuntimeError::InvalidInput(format!(
1196                "dependency_kind is only valid on depends_on edges (got {})",
1197                relation.as_str()
1198            )));
1199        }
1200        let dk_str = dk
1201            .as_str()
1202            .ok_or_else(|| RuntimeError::InvalidInput("dependency_kind must be a string".into()))?;
1203        if !VALID_DEPENDENCY_KINDS.contains(&dk_str) {
1204            return Err(RuntimeError::InvalidInput(format!(
1205                "unknown dependency_kind {dk_str:?}; valid: {}",
1206                VALID_DEPENDENCY_KINDS.join(" | ")
1207            )));
1208        }
1209    }
1210    Ok(())
1211}
1212
1213/// Returns `true` when `note_props` is a superset of all key-value pairs in `filter`.
1214///
1215/// Mirrors the semantics of `khive_pack_kg::handlers::common::props_match` so that the
1216/// storage-leg predicate in `search_notes` is identical to the handler-side post-filter.
1217fn note_props_match(note_props: Option<&serde_json::Value>, filter: &serde_json::Value) -> bool {
1218    let required = match filter.as_object() {
1219        Some(obj) if !obj.is_empty() => obj,
1220        _ => return true,
1221    };
1222    let actual = match note_props.and_then(serde_json::Value::as_object) {
1223        Some(obj) => obj,
1224        None => return false,
1225    };
1226    required
1227        .iter()
1228        .all(|(k, v)| actual.get(k).is_some_and(|av| av == v))
1229}
1230
1231fn note_graph_name(note: &Note) -> String {
1232    note.name
1233        .as_deref()
1234        .filter(|name| !name.trim().is_empty())
1235        .map(str::to_owned)
1236        .unwrap_or_else(|| format!("[{}]", note.kind))
1237}
1238
1239/// Collapse per-namespace `GraphPath`s from [`KhiveRuntime::traverse`] down to exactly
1240/// one entry per distinct `root_id`, merging by `(root_id, node_id)` (shallowest depth
1241/// wins), BFS-ordering the result, and re-applying `limit`. See
1242/// docs/operations.md#merge_traversal_paths_by_root for why naive concatenation is unsound.
1243fn merge_traversal_paths_by_root(paths: Vec<GraphPath>, limit: Option<u32>) -> Vec<GraphPath> {
1244    let mut order: Vec<Uuid> = Vec::new();
1245    let mut merged: HashMap<Uuid, GraphPath> = HashMap::new();
1246    // root_id -> (node_id -> index into merged[root_id].nodes), so a
1247    // shallower depth for an already-seen node updates in place instead of
1248    // rebuilding a seen-set from every prior namespace's contribution.
1249    let mut node_index: HashMap<Uuid, HashMap<Uuid, usize>> = HashMap::new();
1250
1251    for path in paths {
1252        let existing = merged.entry(path.root_id).or_insert_with(|| {
1253            order.push(path.root_id);
1254            GraphPath {
1255                root_id: path.root_id,
1256                nodes: Vec::new(),
1257                total_weight: 0.0,
1258            }
1259        });
1260        let index = node_index.entry(path.root_id).or_default();
1261        for node in path.nodes {
1262            match index.get(&node.node_id) {
1263                Some(&i) => {
1264                    if node.depth < existing.nodes[i].depth {
1265                        existing.nodes[i] = node;
1266                    }
1267                }
1268                None => {
1269                    index.insert(node.node_id, existing.nodes.len());
1270                    existing.nodes.push(node);
1271                }
1272            }
1273        }
1274    }
1275
1276    order
1277        .into_iter()
1278        .filter_map(|root_id| merged.remove(&root_id))
1279        .map(|mut path| {
1280            // BFS order: ascending depth, stable within a depth.
1281            path.nodes.sort_by_key(|n| n.depth);
1282            if let Some(lim) = limit {
1283                let lim = lim as usize;
1284                let mut non_root_kept = 0usize;
1285                path.nodes.retain(|n| {
1286                    if n.depth == 0 {
1287                        return true;
1288                    }
1289                    if non_root_kept < lim {
1290                        non_root_kept += 1;
1291                        true
1292                    } else {
1293                        false
1294                    }
1295                });
1296            }
1297            recompute_total_weight(&mut path);
1298            path
1299        })
1300        .collect()
1301}
1302
1303/// Set `total_weight` to the maximum cumulative path weight among the nodes
1304/// the path currently holds, matching how storage derives it for a
1305/// single-namespace traversal.
1306///
1307/// Call this after any edit to `nodes`. Carrying a weight across an edit is
1308/// what lets the field describe a node the caller was never shown: the
1309/// highest-weighted candidate is exactly the one a `limit` or a
1310/// soft-delete screen can remove while the summary keeps quoting it.
1311fn recompute_total_weight(path: &mut GraphPath) {
1312    path.total_weight = path.nodes.iter().map(|n| n.weight).fold(0.0_f64, f64::max);
1313}
1314
1315/// Await every spawned multi-model embed task in `join_set`, returning one
1316/// vector per model (in model order) on full success.
1317///
1318/// `join_set` entries are `(model_index, embed_result)` — the index lets
1319/// completion order (which is arrival order, not spawn order) be reassembled
1320/// into the caller's model order. On the first failure (an embed error or a
1321/// task panic), every remaining handle is aborted and detached so the error
1322/// return is not gated on a sibling reaching a cancellation point. A sibling
1323/// already inside synchronous native inference may finish that call in the
1324/// background. Embed calls are counted when issued, before the provider
1325/// await, so detached completion cannot change the operation's usage count.
1326/// Each task owns cloned runtime/provider state and only computes an embedding;
1327/// storage writes remain in the parent after this drain succeeds.
1328async fn drain_embed_join_set<T: Send + 'static>(
1329    mut join_set: tokio::task::JoinSet<(usize, RuntimeResult<T>)>,
1330    model_count: usize,
1331) -> RuntimeResult<Vec<T>> {
1332    let mut vectors: Vec<Option<T>> = (0..model_count).map(|_| None).collect();
1333
1334    while let Some(joined) = join_set.join_next().await {
1335        match joined {
1336            Ok((idx, Ok(vector))) => vectors[idx] = Some(vector),
1337            Ok((_idx, Err(e))) => {
1338                join_set.abort_all();
1339                return Err(e);
1340            }
1341            Err(join_err) => {
1342                join_set.abort_all();
1343                return Err(RuntimeError::Internal(format!(
1344                    "embed task panicked: {join_err}"
1345                )));
1346            }
1347        }
1348    }
1349
1350    Ok(vectors
1351        .into_iter()
1352        .map(|v| v.expect("every model index observed exactly once by join_set drain"))
1353        .collect())
1354}
1355
1356impl KhiveRuntime {
1357    // ---- Entity operations ----
1358
1359    async fn compensate_entity_create(
1360        &self,
1361        token: &NamespaceToken,
1362        entity_id: Uuid,
1363        namespace: &str,
1364        vector_models: &[String],
1365    ) -> Vec<String> {
1366        let mut cleanup_errors = Vec::new();
1367
1368        #[cfg(any(test, feature = "fault-injection"))]
1369        let entity_delete_injected = consume_fault(&ENTITY_COMPENSATION_FAIL_NS, namespace);
1370        #[cfg(not(any(test, feature = "fault-injection")))]
1371        let entity_delete_injected = false;
1372
1373        if entity_delete_injected {
1374            cleanup_errors.push("entity row delete: injected compensation failure".to_string());
1375        } else {
1376            match self.entities(token) {
1377                Ok(store) => {
1378                    if let Err(error) = store.delete_entity(entity_id, DeleteMode::Hard).await {
1379                        cleanup_errors.push(format!("entity row delete: {error}"));
1380                    }
1381                }
1382                Err(error) => cleanup_errors.push(format!("entity store access: {error}")),
1383            }
1384        }
1385
1386        match self.text(token) {
1387            Ok(fts) => {
1388                if let Err(error) = fts.delete_document(namespace, entity_id).await {
1389                    cleanup_errors.push(format!("FTS document delete: {error}"));
1390                }
1391            }
1392            Err(error) => cleanup_errors.push(format!("FTS store access: {error}")),
1393        }
1394
1395        for model_name in vector_models {
1396            match self.vectors_for_model(token, model_name) {
1397                Ok(vectors) => {
1398                    if let Err(error) = vectors.delete(entity_id).await {
1399                        cleanup_errors
1400                            .push(format!("vector delete for model {model_name}: {error}"));
1401                    }
1402                }
1403                Err(error) => cleanup_errors.push(format!(
1404                    "vector store access for model {model_name}: {error}"
1405                )),
1406            }
1407        }
1408
1409        cleanup_errors
1410    }
1411
1412    fn entity_create_failure(
1413        entity_id: Uuid,
1414        primary: RuntimeError,
1415        cleanup_errors: Vec<String>,
1416    ) -> RuntimeError {
1417        if cleanup_errors.is_empty() {
1418            primary
1419        } else {
1420            RuntimeError::Khive(KhiveError::internal(format!(
1421                "create_entity indexing failed for record {entity_id}; primary failure: \
1422                 {primary}; compensation failure(s): {}; partial persistence is possible; \
1423                 inspect and reconcile this record before retrying",
1424                cleanup_errors.join("; ")
1425            )))
1426        }
1427    }
1428
1429    /// Create and persist a new entity.
1430    ///
1431    /// Indexing failures trigger compensation across the entity row, FTS
1432    /// document, and any vector models touched by this call. If compensation
1433    /// also fails, the returned structured internal error identifies possible
1434    /// partial persistence, includes both failure classes, and carries the
1435    /// entity ID as a reconciliation handle.
1436    // REASON: entity creation requires kind, type, name, description, properties, tags, and
1437    // namespace token — refactoring into a builder would add indirection without reducing
1438    // caller complexity; this signature mirrors the MCP verb surface directly.
1439    #[allow(clippy::too_many_arguments)]
1440    pub async fn create_entity(
1441        &self,
1442        token: &NamespaceToken,
1443        kind: &str,
1444        entity_type: Option<&str>,
1445        name: &str,
1446        description: Option<&str>,
1447        properties: Option<serde_json::Value>,
1448        tags: Vec<String>,
1449    ) -> RuntimeResult<Entity> {
1450        Ok(self
1451            .create_entity_with_embedding_report_inner(
1452                token,
1453                kind,
1454                entity_type,
1455                name,
1456                description,
1457                properties,
1458                tags,
1459                Vec::new(),
1460            )
1461            .await?
1462            .0)
1463    }
1464
1465    /// Create an entity with role-keyed bytes already published to `BlobStore`.
1466    ///
1467    /// Every [`NewAttachment`] carries a typed content reference, so malformed
1468    /// references cannot enter through this consumer seam. Blob existence is
1469    /// checked before the database write. The entity row and all attachment rows
1470    /// then commit in one storage transaction; the FTS/vector compensation path
1471    /// hard-deletes the entity and its attachments together if a later indexing
1472    /// step fails. Published bytes remain recoverable by the BlobStore grace-period
1473    /// orphan policy when any post-publication step fails.
1474    #[allow(clippy::too_many_arguments)]
1475    pub async fn create_entity_with_attachments(
1476        &self,
1477        token: &NamespaceToken,
1478        kind: &str,
1479        entity_type: Option<&str>,
1480        name: &str,
1481        description: Option<&str>,
1482        properties: Option<serde_json::Value>,
1483        tags: Vec<String>,
1484        attachments: Vec<NewAttachment>,
1485    ) -> RuntimeResult<Entity> {
1486        // Attachment rows are the process-wide BlobStore's liveness authority.
1487        // Validate placement before existence probes or any record write: pack
1488        // runtimes bound to a secondary backend must explicitly call `core()`.
1489        drop(self.attachments()?);
1490        let blob_store = self.blob_store().ok_or_else(|| {
1491            RuntimeError::Unconfigured(
1492                "create_entity_with_attachments requires an installed BlobStore".to_string(),
1493            )
1494        })?;
1495        let mut roles = std::collections::HashSet::with_capacity(attachments.len());
1496        for attachment in &attachments {
1497            attachment.validate()?;
1498            if !roles.insert(attachment.role.as_str()) {
1499                return Err(RuntimeError::InvalidInput(format!(
1500                    "duplicate attachment role {:?}",
1501                    attachment.role
1502                )));
1503            }
1504        }
1505        for attachment in &attachments {
1506            if !blob_store.exists(&attachment.content_ref).await? {
1507                return Err(RuntimeError::InvalidInput(format!(
1508                    "create_entity_with_attachments requires a published blob; no object exists for {}",
1509                    attachment.content_ref
1510                )));
1511            }
1512        }
1513        let validated_type = self.validate_entity_type_for_kind(kind, entity_type)?;
1514        Ok(self
1515            .create_entity_with_embedding_report_inner(
1516                token,
1517                kind,
1518                validated_type.as_deref(),
1519                name,
1520                description,
1521                properties,
1522                tags,
1523                attachments,
1524            )
1525            .await?
1526            .0)
1527    }
1528
1529    #[allow(clippy::too_many_arguments)]
1530    pub async fn create_entity_with_embedding_report(
1531        &self,
1532        token: &NamespaceToken,
1533        kind: &str,
1534        entity_type: Option<&str>,
1535        name: &str,
1536        description: Option<&str>,
1537        properties: Option<serde_json::Value>,
1538        tags: Vec<String>,
1539    ) -> RuntimeResult<(Entity, crate::retrieval::EmbeddingTruncationReport)> {
1540        self.create_entity_with_embedding_report_inner(
1541            token,
1542            kind,
1543            entity_type,
1544            name,
1545            description,
1546            properties,
1547            tags,
1548            Vec::new(),
1549        )
1550        .await
1551    }
1552
1553    #[allow(clippy::too_many_arguments)]
1554    async fn create_entity_with_embedding_report_inner(
1555        &self,
1556        token: &NamespaceToken,
1557        kind: &str,
1558        entity_type: Option<&str>,
1559        name: &str,
1560        description: Option<&str>,
1561        properties: Option<serde_json::Value>,
1562        tags: Vec<String>,
1563        attachments: Vec<NewAttachment>,
1564    ) -> RuntimeResult<(Entity, crate::retrieval::EmbeddingTruncationReport)> {
1565        self.validate_entity_kind(kind)?;
1566        crate::secret_gate::reject_reserved_secret_gate_property(properties.as_ref())?;
1567        // Secret gate: scan name, description, structured properties, and tags.
1568        crate::secret_gate::check_at(name, "entity", "name")?;
1569        if let Some(d) = description {
1570            crate::secret_gate::check_at(d, "entity", "description")?;
1571        }
1572        if let Some(ref p) = properties {
1573            crate::secret_gate::check_json_at(p, "entity", "properties")?;
1574        }
1575        crate::secret_gate::check_tags_at(&tags, "entity", "tags")?;
1576        let ns = token.namespace().as_str();
1577        let mut entity = Entity::new(ns, kind, name).with_entity_type(entity_type);
1578        if let Some(d) = description {
1579            entity = entity.with_description(d);
1580        }
1581        if let Some(p) = properties {
1582            entity = entity.with_properties(p);
1583        }
1584        if !tags.is_empty() {
1585            entity = entity.with_tags(tags);
1586        }
1587        let projected_content_ref = attachments
1588            .iter()
1589            .find(|attachment| attachment.role == "content")
1590            .map(|attachment| attachment.content_ref.to_string());
1591        let attachment_rows = attachments
1592            .into_iter()
1593            .map(|attachment| {
1594                Attachment::from_new(
1595                    entity.id,
1596                    AttachmentSubstrate::Entity,
1597                    attachment,
1598                    entity.created_at,
1599                )
1600            })
1601            .collect();
1602        self.entities(token)?
1603            .upsert_entity_with_attachments(entity.clone(), attachment_rows)
1604            .await?;
1605        entity.content_ref = projected_content_ref;
1606
1607        let doc = entity_fts_document(&entity);
1608        let embed_body = doc.body.clone();
1609
1610        // FTS step — compensate entity row on failure (mirrors create_note_inner).
1611        {
1612            #[cfg(any(test, feature = "fault-injection"))]
1613            let fts_inject = consume_fault(&FTS_FAIL_NS, ns);
1614            #[cfg(not(any(test, feature = "fault-injection")))]
1615            let fts_inject = false;
1616            let fts_result: RuntimeResult<()> = if fts_inject {
1617                Err(RuntimeError::Internal("injected FTS failure".to_string()))
1618            } else {
1619                match self.text(token) {
1620                    Ok(fts) => fts.upsert_document(doc).await.map_err(RuntimeError::from),
1621                    Err(e) => Err(e),
1622                }
1623            };
1624            if let Err(e) = fts_result {
1625                let cleanup_errors = self
1626                    .compensate_entity_create(token, entity.id, ns, &[])
1627                    .await;
1628                return Err(Self::entity_create_failure(entity.id, e, cleanup_errors));
1629            }
1630        }
1631
1632        // Vector embedding + insert step — compensate entity row + FTS doc on failure.
1633        // Fan out to ALL registered models (mirrors create_note_inner multi-model path).
1634        let embed_model_names = {
1635            let names = self.registered_embedding_model_names();
1636            if names.is_empty() {
1637                vec![]
1638            } else {
1639                names
1640            }
1641        };
1642
1643        let mut embedding_report = crate::retrieval::EmbeddingTruncationReport::default();
1644        if embed_model_names.len() == 1 {
1645            let model_name = &embed_model_names[0];
1646            let vec_result = self
1647                .embed_document_with_model_outcome_for_token(token, model_name, &embed_body)
1648                .await;
1649
1650            #[cfg(any(test, feature = "fault-injection"))]
1651            let vec_inject = consume_fault(&VECTOR_FAIL_NS, ns);
1652            #[cfg(not(any(test, feature = "fault-injection")))]
1653            let vec_inject = false;
1654            let vec_result: RuntimeResult<crate::retrieval::DocumentEmbeddingOutcome> =
1655                if vec_inject {
1656                    Err(RuntimeError::Internal(
1657                        "injected vector failure".to_string(),
1658                    ))
1659                } else {
1660                    vec_result
1661                };
1662
1663            let single_result: RuntimeResult<()> = match vec_result {
1664                Ok(outcome) => {
1665                    embedding_report.observe(&outcome);
1666                    match self.vectors_for_model(token, model_name) {
1667                        Ok(vs) => vs
1668                            .insert(
1669                                entity.id,
1670                                SubstrateKind::Entity,
1671                                ns,
1672                                "entity.body",
1673                                vec![outcome.vector],
1674                            )
1675                            .await
1676                            .map_err(RuntimeError::from),
1677                        Err(e) => Err(e),
1678                    }
1679                }
1680                Err(e) => Err(e),
1681            };
1682            if let Err(e) = single_result {
1683                let cleanup_errors = self
1684                    .compensate_entity_create(
1685                        token,
1686                        entity.id,
1687                        ns,
1688                        std::slice::from_ref(model_name),
1689                    )
1690                    .await;
1691                return Err(Self::entity_create_failure(entity.id, e, cleanup_errors));
1692            }
1693        } else if !embed_model_names.is_empty() {
1694            // Multi-model path: embed with each model in parallel, then insert sequentially
1695            // with inserted_models tracking for rollback on partial failure.
1696            let rt_clone = self.clone();
1697            let body_owned = embed_body.clone();
1698            let usage_ctx = crate::usage::current();
1699            let mut join_set = tokio::task::JoinSet::new();
1700            for (idx, model_name) in embed_model_names.iter().enumerate() {
1701                let rt = rt_clone.clone();
1702                let text = body_owned.clone();
1703                let name = model_name.clone();
1704                let ctx = usage_ctx.clone();
1705                let token = (*token).clone();
1706                join_set.spawn(crate::runtime::inherit_request_embedder_scope(async move {
1707                    let fut = rt.embed_document_with_model_outcome_for_token(&token, &name, &text);
1708                    let result = match ctx {
1709                        Some(ctx) => crate::usage::scope(ctx, fut).await,
1710                        None => fut.await,
1711                    };
1712                    (idx, result)
1713                }));
1714            }
1715            // The first failed or panicked handle aborts and detaches its
1716            // siblings. Embed usage is counted at dispatch, so a synchronous
1717            // provider winding down in the background cannot change it.
1718            let outcomes = match drain_embed_join_set(join_set, embed_model_names.len()).await {
1719                Ok(outcomes) => outcomes,
1720                Err(e) => {
1721                    let cleanup_errors = self
1722                        .compensate_entity_create(token, entity.id, ns, &[])
1723                        .await;
1724                    return Err(Self::entity_create_failure(entity.id, e, cleanup_errors));
1725                }
1726            };
1727            // TODO(P2): parallelize vector inserts
1728            let mut inserted_models: Vec<String> = Vec::with_capacity(embed_model_names.len());
1729            for (model_name, outcome) in embed_model_names.iter().zip(outcomes) {
1730                embedding_report.observe(&outcome);
1731                // Count-targetable fault injection for multi-model insert path.
1732                #[cfg(any(test, feature = "fault-injection"))]
1733                let count_inject = VECTOR_FAIL_AFTER.with(|cell| match cell.get() {
1734                    Some(0) => {
1735                        cell.set(None);
1736                        true
1737                    }
1738                    Some(n) => {
1739                        cell.set(Some(n - 1));
1740                        false
1741                    }
1742                    None => false,
1743                });
1744                #[cfg(not(any(test, feature = "fault-injection")))]
1745                let count_inject = false;
1746
1747                let insert_result = if count_inject {
1748                    Err(RuntimeError::Internal(
1749                        "injected vector insert failure".to_string(),
1750                    ))
1751                } else {
1752                    match self.vectors_for_model(token, model_name) {
1753                        Ok(vs) => vs
1754                            .insert(
1755                                entity.id,
1756                                SubstrateKind::Entity,
1757                                ns,
1758                                "entity.body",
1759                                vec![outcome.vector],
1760                            )
1761                            .await
1762                            .map_err(RuntimeError::from),
1763                        Err(e) => Err(e),
1764                    }
1765                };
1766                if let Err(e) = insert_result {
1767                    // Include the model whose INSERT returned an error: a backend
1768                    // error does not prove the write had no side effects.
1769                    let mut cleanup_models = inserted_models.clone();
1770                    cleanup_models.push(model_name.clone());
1771                    let cleanup_errors = self
1772                        .compensate_entity_create(token, entity.id, ns, &cleanup_models)
1773                        .await;
1774                    return Err(Self::entity_create_failure(entity.id, e, cleanup_errors));
1775                }
1776                inserted_models.push(model_name.clone());
1777            }
1778        }
1779
1780        // The arrival event, appended only after every compensating step has had
1781        // its chance to fire: a create that rolled back returns above and never
1782        // reaches here, so the event plane cannot name an entity that does not
1783        // exist. Deletes and updates already emitted theirs; creates did not,
1784        // which left the audit trail able to say what left the graph and not
1785        // what entered it.
1786        let event_store = self.events(token)?;
1787        let created_event = khive_storage::event::Event::new(
1788            entity.namespace.clone(),
1789            "create",
1790            EventKind::EntityCreated,
1791            SubstrateKind::Entity,
1792            "",
1793        )
1794        .with_target(entity.id)
1795        .with_payload(serde_json::json!({
1796            "id": entity.id,
1797            "namespace": entity.namespace,
1798            "kind": entity.kind,
1799        }));
1800        event_store.append_event(created_event).await.map_err(|e| {
1801            RuntimeError::Internal(format!("create_entity: event store write failed: {e}"))
1802        })?;
1803
1804        Ok((entity, embedding_report))
1805    }
1806
1807    /// Retrieve an entity by ID.
1808    ///
1809    /// UUID v4 is globally unique: no namespace filter on by-ID ops.
1810    ///
1811    /// Interim identifier-continuity disclosure (precedes the full transitive
1812    /// redirect chase): a miss is probed once against the tombstone row. If
1813    /// the id was consumed by `merge(into_id, from_id)` — `merged_into` set —
1814    /// the `NotFound` message names the kept id so the caller can requery it
1815    /// directly. Single-level only: it does not chase a chain of merges and
1816    /// does not return the kept entity in place of the miss. The probe only
1817    /// runs after the live-row lookup misses, so the happy path pays no
1818    /// extra query.
1819    pub async fn get_entity(&self, token: &NamespaceToken, id: Uuid) -> RuntimeResult<Entity> {
1820        let store = self.entities(token)?;
1821        if let Some(entity) = store.get_entity(id).await? {
1822            return Ok(entity);
1823        }
1824        if let Some(tombstone) = store.get_entity_including_deleted(id).await? {
1825            if let Some(kept_id) = tombstone.merged_into {
1826                return Err(RuntimeError::NotFound(format!(
1827                    "{id} was merged into {kept_id}; query the kept id"
1828                )));
1829            }
1830        }
1831        Err(RuntimeError::NotFound(format!("entity {id}")))
1832    }
1833
1834    /// Retrieve an entity by ID including soft-deleted rows.
1835    ///
1836    /// UUID v4 is globally unique: no namespace filter on by-ID ops.
1837    pub async fn get_entity_including_deleted(
1838        &self,
1839        token: &NamespaceToken,
1840        id: Uuid,
1841    ) -> RuntimeResult<Option<Entity>> {
1842        self.entities(token)?
1843            .get_entity_including_deleted(id)
1844            .await
1845            .map_err(Into::into)
1846    }
1847
1848    /// Retrieve a note by ID including soft-deleted rows.
1849    ///
1850    /// UUID v4 is globally unique: no namespace filter on by-ID ops.
1851    pub async fn get_note_including_deleted(
1852        &self,
1853        token: &NamespaceToken,
1854        id: Uuid,
1855    ) -> RuntimeResult<Option<khive_storage::note::Note>> {
1856        self.notes(token)?
1857            .get_note_including_deleted(id)
1858            .await
1859            .map_err(Into::into)
1860    }
1861
1862    /// Fetch multiple entities by ID, returning only those that exist in the
1863    /// caller's namespace.  Missing or namespace-mismatched IDs are silently
1864    /// omitted so that batch lookups don't abort on a single stale reference.
1865    pub async fn get_entities_by_ids(
1866        &self,
1867        token: &NamespaceToken,
1868        ids: &[Uuid],
1869    ) -> RuntimeResult<Vec<Entity>> {
1870        if ids.is_empty() {
1871            return Ok(vec![]);
1872        }
1873        let filter = EntityFilter {
1874            ids: ids.to_vec(),
1875            ..Default::default()
1876        };
1877        let page = self
1878            .entities(token)?
1879            .query_entities(
1880                token.namespace().as_str(),
1881                filter,
1882                PageRequest {
1883                    offset: 0,
1884                    limit: ids.len() as u32,
1885                },
1886            )
1887            .await?;
1888        Ok(page.items)
1889    }
1890
1891    /// Like `get_entities_by_ids` but scoped to the token's full visible-namespace
1892    /// set (`primary ∪ extra_visible`) instead of primary only.
1893    ///
1894    /// Graph expansion (`neighbors`, `traverse`) iterates over all visible
1895    /// namespaces, so enrichment must use the same scope — otherwise neighbors
1896    /// or path nodes whose entities live in an extra-visible namespace are left
1897    /// with `name = None`, `kind = None`.  Missing or out-of-scope IDs are
1898    /// silently omitted (best-effort, same as `get_entities_by_ids`).
1899    async fn get_entities_by_ids_visible(
1900        &self,
1901        token: &NamespaceToken,
1902        ids: &[Uuid],
1903    ) -> RuntimeResult<Vec<Entity>> {
1904        if ids.is_empty() {
1905            return Ok(vec![]);
1906        }
1907        let namespaces: Vec<String> = token
1908            .visible_namespaces()
1909            .iter()
1910            .map(|ns| ns.as_str().to_owned())
1911            .collect();
1912        let filter = EntityFilter {
1913            ids: ids.to_vec(),
1914            namespaces,
1915            ..Default::default()
1916        };
1917        let page = self
1918            .entities(token)?
1919            .query_entities(
1920                token.namespace().as_str(),
1921                filter,
1922                PageRequest {
1923                    offset: 0,
1924                    limit: ids.len() as u32,
1925                },
1926            )
1927            .await?;
1928        Ok(page.items)
1929    }
1930
1931    /// Enforce that `record_ns` is within the caller's visible namespace set.
1932    ///
1933    /// Returns `Err(NotFound)` when the record namespace is not in the visible
1934    /// set — wrong-namespace and absent UUIDs must be indistinguishable
1935    /// externally (no existence oracle).
1936    ///
1937    /// When the visible set is a single entry equal to `caller_primary_ns`, this
1938    /// is identical to the former strict-equality check (backward-compatible).
1939    pub(crate) fn ensure_namespace(record_ns: &str, caller_primary_ns: &str) -> RuntimeResult<()> {
1940        if record_ns == caller_primary_ns {
1941            return Ok(());
1942        }
1943        Err(RuntimeError::NotFound("not found in this namespace".into()))
1944    }
1945
1946    /// Enforce that `record_ns` is a member of the token's visible namespace set.
1947    ///
1948    /// This is the multi-namespace-aware variant used when the token carries an
1949    /// extended visibility set. For single-namespace tokens (visible == [primary])
1950    /// this degenerates to the same strict-equality check as `ensure_namespace`.
1951    pub(crate) fn ensure_namespace_visible(
1952        record_ns: &str,
1953        token: &NamespaceToken,
1954    ) -> RuntimeResult<()> {
1955        for ns in token.visible_namespaces() {
1956            if record_ns == ns.as_str() {
1957                return Ok(());
1958            }
1959        }
1960        Err(RuntimeError::NotFound("not found in this namespace".into()))
1961    }
1962
1963    /// List entities visible to the token, optionally filtered by kind and entity_type.
1964    /// A null entity_type falls back to a string properties.type for filtering only.
1965    ///
1966    /// When the token carries a multi-namespace visible set, entities from all
1967    /// visible namespaces are returned. When the visible set is `[primary]`
1968    /// (the default) this behaves identically to the pre-visibility behaviour.
1969    pub async fn list_entities(
1970        &self,
1971        token: &NamespaceToken,
1972        kind: Option<&str>,
1973        entity_type: Option<&str>,
1974        limit: u32,
1975        offset: u32,
1976    ) -> RuntimeResult<Vec<Entity>> {
1977        let filter = EntityFilter {
1978            kinds: kind
1979                .map(|value| vec![value.to_string()])
1980                .unwrap_or_default(),
1981            entity_types: entity_type
1982                .map(|value| vec![value.to_string()])
1983                .unwrap_or_default(),
1984            legacy_entity_type_fallback: true,
1985            ..Default::default()
1986        };
1987        self.list_entities_filtered(token, filter, limit, offset)
1988            .await
1989    }
1990
1991    /// Apply a composed entity predicate before offset pagination. Namespace
1992    /// visibility is supplied by the token, just as for the scalar list API.
1993    pub async fn list_entities_filtered(
1994        &self,
1995        token: &NamespaceToken,
1996        mut filter: EntityFilter,
1997        limit: u32,
1998        offset: u32,
1999    ) -> RuntimeResult<Vec<Entity>> {
2000        filter.namespaces = token
2001            .visible_namespaces()
2002            .iter()
2003            .map(|namespace| namespace.as_str().to_owned())
2004            .collect();
2005        let page = self
2006            .entities(token)?
2007            .query_entities(
2008                token.namespace().as_str(),
2009                filter,
2010                PageRequest {
2011                    offset: offset.into(),
2012                    limit,
2013                },
2014            )
2015            .await?;
2016        Ok(page.items)
2017    }
2018
2019    /// List an immutable insertion-sequence page of visible entities.
2020    ///
2021    /// The public cursor remains the UUID of the last returned entity. We
2022    /// resolve its immutable database-assigned sequence before querying so callers do
2023    /// not need to serialize storage details. A missing or out-of-scope cursor
2024    /// fails explicitly instead of silently resuming from the wrong boundary.
2025    pub async fn list_entities_after(
2026        &self,
2027        token: &NamespaceToken,
2028        kind: Option<&str>,
2029        entity_type: Option<&str>,
2030        tags_any: &[String],
2031        after: Option<Uuid>,
2032        limit: u32,
2033    ) -> RuntimeResult<(Vec<Entity>, Option<Uuid>)> {
2034        let filter = EntityFilter {
2035            kinds: kind
2036                .map(|value| vec![value.to_string()])
2037                .unwrap_or_default(),
2038            entity_types: entity_type
2039                .map(|value| vec![value.to_string()])
2040                .unwrap_or_default(),
2041            legacy_entity_type_fallback: true,
2042            tags_any: tags_any.to_vec(),
2043            ..Default::default()
2044        };
2045        self.list_entities_after_filtered(token, filter, after, limit)
2046            .await
2047    }
2048
2049    /// Apply a composed entity predicate before insertion-sequence pagination,
2050    /// preserving the scalar API's cursor validation and token visibility.
2051    pub async fn list_entities_after_filtered(
2052        &self,
2053        token: &NamespaceToken,
2054        mut filter: EntityFilter,
2055        after: Option<Uuid>,
2056        limit: u32,
2057    ) -> RuntimeResult<(Vec<Entity>, Option<Uuid>)> {
2058        let store = self.entities(token)?;
2059        let after = match after {
2060            Some(id) => {
2061                let entity = self
2062                    .get_entity_including_deleted(token, id)
2063                    .await?
2064                    .ok_or_else(|| RuntimeError::NotFound(format!("entity cursor {id}")))?;
2065                Self::ensure_namespace_visible(&entity.namespace, token)?;
2066                let sequence = store.entity_sequence(id).await?.ok_or_else(|| {
2067                    RuntimeError::Internal(format!(
2068                        "entity cursor {id} has no insertion-sequence ledger row"
2069                    ))
2070                })?;
2071                Some(SeekCursor { sequence, id })
2072            }
2073            None => None,
2074        };
2075        filter.namespaces = token
2076            .visible_namespaces()
2077            .iter()
2078            .map(|namespace| namespace.as_str().to_owned())
2079            .collect();
2080        let page = store
2081            .query_entities_after(token.namespace().as_str(), filter, after, limit)
2082            .await?;
2083        Ok((page.items, page.next_after.map(|cursor| cursor.id)))
2084    }
2085
2086    /// List entities filtered by kind, optional domain tag, limit, and offset.
2087    ///
2088    /// When `domain_tag` is Some, the query is restricted at the storage layer via
2089    /// `EntityFilter::tags_any` so the page result already reflects the domain
2090    /// constraint.  This avoids the silent truncation that occurs when filtering
2091    /// post-page (K-3). Multi-namespace visibility from the token is applied.
2092    pub async fn list_entities_tagged(
2093        &self,
2094        token: &NamespaceToken,
2095        kind: Option<&str>,
2096        domain_tag: Option<&str>,
2097        limit: u32,
2098        offset: u32,
2099    ) -> RuntimeResult<Vec<Entity>> {
2100        let ns_strs: Vec<String> = token
2101            .visible_namespaces()
2102            .iter()
2103            .map(|ns| ns.as_str().to_owned())
2104            .collect();
2105        let filter = EntityFilter {
2106            kinds: match kind {
2107                Some(k) => vec![k.to_string()],
2108                None => vec![],
2109            },
2110            tags_any: match domain_tag {
2111                Some(t) if !t.is_empty() => vec![t.to_string()],
2112                _ => vec![],
2113            },
2114            namespaces: ns_strs,
2115            ..Default::default()
2116        };
2117        let page = self
2118            .entities(token)?
2119            .query_entities(
2120                token.namespace().as_str(),
2121                filter,
2122                PageRequest {
2123                    offset: offset.into(),
2124                    limit,
2125                },
2126            )
2127            .await?;
2128        Ok(page.items)
2129    }
2130
2131    /// Count entities filtered by kind and optional domain tag.
2132    ///
2133    /// Used to report a meaningful `total` alongside a paginated listing (K-6).
2134    /// Multi-namespace visibility from the token is applied.
2135    pub async fn count_entities_tagged(
2136        &self,
2137        token: &NamespaceToken,
2138        kind: Option<&str>,
2139        domain_tag: Option<&str>,
2140    ) -> RuntimeResult<u64> {
2141        let ns_strs: Vec<String> = token
2142            .visible_namespaces()
2143            .iter()
2144            .map(|ns| ns.as_str().to_owned())
2145            .collect();
2146        let filter = EntityFilter {
2147            kinds: match kind {
2148                Some(k) => vec![k.to_string()],
2149                None => vec![],
2150            },
2151            tags_any: match domain_tag {
2152                Some(t) if !t.is_empty() => vec![t.to_string()],
2153                _ => vec![],
2154            },
2155            namespaces: ns_strs,
2156            ..Default::default()
2157        };
2158        Ok(self
2159            .entities(token)?
2160            .count_entities(token.namespace().as_str(), filter)
2161            .await?)
2162    }
2163
2164    /// List events in the namespace proven by the caller token.
2165    pub async fn list_events(
2166        &self,
2167        token: &NamespaceToken,
2168        filter: EventFilter,
2169        page: PageRequest,
2170    ) -> RuntimeResult<Page<Event>> {
2171        self.events(token)?
2172            .query_events(filter, page)
2173            .await
2174            .map_err(Into::into)
2175    }
2176
2177    // ---- Edge operations ----
2178
2179    /// Validate that `source_id` and `target_id` are legal endpoints for `relation`.
2180    ///
2181    /// Centralises the three-case relation contract so that both
2182    /// `link()` and `update_edge()` share identical enforcement:
2183    ///
2184    /// - `annotates`: source MUST be a note; target may be any substrate.
2185    /// - `supersedes` / `supports` / `refutes`: same-substrate only (note→note or entity→entity).
2186    /// - All other 13 relations: both endpoints MUST be entities.
2187    ///
2188    /// Returns `Ok(())` when valid; otherwise `InvalidInput` or `NotFound` with
2189    /// the same messages as the previous inline block (byte-identical behaviour).
2190    ///
2191    /// `pub(crate)`: the atomic prepare pass (`crate::atomic_prepare`) reuses
2192    /// this exact endpoint-type validation during its async prepare step,
2193    /// before building a `LinkPlan`, rather than re-deriving the checks.
2194    pub(crate) async fn validate_edge_relation_endpoints(
2195        &self,
2196        token: &NamespaceToken,
2197        source_id: Uuid,
2198        target_id: Uuid,
2199        relation: EdgeRelation,
2200    ) -> RuntimeResult<()> {
2201        if source_id == target_id {
2202            return Err(RuntimeError::InvalidInput(
2203                "self-loop edges are not allowed: source_id and target_id must be different".into(),
2204            ));
2205        }
2206        if relation == EdgeRelation::Annotates {
2207            // Source must be a note. By-ID endpoint resolution is namespace-agnostic:
2208            // link consumes two by-ID endpoints, so it must resolve exactly what
2209            // get() resolves, regardless of caller namespace.
2210            match self.resolve_edge_endpoint(token, source_id).await? {
2211                Some(Resolved::Note(_)) => {}
2212                Some(_) => {
2213                    return Err(RuntimeError::InvalidInput(format!(
2214                        "annotates source {source_id} must be a note"
2215                    )));
2216                }
2217                None => {
2218                    // Existing edge used as annotates source: wrong kind, not absent.
2219                    if self.get_edge(token, source_id).await?.is_some() {
2220                        return Err(RuntimeError::InvalidInput(format!(
2221                            "annotates source {source_id} must be a note"
2222                        )));
2223                    }
2224                    return Err(RuntimeError::NotFound(format!(
2225                        "link source {source_id} not found"
2226                    )));
2227                }
2228            }
2229            // Target may be any substrate (entity, note, event, or edge) — by-ID, unfiltered.
2230            if !self.substrate_exists_by_id(token, target_id).await? {
2231                return Err(RuntimeError::NotFound(format!(
2232                    "link target {target_id} not found"
2233                )));
2234            }
2235        } else if crate::pack::is_special_relation(relation) {
2236            // supersedes / supports / refutes: same-substrate only (note→note or entity→entity).
2237            // Event and edge endpoints are invalid regardless of the other endpoint.
2238            // Endpoint resolution is by-ID and namespace-agnostic.
2239            let rel_name = relation.as_str();
2240            let src = match self.resolve_edge_endpoint(token, source_id).await? {
2241                Some(r) => r,
2242                None => {
2243                    if self.get_edge(token, source_id).await?.is_some() {
2244                        return Err(RuntimeError::InvalidInput(format!(
2245                            "{rel_name} source {source_id} must be a note or entity (got edge)"
2246                        )));
2247                    }
2248                    return Err(RuntimeError::NotFound(format!(
2249                        "link source {source_id} not found"
2250                    )));
2251                }
2252            };
2253            let tgt = match self.resolve_edge_endpoint(token, target_id).await? {
2254                Some(r) => r,
2255                None => {
2256                    if self.get_edge(token, target_id).await?.is_some() {
2257                        return Err(RuntimeError::InvalidInput(format!(
2258                            "{rel_name} target {target_id} must be a note or entity (got edge)"
2259                        )));
2260                    }
2261                    return Err(RuntimeError::NotFound(format!(
2262                        "link target {target_id} not found"
2263                    )));
2264                }
2265            };
2266            match (&src, &tgt) {
2267                (Resolved::Entity(src_e), Resolved::Entity(tgt_e)) => {
2268                    if !base_entity_rule_allows(&src_e.kind, relation, &tgt_e.kind) {
2269                        let legal_relations = accepted_entity_relations_description(
2270                            &self.pack_edge_rules(),
2271                            &src_e.kind,
2272                            src_e.entity_type.as_deref(),
2273                            &tgt_e.kind,
2274                            tgt_e.entity_type.as_deref(),
2275                        );
2276                        let rule_hint = match relation {
2277                            EdgeRelation::Supports | EdgeRelation::Refutes => {
2278                                "requires concept|document|dataset|artifact -> concept \
2279                                 (or same-substrate note -> note)"
2280                            }
2281                            _ => "requires same-kind entity endpoints",
2282                        };
2283                        return Err(RuntimeError::InvalidInput(format!(
2284                            "({}) -[{rel_name}]-> ({}) is not in the base endpoint \
2285                             allowlist; {rel_name} {rule_hint}; currently legal relations for \
2286                             {} -> {} under the loaded endpoint rules: {legal_relations}",
2287                            src_e.kind, tgt_e.kind, src_e.kind, tgt_e.kind
2288                        )));
2289                    }
2290                }
2291                (Resolved::Note(_), Resolved::Note(_)) => {}
2292                (Resolved::Event(_), _) => {
2293                    return Err(RuntimeError::InvalidInput(format!(
2294                        "{rel_name} does not apply to events; source {source_id} is an event"
2295                    )));
2296                }
2297                (_, Resolved::Event(_)) => {
2298                    return Err(RuntimeError::InvalidInput(format!(
2299                        "{rel_name} does not apply to events; target {target_id} is an event"
2300                    )));
2301                }
2302                (Resolved::Entity(_), Resolved::Note(_)) => {
2303                    return Err(RuntimeError::InvalidInput(format!(
2304                        "{rel_name} endpoints must be the same substrate (note→note or entity→entity); \
2305                         got source={source_id} (entity) target={target_id} (note)"
2306                    )));
2307                }
2308                (Resolved::Note(_), Resolved::Entity(_)) => {
2309                    return Err(RuntimeError::InvalidInput(format!(
2310                        "{rel_name} endpoints must be the same substrate (note→note or entity→entity); \
2311                         got source={source_id} (note) target={target_id} (entity)"
2312                    )));
2313                }
2314                (Resolved::PackRecord { .. }, _) | (_, Resolved::PackRecord { .. }) => {
2315                    return Err(RuntimeError::InvalidInput(format!(
2316                        "pack-private record is not a valid edge endpoint for {rel_name}"
2317                    )));
2318                }
2319            }
2320        } else {
2321            // All remaining base relations require entity→entity with kind-level
2322            // restrictions (see base allowlist). Packs may extend the allowlist
2323            // additively via EDGE_RULES.
2324            //
2325            // Strategy: resolve both endpoints once (by-ID, unfiltered), consult pack
2326            // rules; on miss, fall through to the original base-rule error messages.
2327            let src_res = self.resolve_edge_endpoint(token, source_id).await?;
2328            let tgt_res = self.resolve_edge_endpoint(token, target_id).await?;
2329            let pack_rules = self.pack_edge_rules();
2330
2331            if pack_rule_allows(&pack_rules, relation, src_res.as_ref(), tgt_res.as_ref()) {
2332                return Ok(());
2333            }
2334
2335            // Substrate check: both endpoints must be entities.
2336            let (src_kind, src_entity_type) = match src_res.as_ref() {
2337                Some(Resolved::Entity(e)) => (e.kind.as_str(), e.entity_type.as_deref()),
2338                Some(_) => {
2339                    return Err(RuntimeError::InvalidInput(format!(
2340                        "link source {source_id} must be an entity for relation {relation:?} \
2341                         (only `annotates` crosses substrates)"
2342                    )));
2343                }
2344                None => {
2345                    if self.get_edge(token, source_id).await?.is_some() {
2346                        return Err(RuntimeError::InvalidInput(format!(
2347                            "link source {source_id} must be an entity for relation {relation:?} \
2348                             (only `annotates` crosses substrates)"
2349                        )));
2350                    }
2351                    return Err(RuntimeError::NotFound(format!(
2352                        "link source {source_id} not found"
2353                    )));
2354                }
2355            };
2356            let (tgt_kind, tgt_entity_type) = match tgt_res.as_ref() {
2357                Some(Resolved::Entity(e)) => (e.kind.as_str(), e.entity_type.as_deref()),
2358                Some(_) => {
2359                    return Err(RuntimeError::InvalidInput(format!(
2360                        "link target {target_id} must be an entity for relation {relation:?} \
2361                         (only `annotates` crosses substrates)"
2362                    )));
2363                }
2364                None => {
2365                    if self.get_edge(token, target_id).await?.is_some() {
2366                        return Err(RuntimeError::InvalidInput(format!(
2367                            "link target {target_id} must be an entity for relation {relation:?} \
2368                             (only `annotates` crosses substrates)"
2369                        )));
2370                    }
2371                    return Err(RuntimeError::NotFound(format!(
2372                        "link target {target_id} not found"
2373                    )));
2374                }
2375            };
2376            if !base_entity_rule_allows(src_kind, relation, tgt_kind) {
2377                let legal_relations = accepted_entity_relations_description(
2378                    &pack_rules,
2379                    src_kind,
2380                    src_entity_type,
2381                    tgt_kind,
2382                    tgt_entity_type,
2383                );
2384                return Err(RuntimeError::InvalidInput(format!(
2385                    "({src_kind}) -[{}]-> ({tgt_kind}) is not in the base endpoint \
2386                     allowlist; use pack EDGE_RULES to extend the allowlist; currently legal \
2387                     relations for {src_kind} -> {tgt_kind} under the loaded endpoint rules: \
2388                     {legal_relations}",
2389                    relation.as_str()
2390                )));
2391            }
2392        }
2393        Ok(())
2394    }
2395
2396    /// Public delegator for cross-backend link validation.
2397    ///
2398    /// Exposes `validate_edge_relation_endpoints` for the `SubstrateCoordinator`
2399    /// so it can validate the relation before writing the edge on the source backend.
2400    pub async fn validate_link_endpoints(
2401        &self,
2402        token: &NamespaceToken,
2403        source_id: Uuid,
2404        target_id: Uuid,
2405        relation: EdgeRelation,
2406    ) -> RuntimeResult<()> {
2407        self.validate_edge_relation_endpoints(token, source_id, target_id, relation)
2408            .await
2409    }
2410
2411    /// Validate an edge relation using pre-fetched endpoint records.
2412    ///
2413    /// For cross-backend links the source and target live on different backends —
2414    /// the source runtime cannot resolve the target. The coordinator fetches each
2415    /// endpoint from its own backend, then calls this method to enforce the
2416    /// kind-pairing rules without a second DB round-trip.
2417    ///
2418    /// `src` and `tgt` are the `resolve_edge_endpoint` results from each backend. The
2419    /// `token` supplies the pack edge rules installed on this (source) runtime;
2420    /// no DB access is performed.
2421    pub fn validate_link_endpoints_by_resolved(
2422        &self,
2423        source_id: Uuid,
2424        target_id: Uuid,
2425        relation: EdgeRelation,
2426        src: Option<&Resolved>,
2427        tgt: Option<&Resolved>,
2428    ) -> RuntimeResult<()> {
2429        if source_id == target_id {
2430            return Err(RuntimeError::InvalidInput(
2431                "self-loop edges are not allowed: source_id and target_id must be different".into(),
2432            ));
2433        }
2434
2435        if relation == EdgeRelation::Annotates {
2436            match src {
2437                Some(Resolved::Note(_)) => {}
2438                Some(_) => {
2439                    return Err(RuntimeError::InvalidInput(format!(
2440                        "annotates source {source_id} must be a note"
2441                    )));
2442                }
2443                None => {
2444                    return Err(RuntimeError::NotFound(format!(
2445                        "link source {source_id} not found"
2446                    )));
2447                }
2448            }
2449            if tgt.is_none() {
2450                return Err(RuntimeError::NotFound(format!(
2451                    "link target {target_id} not found"
2452                )));
2453            }
2454            return Ok(());
2455        }
2456
2457        if crate::pack::is_special_relation(relation) {
2458            let rel_name = relation.as_str();
2459            let src = src.ok_or_else(|| {
2460                RuntimeError::NotFound(format!("link source {source_id} not found"))
2461            })?;
2462            let tgt = tgt.ok_or_else(|| {
2463                RuntimeError::NotFound(format!("link target {target_id} not found"))
2464            })?;
2465            match (src, tgt) {
2466                (Resolved::Entity(src_e), Resolved::Entity(tgt_e)) => {
2467                    if !base_entity_rule_allows(&src_e.kind, relation, &tgt_e.kind) {
2468                        let legal_relations = accepted_entity_relations_description(
2469                            &self.pack_edge_rules(),
2470                            &src_e.kind,
2471                            src_e.entity_type.as_deref(),
2472                            &tgt_e.kind,
2473                            tgt_e.entity_type.as_deref(),
2474                        );
2475                        let rule_hint = match relation {
2476                            EdgeRelation::Supports | EdgeRelation::Refutes => {
2477                                "requires concept|document|dataset|artifact -> concept \
2478                                 (or same-substrate note -> note)"
2479                            }
2480                            _ => "requires same-kind entity endpoints",
2481                        };
2482                        return Err(RuntimeError::InvalidInput(format!(
2483                            "({}) -[{rel_name}]-> ({}) is not in the base endpoint \
2484                             allowlist; {rel_name} {rule_hint}; currently legal relations for \
2485                             {} -> {} under the loaded endpoint rules: {legal_relations}",
2486                            src_e.kind, tgt_e.kind, src_e.kind, tgt_e.kind
2487                        )));
2488                    }
2489                }
2490                (Resolved::Note(_), Resolved::Note(_)) => {}
2491                (Resolved::Entity(_), Resolved::Note(_)) => {
2492                    return Err(RuntimeError::InvalidInput(format!(
2493                        "{rel_name} endpoints must be the same substrate \
2494                         (note→note or entity→entity); got source={source_id} (entity) \
2495                         target={target_id} (note)"
2496                    )));
2497                }
2498                (Resolved::Note(_), Resolved::Entity(_)) => {
2499                    return Err(RuntimeError::InvalidInput(format!(
2500                        "{rel_name} endpoints must be the same substrate \
2501                         (note→note or entity→entity); got source={source_id} (note) \
2502                         target={target_id} (entity)"
2503                    )));
2504                }
2505                (Resolved::PackRecord { .. }, _) | (_, Resolved::PackRecord { .. }) => {
2506                    return Err(RuntimeError::InvalidInput(format!(
2507                        "pack-private record is not a valid edge endpoint for {rel_name}"
2508                    )));
2509                }
2510                _ => {
2511                    return Err(RuntimeError::InvalidInput(format!(
2512                        "{rel_name} endpoints must be notes or entities (not events)"
2513                    )));
2514                }
2515            }
2516            return Ok(());
2517        }
2518
2519        // All remaining base relations: entity→entity with kind-level restrictions.
2520        // Consult pack rules installed on this (source) runtime first.
2521        let pack_rules = self.pack_edge_rules();
2522        if pack_rule_allows(&pack_rules, relation, src, tgt) {
2523            return Ok(());
2524        }
2525
2526        let (src_kind, src_entity_type) = match src {
2527            Some(Resolved::Entity(e)) => (e.kind.as_str(), e.entity_type.as_deref()),
2528            Some(_) => {
2529                return Err(RuntimeError::InvalidInput(format!(
2530                    "link source {source_id} must be an entity for relation {relation:?} \
2531                     (only `annotates` crosses substrates)"
2532                )));
2533            }
2534            None => {
2535                return Err(RuntimeError::NotFound(format!(
2536                    "link source {source_id} not found"
2537                )));
2538            }
2539        };
2540        let (tgt_kind, tgt_entity_type) = match tgt {
2541            Some(Resolved::Entity(e)) => (e.kind.as_str(), e.entity_type.as_deref()),
2542            Some(_) => {
2543                return Err(RuntimeError::InvalidInput(format!(
2544                    "link target {target_id} must be an entity for relation {relation:?} \
2545                     (only `annotates` crosses substrates)"
2546                )));
2547            }
2548            None => {
2549                return Err(RuntimeError::NotFound(format!(
2550                    "link target {target_id} not found"
2551                )));
2552            }
2553        };
2554
2555        if !base_entity_rule_allows(src_kind, relation, tgt_kind) {
2556            let legal_relations = accepted_entity_relations_description(
2557                &pack_rules,
2558                src_kind,
2559                src_entity_type,
2560                tgt_kind,
2561                tgt_entity_type,
2562            );
2563            return Err(RuntimeError::InvalidInput(format!(
2564                "({src_kind}) -[{}]-> ({tgt_kind}) is not in the base endpoint \
2565                 allowlist; use pack EDGE_RULES to extend the allowlist; currently legal relations \
2566                 for {src_kind} -> {tgt_kind} under the loaded endpoint rules: {legal_relations}",
2567                relation.as_str()
2568            )));
2569        }
2570
2571        Ok(())
2572    }
2573
2574    /// Validate an `annotates` edge relation using pre-located endpoint kinds.
2575    ///
2576    /// Sibling of [`Self::validate_link_endpoints_by_resolved`] for callers that
2577    /// only have an [`EdgeEndpointKind`] (entity/note/event/edge) rather than a
2578    /// full [`Resolved`] record — the `SubstrateCoordinator`'s cross-backend
2579    /// `locate_endpoint` resolves edge-substrate UUIDs too (matching `get`'s
2580    /// by-ID resolution order), but edges have no `Resolved` variant, so
2581    /// `validate_link_endpoints_by_resolved` cannot express them.
2582    ///
2583    /// `annotates` is the only relation this covers: source must be a note,
2584    /// target may be any substrate (entity, note, event, or edge).
2585    pub fn validate_annotates_endpoint_kinds(
2586        &self,
2587        source_id: Uuid,
2588        target_id: Uuid,
2589        source: Option<EdgeEndpointKind>,
2590        target: Option<EdgeEndpointKind>,
2591    ) -> RuntimeResult<()> {
2592        if source_id == target_id {
2593            return Err(RuntimeError::InvalidInput(
2594                "self-loop edges are not allowed: source_id and target_id must be different".into(),
2595            ));
2596        }
2597        match source {
2598            Some(EdgeEndpointKind::Note) => {}
2599            Some(_) => {
2600                return Err(RuntimeError::InvalidInput(format!(
2601                    "annotates source {source_id} must be a note"
2602                )));
2603            }
2604            None => {
2605                return Err(RuntimeError::NotFound(format!(
2606                    "link source {source_id} not found"
2607                )));
2608            }
2609        }
2610        if target.is_none() {
2611            return Err(RuntimeError::NotFound(format!(
2612                "link target {target_id} not found"
2613            )));
2614        }
2615        Ok(())
2616    }
2617
2618    /// Create a directed edge between two substrates.
2619    ///
2620    /// Enforces the three-case relation contract via
2621    /// `validate_edge_relation_endpoints`. See that method for the full contract.
2622    ///
2623    /// For symmetric relations (`competes_with`, `composed_with`) the endpoint
2624    /// pair is canonicalised to `source_uuid < target_uuid` so that A→B and B→A
2625    /// deduplicate to one row.
2626    ///
2627    /// `metadata` is validated against governed keys; `dependency_kind` is
2628    /// inferred for `depends_on` edges when absent.
2629    ///
2630    /// `target_backend` is always `None` for locally-routed edges written through
2631    /// this path. Both endpoints must exist in the local namespace, so setting
2632    /// `target_backend = None` is the only valid choice.
2633    ///
2634    /// Endpoint existence is a by-ID check and namespace-agnostic: a record
2635    /// that exists in a different namespace than the caller still resolves,
2636    /// exactly as `get()` would.
2637    pub async fn link(
2638        &self,
2639        token: &NamespaceToken,
2640        source_id: Uuid,
2641        target_id: Uuid,
2642        relation: EdgeRelation,
2643        weight: f64,
2644        metadata: Option<serde_json::Value>,
2645    ) -> RuntimeResult<Edge> {
2646        self.link_observed(
2647            token, source_id, target_id, relation, weight, metadata, false,
2648        )
2649        .await
2650        .map(|result| result.edge)
2651    }
2652
2653    /// Observable form of [`Self::link`]. Live natural-key conflicts retain
2654    /// the accepted replace semantics, while tombstones require the explicit
2655    /// `resurrect` opt-in. The returned preimage and disposition are derived
2656    /// inside the graph writer transaction and drive the lifecycle event.
2657    #[allow(clippy::too_many_arguments)]
2658    pub async fn link_observed(
2659        &self,
2660        token: &NamespaceToken,
2661        source_id: Uuid,
2662        target_id: Uuid,
2663        relation: EdgeRelation,
2664        weight: f64,
2665        metadata: Option<serde_json::Value>,
2666        resurrect: bool,
2667    ) -> RuntimeResult<EdgeUpsertResult> {
2668        validate_edge_weight(weight)?;
2669        self.validate_edge_relation_endpoints(token, source_id, target_id, relation)
2670            .await?;
2671        let (source_id, target_id) = canonical_edge_endpoints(relation, source_id, target_id);
2672        let metadata = if relation == EdgeRelation::DependsOn {
2673            // By-ID, unfiltered — matches the namespace-agnostic endpoint validation
2674            // above. The visible-set-scoped `resolve` would silently drop the
2675            // dependency_kind inference for endpoints validation now allows outside
2676            // the caller's visible set.
2677            match (
2678                self.resolve_edge_endpoint(token, source_id).await?,
2679                self.resolve_edge_endpoint(token, target_id).await?,
2680            ) {
2681                (Some(Resolved::Entity(src_e)), Some(Resolved::Entity(tgt_e))) => {
2682                    merge_dependency_kind(&src_e.kind, &tgt_e.kind, metadata)
2683                }
2684                _ => metadata,
2685            }
2686        } else {
2687            metadata
2688        };
2689        validate_edge_metadata(relation, metadata.as_ref())?;
2690        let now = chrono::Utc::now();
2691        let ns = token.namespace().as_str();
2692        let edge = Edge {
2693            id: LinkId::from(Uuid::new_v4()),
2694            namespace: ns.to_string(),
2695            source_id,
2696            target_id,
2697            relation,
2698            weight,
2699            created_at: now,
2700            updated_at: now,
2701            deleted_at: None,
2702            metadata,
2703            target_backend: None,
2704        };
2705        // `upsert_edge_guarded` re-checks both endpoints exist as part of the same
2706        // write, not the separate `validate_edge_relation_endpoints` read above: a
2707        // concurrent hard-delete landing between that read and this write can no
2708        // longer create a durably dangling edge. Which endpoint(s) were missing is
2709        // reported by the guard's own in-transaction probe (`GuardedWriteOutcome::
2710        // Refused`), not reconstructed here by re-reading the endpoints after the
2711        // fact: a second concurrent write landing between the refusal and a
2712        // post-hoc read could otherwise misreport which endpoint was actually
2713        // missing at write time.
2714        let result = match self
2715            .graph(token)?
2716            .upsert_edge_guarded_observed(EdgeUpsertRequest { edge, resurrect })
2717            .await?
2718        {
2719            GuardedEdgeUpsertOutcome::Written(result) => result,
2720            GuardedEdgeUpsertOutcome::Refused(EdgeUpsertRefusal::MissingEndpoints(missing)) => {
2721                return Err(RuntimeError::GuardedWriteFailed(GuardedWriteFailure {
2722                    entry_index: None,
2723                    missing_source: missing.source.then_some(source_id),
2724                    missing_target: missing.target.then_some(target_id),
2725                }));
2726            }
2727            GuardedEdgeUpsertOutcome::Refused(EdgeUpsertRefusal::ResurrectionRequired { edge }) => {
2728                return Err(RuntimeError::InvalidInput(format!(
2729                    "edge {} is soft-deleted; pass resurrect=true to link explicitly",
2730                    edge.id
2731                )))
2732            }
2733        };
2734        self.append_link_mutation_event(token, &result).await?;
2735        Ok(result)
2736    }
2737
2738    /// Write an edge with an explicit `target_backend` stamp (ADR-029 D3).
2739    ///
2740    /// Called by the `SubstrateCoordinator` when source and target are on
2741    /// different backends. The coordinator validates endpoints before calling
2742    /// this method via [`Self::validate_link_endpoints`], so endpoint validation is
2743    /// skipped here. The edge is written on the source backend only.
2744    #[allow(clippy::too_many_arguments)]
2745    pub async fn link_with_target_backend(
2746        &self,
2747        token: &NamespaceToken,
2748        source_id: Uuid,
2749        target_id: Uuid,
2750        relation: EdgeRelation,
2751        weight: f64,
2752        metadata: Option<serde_json::Value>,
2753        target_backend: Option<String>,
2754    ) -> RuntimeResult<Edge> {
2755        self.link_with_target_backend_observed(
2756            token,
2757            source_id,
2758            target_id,
2759            relation,
2760            weight,
2761            metadata,
2762            target_backend,
2763            false,
2764        )
2765        .await
2766        .map(|result| result.edge)
2767    }
2768
2769    /// Policy-aware cross-backend form of [`Self::link_observed`]. Endpoint
2770    /// validation remains the coordinator's responsibility; mutation
2771    /// classification and tombstone handling stay inside the source store.
2772    #[allow(clippy::too_many_arguments)]
2773    pub async fn link_with_target_backend_observed(
2774        &self,
2775        token: &NamespaceToken,
2776        source_id: Uuid,
2777        target_id: Uuid,
2778        relation: EdgeRelation,
2779        weight: f64,
2780        metadata: Option<serde_json::Value>,
2781        target_backend: Option<String>,
2782        resurrect: bool,
2783    ) -> RuntimeResult<EdgeUpsertResult> {
2784        validate_edge_weight(weight)?;
2785        let (source_id, target_id) = canonical_edge_endpoints(relation, source_id, target_id);
2786        validate_edge_metadata(relation, metadata.as_ref())?;
2787        let now = chrono::Utc::now();
2788        let ns = token.namespace().as_str();
2789        let edge = Edge {
2790            id: LinkId::from(Uuid::new_v4()),
2791            namespace: ns.to_string(),
2792            source_id,
2793            target_id,
2794            relation,
2795            weight,
2796            created_at: now,
2797            updated_at: now,
2798            deleted_at: None,
2799            metadata,
2800            target_backend,
2801        };
2802        let result = self
2803            .graph(token)?
2804            .upsert_edge_observed(EdgeUpsertRequest { edge, resurrect })
2805            .await
2806            .map_err(|error| {
2807                if matches!(error, khive_storage::StorageError::Conflict { .. }) {
2808                    RuntimeError::InvalidInput(format!(
2809                        "edge natural key is soft-deleted; pass resurrect=true to link explicitly: {error}"
2810                    ))
2811                } else {
2812                    error.into()
2813                }
2814            })?;
2815        self.append_link_mutation_event(token, &result).await?;
2816        Ok(result)
2817    }
2818
2819    async fn append_link_mutation_event(
2820        &self,
2821        token: &NamespaceToken,
2822        result: &EdgeUpsertResult,
2823    ) -> RuntimeResult<()> {
2824        let kind = match result.disposition {
2825            EdgeUpsertDisposition::Created => EventKind::LinkCreated,
2826            EdgeUpsertDisposition::Updated | EdgeUpsertDisposition::Resurrected => {
2827                EventKind::EdgeUpdated
2828            }
2829        };
2830        let edge_id = Uuid::from(result.edge.id);
2831        let actor = format!("{}:{}", token.actor().kind, token.actor().id);
2832        let event = khive_storage::event::Event::new(
2833            result.edge.namespace.clone(),
2834            "link",
2835            kind,
2836            SubstrateKind::Entity,
2837            actor,
2838        )
2839        .with_target(edge_id)
2840        .with_payload(serde_json::json!({
2841            "id": edge_id,
2842            "namespace": result.edge.namespace,
2843            "mutation": result.disposition.name(),
2844            "source_id": result.edge.source_id,
2845            "target_id": result.edge.target_id,
2846            "relation": result.edge.relation,
2847            "weight": result.edge.weight,
2848            "metadata": result.edge.metadata,
2849            "previous": result.previous,
2850        }));
2851        self.events(token)?
2852            .append_event(event)
2853            .await
2854            .map_err(|error| {
2855                RuntimeError::Internal(format!("link: lifecycle event write failed: {error}"))
2856            })
2857    }
2858
2859    /// Returns `true` if `id` resolves to a live substrate record in the
2860    /// caller's visible namespace set.
2861    ///
2862    /// Covers entity, note, event (via `resolve`) and edge (via `get_edge_visible`).
2863    /// Only records that are accessible to the caller (primary or configured visible
2864    /// namespaces) return `true`; absent or foreign-invisible records return `false`.
2865    pub(crate) async fn substrate_exists_in_ns(
2866        &self,
2867        token: &NamespaceToken,
2868        id: Uuid,
2869    ) -> RuntimeResult<bool> {
2870        if self.resolve(token, id).await?.is_some() {
2871            return Ok(true);
2872        }
2873        match self.get_edge_visible(token, id).await {
2874            Ok(Some(_)) => Ok(true),
2875            Ok(None) | Err(RuntimeError::NotFound(_)) => Ok(false),
2876            Err(err) => Err(err),
2877        }
2878    }
2879
2880    /// Returns `true` if `id` resolves to a live substrate record, by ID, with
2881    /// no namespace filter.
2882    ///
2883    /// Used from `annotates` endpoint validation (`link` and `create`'s
2884    /// `annotates` targets), which consume a by-ID endpoint and so must follow
2885    /// the same namespace-agnostic by-ID contract as `get()`.
2886    pub(crate) async fn substrate_exists_by_id(
2887        &self,
2888        token: &NamespaceToken,
2889        id: Uuid,
2890    ) -> RuntimeResult<bool> {
2891        if self.resolve_edge_endpoint(token, id).await?.is_some() {
2892            return Ok(true);
2893        }
2894        match self.get_edge(token, id).await {
2895            Ok(Some(_)) => Ok(true),
2896            Ok(None) | Err(RuntimeError::NotFound(_)) => Ok(false),
2897            Err(err) => Err(err),
2898        }
2899    }
2900
2901    /// Find the newest live annotation note with an exact kind and tag across
2902    /// the token's visible edge namespaces, on this runtime's bound backend.
2903    /// Each store selects one eligible candidate before returning; note bodies
2904    /// and the complete annotation history are never hydrated here.
2905    pub async fn latest_annotating_note(
2906        &self,
2907        token: &NamespaceToken,
2908        node_id: Uuid,
2909        kind: &str,
2910        tag: &str,
2911    ) -> RuntimeResult<Option<Uuid>> {
2912        if !self.substrate_exists_in_ns(token, node_id).await? {
2913            return Ok(None);
2914        }
2915        let mut latest: Option<(Uuid, i64)> = None;
2916        for namespace in token.visible_namespaces() {
2917            let scoped = NamespaceToken::for_namespace(namespace.clone());
2918            if let Some(candidate) = self
2919                .graph(&scoped)?
2920                .latest_annotating_note(node_id, kind, tag)
2921                .await?
2922            {
2923                if latest.is_none_or(|(id, created_at)| {
2924                    candidate.1 > created_at || (candidate.1 == created_at && candidate.0 < id)
2925                }) {
2926                    latest = Some(candidate);
2927                }
2928            }
2929        }
2930        Ok(latest.map(|(id, _)| id))
2931    }
2932
2933    /// Get immediate neighbors of a node, optionally filtered by relation type.
2934    ///
2935    /// Pass `relations: Some(vec![EdgeRelation::Annotates])` to retrieve only
2936    /// annotation edges, enabling cross-substrate navigation.
2937    ///
2938    /// Symmetric relations (`competes_with`, `composed_with`) are stored
2939    /// with the canonical source as the lower UUID. Direction normalization is
2940    /// applied in `neighbors_with_query` so both callers see correct results.
2941    pub async fn neighbors(
2942        &self,
2943        token: &NamespaceToken,
2944        node_id: Uuid,
2945        direction: Direction,
2946        limit: Option<u32>,
2947        relations: Option<Vec<EdgeRelation>>,
2948    ) -> RuntimeResult<Vec<NeighborHit>> {
2949        self.neighbors_with_query(
2950            token,
2951            node_id,
2952            NeighborQuery {
2953                direction,
2954                relations,
2955                limit,
2956                min_weight: None,
2957            },
2958        )
2959        .await
2960    }
2961
2962    /// Get neighbors with full query control (includes `min_weight`).
2963    ///
2964    /// Applies symmetric-relation direction normalization: if the
2965    /// relations filter contains only symmetric relations the direction is
2966    /// overridden to `Both` so edges stored in canonical order are always found.
2967    ///
2968    /// Soft-deleted entity nodes are excluded from results unless the caller
2969    /// explicitly requested them (future: `include_deleted` flag; currently
2970    /// always false).
2971    pub async fn neighbors_with_query(
2972        &self,
2973        token: &NamespaceToken,
2974        node_id: Uuid,
2975        query: NeighborQuery,
2976    ) -> RuntimeResult<Vec<NeighborHit>> {
2977        self.neighbors_with_query_page(token, node_id, query, None, None, true)
2978            .await
2979    }
2980
2981    /// Get a deterministic neighbor page, optionally applying a continuation
2982    /// cursor and filtering entity/note kinds before the storage limit.
2983    /// `enrich` is false for the lightweight edge projection.
2984    pub async fn neighbors_with_query_page(
2985        &self,
2986        token: &NamespaceToken,
2987        node_id: Uuid,
2988        mut query: NeighborQuery,
2989        after: Option<NeighborCursor>,
2990        neighbor_kinds: Option<Vec<String>>,
2991        enrich: bool,
2992    ) -> RuntimeResult<Vec<NeighborHit>> {
2993        // A full-UUID anchor follows get's by-ID lookup. Only the adjacency
2994        // expansion below is scoped to the caller's visible namespaces.
2995        if !self.substrate_exists_by_id(token, node_id).await? {
2996            return Err(RuntimeError::NotFound(format!(
2997                "neighbor anchor {node_id} not found"
2998            )));
2999        }
3000
3001        query.direction =
3002            normalize_symmetric_direction(query.direction, query.relations.as_deref());
3003        let mut hits = Vec::new();
3004        for ns in token.visible_namespaces() {
3005            let temp = NamespaceToken::for_namespace(ns.clone());
3006            let mut ns_hits = self
3007                .graph(&temp)?
3008                .neighbors_page(node_id, query.clone(), after, neighbor_kinds.clone())
3009                .await?;
3010            hits.append(&mut ns_hits);
3011        }
3012        hits.sort_by_key(|h| (h.node_id, h.edge_id));
3013        hits.dedup_by_key(|h| (h.node_id, h.edge_id));
3014        if enrich {
3015            self.enrich_neighbor_hits(token, &mut hits).await;
3016        }
3017        // Filter out soft-deleted entity nodes.
3018        let candidate_ids: Vec<Uuid> = hits.iter().map(|h| h.node_id).collect();
3019        let deleted = self.deleted_entity_ids(candidate_ids).await?;
3020        if !deleted.is_empty() {
3021            hits.retain(|h| !deleted.contains(&h.node_id));
3022        }
3023        // Restore the weight-descending, node_id-ascending order the storage
3024        // layer established (khive-db graph.rs `ORDER BY weight DESC, node_id
3025        // ASC`) — the (node_id, edge_id) sort above exists only to make
3026        // `dedup_by_key` adjacent-comparable and otherwise discards it. This
3027        // ordering contract must hold at every call site of this op (context
3028        // and neighbors verb alike), for every direction.
3029        hits.sort_by(|a, b| {
3030            b.weight
3031                .partial_cmp(&a.weight)
3032                .unwrap_or(std::cmp::Ordering::Equal)
3033                .then(a.node_id.cmp(&b.node_id))
3034                .then(a.edge_id.cmp(&b.edge_id))
3035        });
3036        Ok(hits)
3037    }
3038
3039    /// Find live `annotates` edges targeting one record without applying a
3040    /// namespace predicate.
3041    ///
3042    /// This is the graph counterpart to the namespace-agnostic by-ID `get`
3043    /// contract (ADR-007 Rev 6). Multi-record neighbor traversal remains
3044    /// visibility-scoped; callers should use this only after resolving a live
3045    /// target through a by-ID operation.
3046    pub async fn annotation_neighbors_by_target_id(
3047        &self,
3048        target_id: Uuid,
3049    ) -> RuntimeResult<Vec<NeighborHit>> {
3050        let mut reader = self.sql().reader().await?;
3051        let rows = reader
3052            .query_all(SqlStatement {
3053                sql: "SELECT source_id, id, weight FROM graph_edges \
3054                      WHERE target_id = ?1 AND relation = ?2 AND deleted_at IS NULL \
3055                      ORDER BY weight DESC, source_id ASC"
3056                    .to_string(),
3057                params: vec![
3058                    SqlValue::Text(target_id.to_string()),
3059                    SqlValue::Text(EdgeRelation::Annotates.to_string()),
3060                ],
3061                label: Some("annotations.by_target_id_unfiltered".into()),
3062            })
3063            .await?;
3064
3065        rows.into_iter()
3066            .map(|row| {
3067                let parse_uuid = |name: &str| match row.get(name) {
3068                    Some(SqlValue::Text(value)) => Uuid::from_str(value).map_err(|error| {
3069                        RuntimeError::Internal(format!("graph_edges.{name} is not a UUID: {error}"))
3070                    }),
3071                    Some(value) => Err(RuntimeError::Internal(format!(
3072                        "graph_edges.{name} has unexpected SQL value {value:?}"
3073                    ))),
3074                    None => Err(RuntimeError::Internal(format!(
3075                        "graph_edges row missing {name}"
3076                    ))),
3077                };
3078                let weight = match row.get("weight") {
3079                    Some(SqlValue::Float(value)) => Ok(*value),
3080                    Some(value) => Err(RuntimeError::Internal(format!(
3081                        "graph_edges.weight has unexpected SQL value {value:?}"
3082                    ))),
3083                    None => Err(RuntimeError::Internal(
3084                        "graph_edges row missing weight".into(),
3085                    )),
3086                }?;
3087
3088                Ok(NeighborHit {
3089                    node_id: parse_uuid("source_id")?,
3090                    edge_id: parse_uuid("id")?,
3091                    relation: EdgeRelation::Annotates,
3092                    weight,
3093                    name: None,
3094                    kind: None,
3095                    entity_type: None,
3096                })
3097            })
3098            .collect()
3099    }
3100
3101    /// Get both-direction neighbors, each tagged with the direction (`Out`/
3102    /// `In`) it was found in, via a single storage query per visible
3103    /// namespace instead of two separate direction-scoped `neighbors_with_query`
3104    /// calls: halving the neighbor SELECT count for `context(direction="both")`
3105    /// expansion. `query.direction` is ignored: always both.
3106    ///
3107    /// Mirrors `neighbors_with_query`'s dedup/enrich/soft-delete-filter/order
3108    /// pipeline exactly, carrying the per-hit direction tag through unchanged.
3109    pub async fn neighbors_with_query_directed(
3110        &self,
3111        token: &NamespaceToken,
3112        node_id: Uuid,
3113        query: NeighborQuery,
3114    ) -> RuntimeResult<Vec<(NeighborHit, Direction)>> {
3115        if !self.substrate_exists_by_id(token, node_id).await? {
3116            return Err(RuntimeError::NotFound(format!(
3117                "neighbor anchor {node_id} not found"
3118            )));
3119        }
3120
3121        let mut hits: Vec<DirectedNeighborHit> = Vec::new();
3122        for ns in token.visible_namespaces() {
3123            let temp = NamespaceToken::for_namespace(ns.clone());
3124            let mut ns_hits = self
3125                .graph(&temp)?
3126                .neighbors_both_directions(node_id, query.clone())
3127                .await?;
3128            hits.append(&mut ns_hits);
3129        }
3130        // Direction is part of the key (not just node_id/edge_id) so a
3131        // self-loop's Out row and In row — same node_id and edge_id, opposite
3132        // direction: sort adjacent but distinct and both survive dedup.
3133        hits.sort_by_key(|h| {
3134            (
3135                h.hit.node_id,
3136                h.hit.edge_id,
3137                direction_sort_rank(&h.direction),
3138            )
3139        });
3140        hits.dedup_by_key(|h| {
3141            (
3142                h.hit.node_id,
3143                h.hit.edge_id,
3144                direction_sort_rank(&h.direction),
3145            )
3146        });
3147
3148        let mut plain_hits: Vec<NeighborHit> = hits.iter().map(|h| h.hit.clone()).collect();
3149        self.enrich_neighbor_hits(token, &mut plain_hits).await;
3150        for (dh, enriched) in hits.iter_mut().zip(plain_hits) {
3151            dh.hit = enriched;
3152        }
3153
3154        // Filter out soft-deleted entity nodes.
3155        let candidate_ids: Vec<Uuid> = hits.iter().map(|h| h.hit.node_id).collect();
3156        let deleted = self.deleted_entity_ids(candidate_ids).await?;
3157        if !deleted.is_empty() {
3158            hits.retain(|h| !deleted.contains(&h.hit.node_id));
3159        }
3160        // Same global weight-descending/node_id-ascending restore as
3161        // `neighbors_with_query`: the (node_id, edge_id, direction) sort above
3162        // exists only to make `dedup_by_key` adjacent-comparable.
3163        hits.sort_by(|a, b| {
3164            b.hit
3165                .weight
3166                .partial_cmp(&a.hit.weight)
3167                .unwrap_or(std::cmp::Ordering::Equal)
3168                .then(a.hit.node_id.cmp(&b.hit.node_id))
3169                .then(a.hit.edge_id.cmp(&b.hit.edge_id))
3170        });
3171        Ok(hits.into_iter().map(|h| (h.hit, h.direction)).collect())
3172    }
3173
3174    /// Traverse the graph from a set of root nodes.
3175    ///
3176    /// Full-UUID roots use the by-ID contract; expansion and returned edges
3177    /// remain scoped to the caller's visible namespaces. Missing roots refuse.
3178    /// Soft-deleted entity nodes are excluded from results.
3179    pub async fn traverse(
3180        &self,
3181        token: &NamespaceToken,
3182        request: TraversalRequest,
3183    ) -> RuntimeResult<Vec<GraphPath>> {
3184        let mut request = request;
3185        request.validate().map_err(RuntimeError::InvalidInput)?;
3186        let mut roots = Vec::with_capacity(request.roots.len());
3187        let mut seen_roots = std::collections::HashSet::with_capacity(request.roots.len());
3188        for root in request.roots.drain(..) {
3189            if seen_roots.insert(root) {
3190                if !self.substrate_exists_by_id(token, root).await? {
3191                    return Err(RuntimeError::NotFound(format!(
3192                        "traverse root {root} not found"
3193                    )));
3194                }
3195                roots.push(root);
3196            }
3197        }
3198        request.roots = roots;
3199        if request.roots.is_empty() {
3200            return Ok(Vec::new());
3201        }
3202
3203        let mut paths = Vec::new();
3204        for ns in token.visible_namespaces() {
3205            let temp = NamespaceToken::for_namespace(ns.clone());
3206            let mut ns_paths = self.graph(&temp)?.traverse(request.clone()).await?;
3207            paths.append(&mut ns_paths);
3208        }
3209        // Reconcile the per-namespace GraphPaths back down to one per
3210        // distinct root_id (see merge_traversal_paths_by_root for why this
3211        // is needed and what it enforces).
3212        let mut paths =
3213            merge_traversal_paths_by_root(paths, Some(request.options.effective_limit()));
3214        self.enrich_path_nodes(token, &mut paths, request.include_properties)
3215            .await;
3216        // Filter out soft-deleted entity nodes from all path nodes.
3217        let all_node_ids: Vec<Uuid> = paths
3218            .iter()
3219            .flat_map(|p| p.nodes.iter().map(|n| n.node_id))
3220            .collect();
3221        let deleted = self.deleted_entity_ids(all_node_ids).await?;
3222        if !deleted.is_empty() {
3223            for path in paths.iter_mut() {
3224                path.nodes.retain(|n| !deleted.contains(&n.node_id));
3225                recompute_total_weight(path);
3226            }
3227            paths.retain(|p| !p.nodes.is_empty());
3228        }
3229        Ok(paths)
3230    }
3231
3232    /// Batch-query for soft-deleted UUIDs in `ids`, across BOTH the entities
3233    /// and notes tables.
3234    ///
3235    /// Neighbor/traverse candidates can be note-kind nodes (e.g. reached via
3236    /// `annotates` edges) as well as entities; a screen that only consults
3237    /// `entities` lets soft-deleted note targets leak through and hydrate as
3238    /// blank/missing hits. This is a view-layer read-only screen: it does
3239    /// not touch edges or mutate any data.
3240    ///
3241    /// Returns the subset of `ids` that have `deleted_at IS NOT NULL` in
3242    /// either table. Takes `Vec<Uuid>` (not an iterator) so the async state
3243    /// machine holds only owned data — no iterator borrow across yields.
3244    ///
3245    /// Propagates reader-admission and other storage errors instead of
3246    /// treating them as "nothing is deleted": on a saturated pool this query
3247    /// now runs on the bounded reader pool like any other read, and silently
3248    /// swallowing its failure would let soft-deleted nodes back into
3249    /// `neighbors`/`traverse` results instead of surfacing the retryable
3250    /// admission failure those callers otherwise promise.
3251    async fn deleted_entity_ids(
3252        &self,
3253        ids: Vec<Uuid>,
3254    ) -> RuntimeResult<std::collections::HashSet<Uuid>> {
3255        if ids.is_empty() {
3256            return Ok(std::collections::HashSet::new());
3257        }
3258        let id_strs: Vec<String> = ids.iter().map(|u| u.to_string()).collect();
3259        let n = id_strs.len();
3260        // Each UNION half gets its OWN numbered-placeholder block (?1..?n for
3261        // entities, ?(n+1)..?(2n) for notes) — numbered SQLite params bind by
3262        // index, so reusing the same numbers across halves would silently
3263        // collapse to a single shared block instead of binding the full list
3264        // twice (see khive-db/src/stores/graph.rs batch_neighbors: "each half
3265        // is a fully independent positional-parameter block").
3266        let entities_placeholders = (0..n)
3267            .map(|i| format!("?{}", i + 1))
3268            .collect::<Vec<_>>()
3269            .join(",");
3270        let notes_placeholders = (0..n)
3271            .map(|i| format!("?{}", n + i + 1))
3272            .collect::<Vec<_>>()
3273            .join(",");
3274        let sql_str = format!(
3275            "SELECT id FROM entities WHERE id IN ({entities_placeholders}) AND deleted_at IS NOT NULL \
3276             UNION \
3277             SELECT id FROM notes WHERE id IN ({notes_placeholders}) AND deleted_at IS NOT NULL"
3278        );
3279        // Same id list bound twice — once per UNION arm's independent placeholder block.
3280        let params: Vec<SqlValue> = id_strs
3281            .iter()
3282            .chain(id_strs.iter())
3283            .cloned()
3284            .map(SqlValue::Text)
3285            .collect();
3286        let stmt = SqlStatement {
3287            sql: sql_str,
3288            params,
3289            label: Some("deleted_entity_ids".into()),
3290        };
3291        let mut out = std::collections::HashSet::new();
3292        let sql = self.sql();
3293        let mut reader = sql.reader().await?;
3294        let rows = reader.query_all(stmt).await?;
3295        for row in rows {
3296            if let Some(col) = row.columns.first() {
3297                if let SqlValue::Text(s) = &col.value {
3298                    if let Ok(u) = s.parse::<Uuid>() {
3299                        out.insert(u);
3300                    }
3301                }
3302            }
3303        }
3304        Ok(out)
3305    }
3306
3307    /// Populate `name` and `kind` on each `NeighborHit` from the corresponding
3308    /// entity or note record. Best-effort: unresolved IDs leave the fields `None`.
3309    ///
3310    /// Uses a single batched entity lookup via `get_entities_by_ids_visible`
3311    /// (scoped to the token's full visible-namespace set so that neighbors in
3312    /// extra-visible namespaces are enriched), then a batched note lookup
3313    /// (`get_notes_batch`) for the residual IDs not resolved as entities.
3314    /// Order and identity of hits is preserved via `HashMap` re-index.
3315    async fn enrich_neighbor_hits(&self, token: &NamespaceToken, hits: &mut [NeighborHit]) {
3316        if hits.is_empty() {
3317            return;
3318        }
3319
3320        // Deduplicated IDs for the batch call.
3321        let unique_ids: Vec<Uuid> = {
3322            let mut seen = std::collections::HashSet::new();
3323            hits.iter()
3324                .filter_map(|h| {
3325                    if seen.insert(h.node_id) {
3326                        Some(h.node_id)
3327                    } else {
3328                        None
3329                    }
3330                })
3331                .collect()
3332        };
3333
3334        let entity_map: HashMap<Uuid, Entity> = self
3335            .get_entities_by_ids_visible(token, &unique_ids)
3336            .await
3337            .unwrap_or_default()
3338            .into_iter()
3339            .map(|e| (e.id, e))
3340            .collect();
3341
3342        // Batch note lookup for IDs not found as entities.
3343        let residual_ids: Vec<Uuid> = unique_ids
3344            .iter()
3345            .filter(|id| !entity_map.contains_key(id))
3346            .copied()
3347            .collect();
3348
3349        let note_map: HashMap<Uuid, Note> = if !residual_ids.is_empty() {
3350            if let Ok(store) = self.notes(token) {
3351                store
3352                    .get_notes_batch(&residual_ids)
3353                    .await
3354                    .unwrap_or_default()
3355                    .into_iter()
3356                    .map(|n| (n.id, n))
3357                    .collect()
3358            } else {
3359                HashMap::new()
3360            }
3361        } else {
3362            HashMap::new()
3363        };
3364
3365        for hit in hits.iter_mut() {
3366            if let Some(entity) = entity_map.get(&hit.node_id) {
3367                hit.name = Some(entity.name.clone());
3368                hit.kind = Some(entity.kind.clone());
3369                hit.entity_type = entity.entity_type.clone();
3370            } else if let Some(note) = note_map.get(&hit.node_id) {
3371                hit.name = Some(note_graph_name(note));
3372                hit.kind = Some(note.kind.clone());
3373            }
3374        }
3375    }
3376
3377    /// Populate `name` and `kind` on each `PathNode` from the corresponding
3378    /// entity or note record. Same best-effort policy as `enrich_neighbor_hits`.
3379    ///
3380    /// Uses `get_entities_by_ids_visible` so that path nodes whose entities
3381    /// live in extra-visible namespaces are enriched correctly. Node IDs that
3382    /// repeat across paths are fetched exactly once.
3383    ///
3384    /// `include_properties` gates whether `entity.properties` is cloned onto
3385    /// each node. When `false` (the default), the potentially large JSON blob
3386    /// is never read from the map, keeping the hot path allocation-free.
3387    async fn enrich_path_nodes(
3388        &self,
3389        token: &NamespaceToken,
3390        paths: &mut [GraphPath],
3391        include_properties: bool,
3392    ) {
3393        if paths.is_empty() {
3394            return;
3395        }
3396
3397        // Deduplicate node IDs across all paths before the batch call.
3398        let unique_ids: Vec<Uuid> = {
3399            let mut seen = std::collections::HashSet::new();
3400            paths
3401                .iter()
3402                .flat_map(|p| p.nodes.iter())
3403                .filter_map(|n| {
3404                    if seen.insert(n.node_id) {
3405                        Some(n.node_id)
3406                    } else {
3407                        None
3408                    }
3409                })
3410                .collect()
3411        };
3412
3413        let entity_map: HashMap<Uuid, Entity> = self
3414            .get_entities_by_ids_visible(token, &unique_ids)
3415            .await
3416            .unwrap_or_default()
3417            .into_iter()
3418            .map(|e| (e.id, e))
3419            .collect();
3420
3421        let residual_ids: Vec<Uuid> = unique_ids
3422            .iter()
3423            .filter(|id| !entity_map.contains_key(id))
3424            .copied()
3425            .collect();
3426
3427        let note_map: HashMap<Uuid, Note> = if !residual_ids.is_empty() {
3428            if let Ok(store) = self.notes(token) {
3429                store
3430                    .get_notes_batch(&residual_ids)
3431                    .await
3432                    .unwrap_or_default()
3433                    .into_iter()
3434                    .map(|n| (n.id, n))
3435                    .collect()
3436            } else {
3437                HashMap::new()
3438            }
3439        } else {
3440            HashMap::new()
3441        };
3442
3443        for path in paths.iter_mut() {
3444            for node in path.nodes.iter_mut() {
3445                if let Some(entity) = entity_map.get(&node.node_id) {
3446                    node.name = Some(entity.name.clone());
3447                    node.kind = Some(entity.kind.clone());
3448                    if include_properties {
3449                        node.properties = entity.properties.clone();
3450                    }
3451                } else if let Some(note) = note_map.get(&node.node_id) {
3452                    node.name = Some(note_graph_name(note));
3453                    node.kind = Some(note.kind.clone());
3454                }
3455            }
3456        }
3457    }
3458
3459    // ---- Note operations ----
3460
3461    /// Create and persist a note, optionally with properties and annotation targets.
3462    ///
3463    /// After creating the note:
3464    /// - Always indexes into FTS5 at the `notes_<namespace>` key.
3465    /// - If an embedding model is configured, indexes into the vector store with
3466    ///   `SubstrateKind::Note`.
3467    /// - For each UUID in `annotates`, creates an `EdgeRelation::Annotates` edge from
3468    ///   the note to that target.
3469    // REASON: note creation requires kind, name, content, salience, properties, annotates,
3470    // and namespace token — mirrors the MCP verb surface; a builder would not reduce
3471    // caller complexity for pack handler callers.
3472    #[allow(clippy::too_many_arguments)]
3473    pub async fn create_note(
3474        &self,
3475        token: &NamespaceToken,
3476        kind: &str,
3477        name: Option<&str>,
3478        content: &str,
3479        salience: Option<f64>,
3480        properties: Option<serde_json::Value>,
3481        annotates: Vec<Uuid>,
3482    ) -> RuntimeResult<Note> {
3483        Ok(self
3484            .create_note_inner(
3485                token, kind, name, content, None, salience, None, properties, annotates, None,
3486            )
3487            .await?
3488            .0)
3489    }
3490
3491    /// Like [`Self::create_note`], but lets the caller supply a smaller text
3492    /// to send to the vector embedder while the note's stored/FTS-indexed
3493    /// `content` remains the full text.
3494    ///
3495    /// `embedding_content`, when `Some`, must be non-empty and a proper
3496    /// prefix of `content` — anything else is rejected with `InvalidInput`
3497    /// before any write. `None` behaves exactly like [`Self::create_note`].
3498    /// Use this when `content` may exceed an embedder's input cap (e.g. a
3499    /// very long commit message) and only a capped head prefix should be
3500    /// embedded, while the full text is still stored and searchable via FTS.
3501    #[allow(clippy::too_many_arguments)]
3502    pub async fn create_note_with_embedding_content(
3503        &self,
3504        token: &NamespaceToken,
3505        kind: &str,
3506        name: Option<&str>,
3507        content: &str,
3508        embedding_content: Option<&str>,
3509        salience: Option<f64>,
3510        properties: Option<serde_json::Value>,
3511        annotates: Vec<Uuid>,
3512    ) -> RuntimeResult<Note> {
3513        Ok(self
3514            .create_note_inner(
3515                token,
3516                kind,
3517                name,
3518                content,
3519                embedding_content,
3520                salience,
3521                None,
3522                properties,
3523                annotates,
3524                None,
3525            )
3526            .await?
3527            .0)
3528    }
3529
3530    #[allow(clippy::too_many_arguments)]
3531    pub async fn create_note_with_embedding_content_and_report(
3532        &self,
3533        token: &NamespaceToken,
3534        kind: &str,
3535        name: Option<&str>,
3536        content: &str,
3537        embedding_content: Option<&str>,
3538        salience: Option<f64>,
3539        properties: Option<serde_json::Value>,
3540        annotates: Vec<Uuid>,
3541    ) -> RuntimeResult<(Note, crate::retrieval::EmbeddingTruncationReport)> {
3542        self.create_note_inner(
3543            token,
3544            kind,
3545            name,
3546            content,
3547            embedding_content,
3548            salience,
3549            None,
3550            properties,
3551            annotates,
3552            None,
3553        )
3554        .await
3555    }
3556
3557    /// Like [`Self::create_note`] but also sets a non-zero decay factor on the note.
3558    // REASON: extends create_note with an additional decay_factor parameter; same
3559    // rationale — mirrors the MCP surface and reduces an extra builder layer.
3560    #[allow(clippy::too_many_arguments)]
3561    pub async fn create_note_with_decay(
3562        &self,
3563        token: &NamespaceToken,
3564        kind: &str,
3565        name: Option<&str>,
3566        content: &str,
3567        salience: Option<f64>,
3568        decay_factor: f64,
3569        properties: Option<serde_json::Value>,
3570        annotates: Vec<Uuid>,
3571    ) -> RuntimeResult<Note> {
3572        self.create_note_with_decay_for_embedding_model(
3573            token,
3574            kind,
3575            name,
3576            content,
3577            salience,
3578            decay_factor,
3579            properties,
3580            annotates,
3581            None,
3582        )
3583        .await
3584    }
3585
3586    /// Like [`Self::create_note_with_decay`] but targets a specific embedding model.
3587    // REASON: adds an embedding_model parameter to the decay variant; the full parameter
3588    // set is required for correct MCP verb routing and cannot be collapsed without
3589    // introducing a separate config struct that would obscure call sites.
3590    #[allow(clippy::too_many_arguments)]
3591    pub async fn create_note_with_decay_for_embedding_model(
3592        &self,
3593        token: &NamespaceToken,
3594        kind: &str,
3595        name: Option<&str>,
3596        content: &str,
3597        salience: Option<f64>,
3598        decay_factor: f64,
3599        properties: Option<serde_json::Value>,
3600        annotates: Vec<Uuid>,
3601        embedding_model: Option<&str>,
3602    ) -> RuntimeResult<Note> {
3603        Ok(self
3604            .create_note_inner(
3605                token,
3606                kind,
3607                name,
3608                content,
3609                None,
3610                salience,
3611                Some(decay_factor),
3612                properties,
3613                annotates,
3614                embedding_model,
3615            )
3616            .await?
3617            .0)
3618    }
3619
3620    /// Insert a note using `INSERT OR IGNORE` semantics for atomic deduplication.
3621    ///
3622    /// Returns `Ok(Some(note))` when the note was newly written.  Returns
3623    /// `Ok(None)` when a unique constraint (e.g. the `external_id` partial
3624    /// index on comm message notes) was already satisfied by an existing row,
3625    /// making this call a no-op.  FTS indexing and vector embedding are
3626    /// attempted on success but treated as best-effort: failures are logged
3627    /// and do not abort the write.
3628    ///
3629    /// This method is intentionally narrower than `create_note`: it skips
3630    /// salience/decay, annotates edges, and embedding-model selection, which
3631    /// are not needed for channel-ingest paths.
3632    ///
3633    /// Rejects `quarantined` / `channel_kind` / `channel_slug` on a `message`
3634    /// note: those three properties are transport-owned evidence that
3635    /// `comm.health` trusts at face value, and this fast path (unlike the
3636    /// generic `create` verb funnel) is not covered by the
3637    /// pack-installed note-write validator. Only the trusted channel-ingest
3638    /// path may establish them — see
3639    /// [`Self::try_create_note_as_trusted_ingest`].
3640    pub async fn try_create_note(
3641        &self,
3642        token: &NamespaceToken,
3643        kind: &str,
3644        name: Option<&str>,
3645        content: &str,
3646        properties: Option<serde_json::Value>,
3647    ) -> RuntimeResult<Option<Note>> {
3648        self.try_create_note_impl(token, kind, name, content, properties, false)
3649            .await
3650    }
3651
3652    /// Like [`Self::try_create_note`] but permits the caller to establish the
3653    /// transport-owned `message` properties (`quarantined`, `channel_kind`,
3654    /// `channel_slug`).
3655    ///
3656    /// This is a deliberately named, separate entry point rather than a flag
3657    /// on `try_create_note` so the trust decision is visible at every call
3658    /// site: `comm.ingest` (`khive-pack-comm/src/handlers.rs`) is the sole
3659    /// legitimate caller, because it is the only code that has just derived
3660    /// quarantine disposition and channel provenance from the inbound
3661    /// transport itself. The caller set is bounded by possession, not
3662    /// documentation: the required [`crate::ChannelIngestCapability`] is
3663    /// constructible only inside this crate and granted at pack registration
3664    /// exclusively to channel-transport packs. Every other write path uses
3665    /// `try_create_note`, which rejects those three properties
3666    /// unconditionally.
3667    pub async fn try_create_note_as_trusted_ingest(
3668        &self,
3669        _capability: &crate::pack::ChannelIngestCapability,
3670        token: &NamespaceToken,
3671        kind: &str,
3672        name: Option<&str>,
3673        content: &str,
3674        properties: Option<serde_json::Value>,
3675    ) -> RuntimeResult<Option<Note>> {
3676        self.try_create_note_impl(token, kind, name, content, properties, true)
3677            .await
3678    }
3679
3680    #[allow(clippy::too_many_arguments)]
3681    async fn try_create_note_impl(
3682        &self,
3683        token: &NamespaceToken,
3684        kind: &str,
3685        name: Option<&str>,
3686        content: &str,
3687        properties: Option<serde_json::Value>,
3688        allow_transport_owned_message_properties: bool,
3689    ) -> RuntimeResult<Option<Note>> {
3690        self.validate_note_kind(kind)?;
3691        crate::secret_gate::reject_reserved_secret_gate_property(properties.as_ref())?;
3692        crate::secret_gate::check_at(content, "note", "content")?;
3693        if let Some(n) = name {
3694            crate::secret_gate::check_at(n, "note", "name")?;
3695        }
3696        if let Some(ref p) = properties {
3697            crate::secret_gate::check_json_at(p, "note", "properties")?;
3698        }
3699        if !allow_transport_owned_message_properties && kind == "message" {
3700            if let Some(key) = properties
3701                .as_ref()
3702                .and_then(serde_json::Value::as_object)
3703                .and_then(transport_owned_message_property_named_in)
3704            {
3705                return Err(RuntimeError::InvalidInput(format!(
3706                    "`{key}` is transport-owned on a `message` note and cannot be supplied \
3707                     through `try_create_note`; only the trusted channel-ingest path may \
3708                     establish quarantine disposition and channel provenance"
3709                )));
3710            }
3711        }
3712
3713        let ns = token.namespace().as_str();
3714        let mut note = Note::new(ns, kind, content);
3715        if let Some(n) = name {
3716            note = note.with_name(n);
3717        }
3718        if let Some(p) = properties {
3719            note = note.with_properties(p);
3720        }
3721
3722        // Bypasses the `notes()` accessor's PolicyEnforcingNoteStore wrapper —
3723        // the reserved-transport-property check above already enforces the
3724        // identical policy, conditionally allowing the trusted-ingest path,
3725        // so this reaches storage directly rather than duplicate the check
3726        // through a wrapper that cannot see the trust decision this function
3727        // just made.
3728        let inserted = self.raw_notes(token)?.try_insert_note(note.clone()).await?;
3729        if !inserted {
3730            return Ok(None);
3731        }
3732
3733        // Best-effort FTS: log and continue on failure.
3734        if let Ok(fts) = self.text_for_notes(token) {
3735            if let Err(e) = fts.upsert_document(note_fts_document(&note)).await {
3736                tracing::warn!(
3737                    note_id = %note.id,
3738                    error = %e,
3739                    "try_create_note: FTS indexing failed (non-fatal)"
3740                );
3741            }
3742        }
3743
3744        // Best-effort vector embedding: log and continue on failure.
3745        let embed_model_names = self.registered_embedding_model_names();
3746        for model_name in &embed_model_names {
3747            match self
3748                .embed_document_with_model_outcome_for_token(
3749                    token,
3750                    model_name,
3751                    note_embedding_text_ref(&note),
3752                )
3753                .await
3754            {
3755                Ok(outcome) => {
3756                    if outcome.truncated {
3757                        tracing::warn!(
3758                            note_id = %note.id,
3759                            model = %outcome.model_name,
3760                            source_bytes = outcome.source_bytes,
3761                            embedded_bytes = outcome.embedded_bytes,
3762                            "try_create_note: embedding input truncated; full content stored unchanged"
3763                        );
3764                    }
3765                    if let Ok(vs) = self.vectors_for_model(token, model_name) {
3766                        if let Err(e) = vs
3767                            .insert(
3768                                note.id,
3769                                SubstrateKind::Note,
3770                                ns,
3771                                "note.content",
3772                                vec![outcome.vector],
3773                            )
3774                            .await
3775                        {
3776                            tracing::warn!(
3777                                note_id = %note.id,
3778                                model = %model_name,
3779                                error = %e,
3780                                "try_create_note: vector insert failed (non-fatal)"
3781                            );
3782                        }
3783                    }
3784                }
3785                Err(e) => {
3786                    tracing::warn!(
3787                        note_id = %note.id,
3788                        model = %model_name,
3789                        error = %e,
3790                        "try_create_note: embedding failed (non-fatal)"
3791                    );
3792                }
3793            }
3794        }
3795
3796        Ok(Some(note))
3797    }
3798
3799    // REASON: private inner function unifies all create_note variants; it receives every
3800    // optional parameter individually so that public variants can pass None without
3801    // requiring callers to construct an intermediate struct.
3802    #[allow(clippy::too_many_arguments)]
3803    async fn create_note_inner(
3804        &self,
3805        token: &NamespaceToken,
3806        kind: &str,
3807        name: Option<&str>,
3808        content: &str,
3809        embedding_content: Option<&str>,
3810        salience: Option<f64>,
3811        decay_factor: Option<f64>,
3812        properties: Option<serde_json::Value>,
3813        annotates: Vec<Uuid>,
3814        embedding_model: Option<&str>,
3815    ) -> RuntimeResult<(Note, crate::retrieval::EmbeddingTruncationReport)> {
3816        self.validate_note_kind(kind)?;
3817        // Owned identity properties are derived from the authorization token
3818        // before anything else touches them, so every caller of this function —
3819        // the generic `create` verb and direct Rust callers alike — stores the
3820        // same derived values. Runs before the secret gate so the gate scans
3821        // exactly what will be written.
3822        let properties = self.derive_note_write_properties(kind, token, properties)?;
3823        crate::secret_gate::reject_reserved_secret_gate_property(properties.as_ref())?;
3824        // Secret gate: scan content, optional name, and structured properties.
3825        crate::secret_gate::check_at(content, "note", "content")?;
3826        if let Some(n) = name {
3827            crate::secret_gate::check_at(n, "note", "name")?;
3828        }
3829        if let Some(ref p) = properties {
3830            crate::secret_gate::check_json_at(p, "note", "properties")?;
3831        }
3832        // `embedding_content` is a caller-supplied alternate vector-embedding
3833        // input: it must be a non-empty proper prefix of `content` (never a
3834        // superset, an unrelated string, or the full text) and passes the
3835        // same secret gate as any other stored/embedded text. Rejected
3836        // before any write, same as the checks above.
3837        if let Some(ec) = embedding_content {
3838            if ec.is_empty() {
3839                return Err(RuntimeError::InvalidInput(
3840                    "embedding_content must not be empty".into(),
3841                ));
3842            }
3843            if ec.len() >= content.len() || !content.starts_with(ec) {
3844                return Err(RuntimeError::InvalidInput(
3845                    "embedding_content must be a proper prefix of content".into(),
3846                ));
3847            }
3848            crate::secret_gate::check_at(ec, "note", "embedding_content")?;
3849        }
3850        let ns = token.namespace().as_str();
3851
3852        // Validate all annotates targets before any write (atomicity: all-or-nothing).
3853        // Endpoint resolution is by-ID and namespace-agnostic.
3854        for &target_id in &annotates {
3855            if !self.substrate_exists_by_id(token, target_id).await? {
3856                return Err(RuntimeError::NotFound(format!(
3857                    "create_note annotates target {target_id} not found"
3858                )));
3859            }
3860        }
3861
3862        // Reject non-finite or out-of-range salience/decay at the runtime boundary
3863        // rather than letting storage silently clamp them (coding-standards §508-516).
3864        if let Some(s) = salience {
3865            if !s.is_finite() || !(0.0..=1.0).contains(&s) {
3866                return Err(RuntimeError::InvalidInput(format!(
3867                    "salience must be a finite value in [0.0, 1.0]; got {s}"
3868                )));
3869            }
3870        }
3871        if let Some(d) = decay_factor {
3872            if !d.is_finite() || d < 0.0 {
3873                return Err(RuntimeError::InvalidInput(format!(
3874                    "decay_factor must be a finite value >= 0.0; got {d}"
3875                )));
3876            }
3877        }
3878
3879        // Resolve embedding_model BEFORE any note/FTS/vector write so unknown-model
3880        // errors are atomic at the runtime layer, not just at one pack handler.
3881        // Direct Rust callers (other packs, integration tests) get the same guarantee.
3882        if let Some(model_name) = embedding_model {
3883            self.resolve_embedding_model(Some(model_name))?;
3884        }
3885
3886        let mut note = Note::new(ns, kind, content);
3887        if let Some(s) = salience {
3888            note = note.with_salience(s);
3889        }
3890        if let Some(df) = decay_factor {
3891            note = note.with_decay(df);
3892        }
3893        if let Some(n) = name {
3894            note = note.with_name(n);
3895        }
3896        if let Some(p) = properties {
3897            note = note.with_properties(p);
3898        }
3899        self.notes(token)?.upsert_note(note.clone()).await?;
3900
3901        // From here on, any error must compensate by removing the note row, its
3902        // FTS document, and any vector entries already inserted — the same
3903        // cleanup used by the annotates-edge block below.
3904
3905        // Decide which embedding models to use (before touching FTS/vectors).
3906        let embed_model_names: Vec<String> = if let Some(m) = embedding_model {
3907            vec![m.to_string()]
3908        } else {
3909            // Fan out to ALL registered models — includes both lattice models
3910            // from RuntimeConfig and any custom providers added via
3911            // register_embedder(). Gate on the registry, not
3912            // config().embedding_model, so that custom-only runtimes (no
3913            // lattice model in config) also fan out.
3914            let names = self.registered_embedding_model_names();
3915            if names.is_empty() {
3916                // No models configured at all — skip vector embedding.
3917                vec![]
3918            } else {
3919                names
3920            }
3921        };
3922
3923        // FTS step — compensate note row on failure.
3924        {
3925            // Injection: check FTS_FAIL_NS (armed by `arm_fts_fail_scoped(ns)`).
3926            // Fires only when `ns` is in the armed set, removing it on the way
3927            // out (one-shot, atomic check-and-remove under the mutex). No lock
3928            // acquisition in release builds — the cfg(not) branch is a const
3929            // false so the compiler eliminates the if-branch entirely.
3930            #[cfg(any(test, feature = "fault-injection"))]
3931            let fts_inject = consume_fault(&FTS_FAIL_NS, ns);
3932            #[cfg(not(any(test, feature = "fault-injection")))]
3933            let fts_inject = false;
3934            let fts_result: RuntimeResult<()> = if fts_inject {
3935                Err(RuntimeError::Internal("injected FTS failure".to_string()))
3936            } else {
3937                let statements =
3938                    khive_db::stores::text::delete_document_statements("fts_notes", ns, note.id)
3939                        .into_iter()
3940                        .chain(khive_db::stores::text::insert_document_statements(
3941                            "fts_notes",
3942                            &note_fts_document(&note),
3943                        ))
3944                        .collect();
3945                self.apply_note_index_revision(&note, statements)
3946                    .await
3947                    .map(|_| ())
3948            };
3949
3950            if let Err(e) = fts_result {
3951                self.compensate_note_creation(&note).await;
3952                return Err(e);
3953            }
3954        }
3955
3956        // Vector embedding + insert step — compensate note row + FTS doc on failure.
3957        // Multi-model vector embedding:
3958        //   - explicit embedding_model → single model (existing behaviour)
3959        //   - None + any models registered → ALL registered models in parallel
3960        //   - None + no models configured → skip (text-only)
3961        // The effective text sent to every embedder: the caller-supplied
3962        // capped override when present, otherwise the full stored content.
3963        // FTS indexing above always used the full `note.content` — this cap
3964        // affects only the vector-embedding input.
3965        let canonical_embed_text = note_embedding_text_ref(&note);
3966        let embed_text = embedding_content.unwrap_or(canonical_embed_text);
3967
3968        let mut embedding_report = crate::retrieval::EmbeddingTruncationReport::default();
3969        if embed_model_names.len() == 1 {
3970            // Single-model path: preserves original sequential behaviour.
3971            let model_name = &embed_model_names[0];
3972            let vec_result = self
3973                .embed_document_with_model_outcome_for_token(token, model_name, embed_text)
3974                .await;
3975
3976            // Injection: check VECTOR_FAIL_NS (armed by `arm_vector_fail_scoped(ns)`) or
3977            // VECTOR_FAIL_AFTER (armed by `arm_vector_fail_after(n)`). The former
3978            // fires only when the armed namespace matches this note's namespace;
3979            // callers that cannot guarantee no concurrently-running test also
3980            // writes a note into that same namespace (e.g. a test suite whose
3981            // fixtures share one default namespace) should prefer the latter,
3982            // thread-local count instead — see its doc comment. Either clears
3983            // (one-shot) once it fires. No lock/cell access in release builds —
3984            // the cfg(not) branch is a const false eliminating the if-branch.
3985            #[cfg(any(test, feature = "fault-injection"))]
3986            let vec_inject = {
3987                let ns_inject = consume_fault(&VECTOR_FAIL_NS, ns);
3988                let count_inject = VECTOR_FAIL_AFTER.with(|cell| match cell.get() {
3989                    Some(0) => {
3990                        cell.set(None);
3991                        true
3992                    }
3993                    Some(n) => {
3994                        cell.set(Some(n - 1));
3995                        false
3996                    }
3997                    None => false,
3998                });
3999                ns_inject || count_inject
4000            };
4001            #[cfg(not(any(test, feature = "fault-injection")))]
4002            let vec_inject = false;
4003            let vec_result: RuntimeResult<crate::retrieval::DocumentEmbeddingOutcome> =
4004                if vec_inject {
4005                    Err(RuntimeError::Internal(
4006                        "injected vector failure".to_string(),
4007                    ))
4008                } else {
4009                    vec_result
4010                };
4011
4012            let single_model_result: RuntimeResult<()> = match vec_result {
4013                Ok(outcome) => {
4014                    embedding_report.observe(&outcome);
4015                    self.publish_note_vector_revision(token, &note, model_name, &outcome.vector)
4016                        .await
4017                        .map(|_| ())
4018                }
4019                Err(e) => Err(e),
4020            };
4021            if let Err(e) = single_model_result {
4022                self.compensate_note_creation(&note).await;
4023                return Err(e);
4024            }
4025        } else if !embed_model_names.is_empty() {
4026            // Multi-model path: embed with each model in parallel via spawned tasks,
4027            // then insert one VectorRecord per model.
4028            let rt_clone = self.clone();
4029            // JoinSet tasks require owned text; an Arc keeps this to one
4030            // content-sized allocation rather than one clone per model.
4031            let content_owned: std::sync::Arc<str> = std::sync::Arc::from(embed_text);
4032            let usage_ctx = crate::usage::current();
4033            let mut join_set = tokio::task::JoinSet::new();
4034            for (idx, model_name) in embed_model_names.iter().enumerate() {
4035                let rt = rt_clone.clone();
4036                let text = std::sync::Arc::clone(&content_owned);
4037                let name = model_name.clone();
4038                let ctx = usage_ctx.clone();
4039                let token = (*token).clone();
4040                join_set.spawn(crate::runtime::inherit_request_embedder_scope(async move {
4041                    let fut = rt.embed_document_with_model_outcome_for_token(
4042                        &token,
4043                        &name,
4044                        text.as_ref(),
4045                    );
4046                    let result = match ctx {
4047                        Some(ctx) => crate::usage::scope(ctx, fut).await,
4048                        None => fut.await,
4049                    };
4050                    (idx, result)
4051                }));
4052            }
4053            // The first failed or panicked handle aborts and detaches its
4054            // siblings. Embed usage is counted at dispatch, so a synchronous
4055            // provider winding down in the background cannot change it.
4056            let outcomes = match drain_embed_join_set(join_set, embed_model_names.len()).await {
4057                Ok(outcomes) => outcomes,
4058                Err(e) => {
4059                    self.compensate_note_creation(&note).await;
4060                    return Err(e);
4061                }
4062            };
4063            // TODO(P2): parallelize vector inserts
4064            for (model_name, outcome) in embed_model_names.iter().zip(outcomes) {
4065                embedding_report.observe(&outcome);
4066                let insert_result = self
4067                    .publish_note_vector_revision(token, &note, model_name, &outcome.vector)
4068                    .await;
4069                if let Err(e) = insert_result {
4070                    self.compensate_note_creation(&note).await;
4071                    return Err(e);
4072                }
4073            }
4074        }
4075
4076        // Create annotates edges, compensating on failure to preserve atomicity.
4077        //
4078        // Pre-validation (above) ensures all targets exist, so link failures are
4079        // unexpected. If one occurs: delete any edges already created, then remove
4080        // the note, its FTS document, and its vector entry.
4081        let mut created_edges: Vec<Uuid> = Vec::with_capacity(annotates.len());
4082
4083        // In test builds, iterate with an index so the failure-injection hook can
4084        // target a specific call.  In release builds, skip the enumerate overhead.
4085        #[cfg(test)]
4086        let annotates_iter: Vec<(usize, Uuid)> = annotates
4087            .iter()
4088            .enumerate()
4089            .map(|(i, &id)| (i, id))
4090            .collect();
4091        #[cfg(test)]
4092        macro_rules! next_target {
4093            ($pair:expr) => {
4094                $pair.1
4095            };
4096        }
4097        #[cfg(not(test))]
4098        let annotates_iter: Vec<Uuid> = annotates.to_vec();
4099        #[cfg(not(test))]
4100        macro_rules! next_target {
4101            ($pair:expr) => {
4102                $pair
4103            };
4104        }
4105
4106        for pair in annotates_iter {
4107            let target_id = next_target!(pair);
4108
4109            // Test-only: inject a failure on the configured call index (1-based).
4110            #[cfg(test)]
4111            let injected_err: Option<RuntimeError> = {
4112                let call_idx = pair.0;
4113                LINK_FAIL_AFTER.with(|cell| {
4114                    let n = cell.get();
4115                    if n > 0 && call_idx + 1 == n {
4116                        cell.set(0); // reset so subsequent calls are unaffected
4117                        Some(RuntimeError::Internal("injected link failure".to_string()))
4118                    } else {
4119                        None
4120                    }
4121                })
4122            };
4123            #[cfg(not(test))]
4124            let injected_err: Option<RuntimeError> = None;
4125
4126            let link_result = if let Some(e) = injected_err {
4127                Err(e)
4128            } else {
4129                self.link(
4130                    token,
4131                    note.id,
4132                    target_id,
4133                    EdgeRelation::Annotates,
4134                    1.0,
4135                    None,
4136                )
4137                .await
4138            };
4139
4140            match link_result {
4141                Ok(edge) => created_edges.push(edge.id.into()),
4142                Err(e) => {
4143                    // Preserve newer revisions and their edges. Successful
4144                    // removal still uses canonical edge cleanup and its audits.
4145                    if self.compensate_note_creation(&note).await {
4146                        for edge_id in created_edges {
4147                            let _ = self.delete_edge(token, edge_id, true).await;
4148                        }
4149                    }
4150                    return Err(e);
4151                }
4152            }
4153        }
4154
4155        // Same contract as the entity arrival event above: after compensation,
4156        // so a rolled-back create leaves no event. This is the single funnel for
4157        // every note create in the product, which is why the memory pack's own
4158        // note_created emitter was removed rather than left beside it.
4159        let event_store = self.events(token)?;
4160        let created_event = khive_storage::event::Event::new(
4161            note.namespace.clone(),
4162            "create",
4163            EventKind::NoteCreated,
4164            SubstrateKind::Note,
4165            "",
4166        )
4167        .with_target(note.id)
4168        .with_payload(serde_json::json!({
4169            "id": note.id,
4170            "namespace": note.namespace,
4171            "kind": note.kind,
4172            "salience": note.salience,
4173        }));
4174        event_store.append_event(created_event).await.map_err(|e| {
4175            RuntimeError::Internal(format!("create_note: event store write failed: {e}"))
4176        })?;
4177
4178        Ok((note, embedding_report))
4179    }
4180
4181    /// List notes visible to the token, optionally filtered by kind.
4182    ///
4183    /// When the token carries a multi-namespace visible set, notes from all
4184    /// visible namespaces are returned. When the visible set is `[primary]`
4185    /// (the default) this behaves identically to the pre-visibility behaviour.
4186    pub async fn list_notes(
4187        &self,
4188        token: &NamespaceToken,
4189        kind: Option<&str>,
4190        limit: u32,
4191        offset: u32,
4192    ) -> RuntimeResult<Vec<Note>> {
4193        let visible = token.visible_namespaces();
4194        if visible.len() == 1 {
4195            // Fast path: single namespace — use the dedicated query_notes method.
4196            let page = self
4197                .notes(token)?
4198                .query_notes_count_free(
4199                    token.namespace().as_str(),
4200                    kind,
4201                    PageRequest {
4202                        offset: offset.into(),
4203                        limit,
4204                    },
4205                )
4206                .await?;
4207            return Ok(page.items);
4208        }
4209        // Multi-namespace path: use query_notes_filtered with the visible set.
4210        use khive_storage::note::NoteFilter;
4211        let ns_strs: Vec<String> = visible.iter().map(|ns| ns.as_str().to_owned()).collect();
4212        let filter = NoteFilter {
4213            kind: kind.map(|k| k.to_string()),
4214            namespaces: ns_strs,
4215            ..Default::default()
4216        };
4217        let page = self
4218            .notes(token)?
4219            .query_notes_filtered_count_free(
4220                token.namespace().as_str(),
4221                &filter,
4222                PageRequest {
4223                    offset: offset.into(),
4224                    limit,
4225                },
4226            )
4227            .await?;
4228        Ok(page.items)
4229    }
4230
4231    /// List an immutable insertion-sequence page of visible notes.
4232    ///
4233    /// Soft-deleting the prior page's last note does not invalidate the
4234    /// cursor because the boundary is resolved including tombstones. A hard
4235    /// deletion makes the cursor unresolvable and returns an explicit error.
4236    pub async fn list_notes_after(
4237        &self,
4238        token: &NamespaceToken,
4239        kind: Option<&str>,
4240        after: Option<Uuid>,
4241        limit: u32,
4242    ) -> RuntimeResult<(Vec<Note>, Option<Uuid>)> {
4243        let store = self.notes(token)?;
4244        let after = match after {
4245            Some(id) => {
4246                let note = self
4247                    .get_note_including_deleted(token, id)
4248                    .await?
4249                    .ok_or_else(|| RuntimeError::NotFound(format!("note cursor {id}")))?;
4250                Self::ensure_namespace_visible(&note.namespace, token)?;
4251                let sequence = store.note_sequence(id).await?.ok_or_else(|| {
4252                    RuntimeError::Internal(format!(
4253                        "note cursor {id} has no insertion-sequence ledger row"
4254                    ))
4255                })?;
4256                Some(SeekCursor { sequence, id })
4257            }
4258            None => None,
4259        };
4260        let filter = khive_storage::note::NoteFilter {
4261            kind: kind.map(str::to_string),
4262            namespaces: token
4263                .visible_namespaces()
4264                .iter()
4265                .map(|namespace| namespace.as_str().to_owned())
4266                .collect(),
4267            ..Default::default()
4268        };
4269        let page = store
4270            .query_notes_filtered_after(token.namespace().as_str(), &filter, after, limit)
4271            .await?;
4272        Ok((page.items, page.next_after.map(|cursor| cursor.id)))
4273    }
4274
4275    /// Count notes matching `kind` across the caller's visible namespaces.
4276    pub async fn count_notes(
4277        &self,
4278        token: &NamespaceToken,
4279        kind: Option<&str>,
4280    ) -> RuntimeResult<u64> {
4281        let namespaces: Vec<String> = token
4282            .visible_namespaces()
4283            .iter()
4284            .map(|namespace| namespace.as_str().to_owned())
4285            .collect();
4286        Ok(self
4287            .notes(token)?
4288            .count_notes_in_namespaces(&namespaces, kind)
4289            .await?)
4290    }
4291
4292    /// Search notes using a hybrid FTS5 + vector pipeline with salience weighting.
4293    ///
4294    /// Pipeline:
4295    /// 1. FTS5 query against `notes_<namespace>`.
4296    /// 2. If embedding model is configured: vector search filtered to `kind="note"`.
4297    /// 3. RRF fusion (k=60).
4298    /// 4. Salience-weighted rerank: `score *= (0.5 + 0.5 * note.salience)`.
4299    /// 5. Filter soft-deleted notes, apply optional kind / tag / properties predicates.
4300    ///    Tags and properties are pushed into the per-note fetch loop BEFORE truncation
4301    ///    so that matching notes ranked beyond `limit` in the raw fusion are not silently
4302    ///    dropped.
4303    /// 6. Truncate to `limit`.
4304    ///
4305    /// `tags_any`: when non-empty, only notes that have at least one of these tags
4306    /// (stored in `properties["tags"]`, case-insensitive match) are retained. The
4307    /// check happens inside the alive-note loop, before `hits.truncate(limit)`.
4308    ///
4309    /// `properties_filter`: when `Some`, only notes whose `properties` JSON object is
4310    /// a superset of the given filter object are retained. Also applied before truncation.
4311    #[allow(clippy::too_many_arguments)]
4312    pub async fn search_notes(
4313        &self,
4314        token: &NamespaceToken,
4315        query_text: &str,
4316        query_vector: Option<Vec<f32>>,
4317        limit: u32,
4318        note_kind: Option<&str>,
4319        include_superseded: bool,
4320        tags_any: &[String],
4321        properties_filter: Option<&serde_json::Value>,
4322    ) -> RuntimeResult<Vec<NoteSearchHit>> {
4323        self.search_notes_with_text_mode(
4324            token,
4325            query_text,
4326            query_vector,
4327            limit,
4328            note_kind,
4329            include_superseded,
4330            tags_any,
4331            properties_filter,
4332            TextQueryMode::Plain,
4333        )
4334        .await
4335    }
4336
4337    /// Note search with an explicit lexical mode for the text arm.
4338    #[allow(clippy::too_many_arguments)]
4339    pub async fn search_notes_with_text_mode(
4340        &self,
4341        token: &NamespaceToken,
4342        query_text: &str,
4343        query_vector: Option<Vec<f32>>,
4344        limit: u32,
4345        note_kind: Option<&str>,
4346        include_superseded: bool,
4347        tags_any: &[String],
4348        properties_filter: Option<&serde_json::Value>,
4349        text_mode: TextQueryMode,
4350    ) -> RuntimeResult<Vec<NoteSearchHit>> {
4351        let (hits, _vector_error) = self
4352            .search_notes_inner(
4353                token,
4354                query_text,
4355                query_vector,
4356                limit,
4357                note_kind,
4358                include_superseded,
4359                tags_any,
4360                properties_filter,
4361                text_mode,
4362                false,
4363            )
4364            .await?;
4365        Ok(hits)
4366    }
4367
4368    /// Coordinator fan-out variant of [`Self::search_notes`]: the text arm
4369    /// still fails loud, but a vector-arm failure after a successful text leg
4370    /// is captured instead of discarding the text hits — mirrors
4371    /// [`Self::hybrid_search_outcome`]'s contract for the entity substrate.
4372    /// Reserved for `SubstrateCoordinator::fan_out_search_with_visibility`;
4373    /// every other caller keeps the fail-loud [`Self::search_notes`] contract.
4374    #[allow(clippy::too_many_arguments)]
4375    pub async fn search_notes_outcome(
4376        &self,
4377        token: &NamespaceToken,
4378        query_text: &str,
4379        limit: u32,
4380        note_kind: Option<&str>,
4381        include_superseded: bool,
4382        tags_any: &[String],
4383        properties_filter: Option<&serde_json::Value>,
4384    ) -> RuntimeResult<NoteSearchOutcome> {
4385        self.search_notes_outcome_with_text_mode(
4386            token,
4387            query_text,
4388            limit,
4389            note_kind,
4390            include_superseded,
4391            tags_any,
4392            properties_filter,
4393            TextQueryMode::Plain,
4394        )
4395        .await
4396    }
4397
4398    /// Coordinator note-search variant with an explicit lexical mode.
4399    #[allow(clippy::too_many_arguments)]
4400    pub async fn search_notes_outcome_with_text_mode(
4401        &self,
4402        token: &NamespaceToken,
4403        query_text: &str,
4404        limit: u32,
4405        note_kind: Option<&str>,
4406        include_superseded: bool,
4407        tags_any: &[String],
4408        properties_filter: Option<&serde_json::Value>,
4409        text_mode: TextQueryMode,
4410    ) -> RuntimeResult<NoteSearchOutcome> {
4411        let (hits, vector_error) = self
4412            .search_notes_inner(
4413                token,
4414                query_text,
4415                None,
4416                limit,
4417                note_kind,
4418                include_superseded,
4419                tags_any,
4420                properties_filter,
4421                text_mode,
4422                true,
4423            )
4424            .await?;
4425        Ok(NoteSearchOutcome { hits, vector_error })
4426    }
4427
4428    #[allow(clippy::too_many_arguments)]
4429    async fn search_notes_inner(
4430        &self,
4431        token: &NamespaceToken,
4432        query_text: &str,
4433        query_vector: Option<Vec<f32>>,
4434        limit: u32,
4435        note_kind: Option<&str>,
4436        include_superseded: bool,
4437        tags_any: &[String],
4438        properties_filter: Option<&serde_json::Value>,
4439        text_mode: TextQueryMode,
4440        tolerate_vector_error: bool,
4441    ) -> RuntimeResult<(Vec<NoteSearchHit>, Option<String>)> {
4442        const RRF_K: usize = 60;
4443        let candidates = limit.saturating_mul(4).max(limit);
4444        let visible_ns: Vec<String> = token
4445            .visible_namespaces()
4446            .iter()
4447            .map(|ns| ns.as_str().to_owned())
4448            .collect();
4449
4450        // FTS5 over the notes index — search all visible namespaces.
4451        //
4452        // `sanitize_fts5_query` strips known-unsafe FTS5 metacharacters, but
4453        // residual punctuation the sanitizer does not strip can still reach
4454        // the FTS5 parser and error. This fails loud instead of degrading to
4455        // vector-only fusion, so callers see the bad query instead of
4456        // silently losing the lexical leg. Errors from any other leg (vector
4457        // search, note hydration) still propagate normally.
4458        //
4459        // Injection: check FTS_SEARCH_FAIL_NS (armed by `arm_fts_search_fail(ns)`),
4460        // exercising the propagate branch above. Fires only when the armed
4461        // namespace is among this call's visible namespaces, then clears (one-shot).
4462        #[cfg(any(test, feature = "fault-injection"))]
4463        let fts_search_inject = {
4464            let mut g = FTS_SEARCH_FAIL_NS.lock().unwrap();
4465            match g.as_deref() {
4466                Some(armed) if visible_ns.iter().any(|ns| ns == armed) => {
4467                    *g = None;
4468                    true
4469                }
4470                _ => false,
4471            }
4472        };
4473        #[cfg(not(any(test, feature = "fault-injection")))]
4474        let fts_search_inject = false;
4475
4476        let text_search_result = if fts_search_inject {
4477            Err(khive_storage::StorageError::Timeout {
4478                operation: "fts_search".into(),
4479            })
4480        } else {
4481            self.text_for_notes(token)?
4482                .search(TextSearchRequest {
4483                    query: query_text.to_string(),
4484                    mode: text_mode,
4485                    filter: Some(TextFilter {
4486                        namespaces: visible_ns.clone(),
4487                        // Push the note-kind filter into the FTS query. Without it the
4488                        // text arm returns the top `candidates` rows across EVERY note
4489                        // kind in the namespace and the kind is applied post-fetch, so a
4490                        // store where short message/session rows outrank task
4491                        // descriptions under BM25 hands the caller one or two task hits
4492                        // while the store holds many more carrying the literal.
4493                        record_kinds: note_kind
4494                            .map(|kind| vec![kind.to_string()])
4495                            .unwrap_or_default(),
4496                        ..TextFilter::default()
4497                    }),
4498                    top_k: candidates,
4499                    snippet_chars: 200,
4500                })
4501                .await
4502        };
4503
4504        // FtsPasses is counted inside the store's `search()` (khive-db
4505        // stores/text.rs), only once a real FTS5 statement is prepared —
4506        // an empty/fully-sanitized query short-circuits there before any
4507        // statement exists and must not count (nor does the injected-failure
4508        // branch above, which never reaches the store at all).
4509        let text_hits = crate::error::fts_text_leg_or_err(
4510            text_search_result.map_err(RuntimeError::from),
4511            "search_notes",
4512            query_text,
4513        )?;
4514
4515        // Vector search filtered to notes.
4516        let mut vector_error: Option<String> = None;
4517        let vector_hits = if query_vector.is_some() || self.config().embedding_model.is_some() {
4518            match self
4519                .vector_search(
4520                    token,
4521                    query_vector,
4522                    Some(query_text),
4523                    candidates,
4524                    Some(SubstrateKind::Note),
4525                )
4526                .await
4527            {
4528                Ok(hits) => hits,
4529                Err(e) if tolerate_vector_error => {
4530                    vector_error = Some(e.to_string());
4531                    Vec::new()
4532                }
4533                Err(e) => return Err(e),
4534            }
4535        } else {
4536            vec![]
4537        };
4538
4539        // Keep the full text∪vector union through RRF — salience weighting and
4540        // soft-delete/kind filtering happen *after* this, and the final
4541        // `hits.truncate(limit)` is the only result-limiting cut. Truncating to
4542        // `candidates` here would drop a high-salience note ranked just outside
4543        // the raw RRF cutoff before salience ever applied.
4544        let fuse_k = text_hits.len() + vector_hits.len();
4545        let fused = crate::fusion::rrf_fuse_k(self, text_hits, vector_hits, RRF_K, fuse_k).await?;
4546
4547        let candidate_ids: Vec<Uuid> = fused.iter().map(|hit| hit.entity_id).collect();
4548        if candidate_ids.is_empty() {
4549            return Ok((vec![], vector_error));
4550        }
4551
4552        // Fetch each candidate note individually to get salience and apply
4553        // soft-delete + (optional) kind filtering. Notes whose `kind` doesn't
4554        // match `note_kind` are dropped post-fetch — they're a small set
4555        // bounded by the text∪vector union (≤ 2×candidates), so the read is cheap.
4556        let note_store = self.notes(token)?;
4557        let mut alive_notes: HashMap<Uuid, Note> = HashMap::new();
4558        for id in &candidate_ids {
4559            if let Some(note) = note_store.get_note(*id).await? {
4560                if note.deleted_at.is_some() {
4561                    continue;
4562                }
4563                if let Some(want_kind) = note_kind {
4564                    if note.kind != want_kind {
4565                        continue;
4566                    }
4567                }
4568                // Apply tag predicate before adding to alive set: tags on notes live
4569                // inside `properties["tags"]` (a JSON array). This pushes the filter
4570                // before truncation so matching notes ranked beyond `limit` in the raw
4571                // fusion are not silently dropped.
4572                if !tags_any.is_empty() {
4573                    let note_tags: Vec<String> = note
4574                        .properties
4575                        .as_ref()
4576                        .and_then(|p| p.get("tags"))
4577                        .and_then(serde_json::Value::as_array)
4578                        .map(|arr| {
4579                            arr.iter()
4580                                .filter_map(serde_json::Value::as_str)
4581                                .map(str::to_owned)
4582                                .collect()
4583                        })
4584                        .unwrap_or_default();
4585                    if !note_tags
4586                        .iter()
4587                        .any(|t| tags_any.iter().any(|w| t.eq_ignore_ascii_case(w)))
4588                    {
4589                        continue;
4590                    }
4591                }
4592                // Apply properties predicate before truncation, same reasoning as tags above.
4593                if let Some(pf) = properties_filter {
4594                    if !note_props_match(note.properties.as_ref(), pf) {
4595                        continue;
4596                    }
4597                }
4598                alive_notes.insert(*id, note);
4599            }
4600        }
4601
4602        // Drop superseded notes unless include_superseded is true: any note targeted
4603        // by a `supersedes` edge is obsolete and excluded from default search.
4604        if !include_superseded && !alive_notes.is_empty() {
4605            let graph = self.graph(token)?;
4606            let note_ids: Vec<Uuid> = alive_notes.keys().copied().collect();
4607            let superseded: std::collections::HashSet<Uuid> = graph
4608                .batch_neighbors(
4609                    &note_ids,
4610                    NeighborQuery {
4611                        direction: Direction::In,
4612                        relations: Some(vec![EdgeRelation::Supersedes]),
4613                        limit: Some(1),
4614                        min_weight: None,
4615                    },
4616                )
4617                .await?
4618                .into_iter()
4619                .map(|(note_id, _)| note_id)
4620                .collect();
4621            alive_notes.retain(|id, _| !superseded.contains(id));
4622        }
4623
4624        // Apply salience weighting and collect final hits.
4625        let mut hits: Vec<NoteSearchHit> = fused
4626            .into_iter()
4627            .filter_map(|hit| {
4628                let note = alive_notes.get(&hit.entity_id)?;
4629                let weighted = salience_weighted_rank(hit.score, note.salience);
4630                Some(NoteSearchHit {
4631                    note_id: hit.entity_id,
4632                    score: weighted,
4633                    rank_score_kind: hit.rank_score_kind,
4634                    signals: hit.signals,
4635                    source: hit.source,
4636                    title: hit.title.or_else(|| note_title(note)),
4637                    snippet: hit.snippet.or_else(|| note_snippet(note)),
4638                })
4639            })
4640            .collect();
4641
4642        hits.sort_by(|a, b| b.score.cmp(&a.score).then(a.note_id.cmp(&b.note_id)));
4643        hits.truncate(limit as usize);
4644        Ok((hits, vector_error))
4645    }
4646
4647    /// Resolve a short UUID prefix (8+ hex chars) to a full UUID.
4648    ///
4649    /// Searches entities, notes, and edges tables for a UUID starting with the
4650    /// given prefix, scoped to the caller's primary namespace only. Returns
4651    /// `Ok(Some(uuid))` if exactly one match is found, `Ok(None)` if no
4652    /// matches, or an error if ambiguous (multiple matches).
4653    pub async fn resolve_prefix(
4654        &self,
4655        token: &NamespaceToken,
4656        prefix: &str,
4657    ) -> RuntimeResult<Option<Uuid>> {
4658        let namespaces = [token.namespace().as_str().to_owned()];
4659        self.resolve_prefix_inner(Some(&namespaces), prefix, false, false)
4660            .await
4661    }
4662
4663    pub async fn resolve_prefix_including_deleted(
4664        &self,
4665        token: &NamespaceToken,
4666        prefix: &str,
4667    ) -> RuntimeResult<Option<Uuid>> {
4668        let namespaces = [token.namespace().as_str().to_owned()];
4669        self.resolve_prefix_inner(Some(&namespaces), prefix, true, false)
4670            .await
4671    }
4672
4673    /// Resolve a short UUID prefix (8+ hex chars) to a full UUID with NO
4674    /// namespace filter at all: mirrors `resolve_by_id`'s by-ID contract:
4675    /// by-ID resolution is namespace-agnostic, since the Gate (not
4676    /// storage-layer filtering) is the authz seam. Used by the four by-ID
4677    /// CRUD verbs (get/update/delete/merge) so their prefix path matches
4678    /// their already-unfiltered full-UUID path. No token param: unlike
4679    /// `resolve_prefix`, there is no namespace to derive from one.
4680    pub async fn resolve_prefix_unfiltered(&self, prefix: &str) -> RuntimeResult<Option<Uuid>> {
4681        self.resolve_prefix_inner(None, prefix, false, false).await
4682    }
4683
4684    /// `resolve_prefix_unfiltered`, including soft-deleted rows — used by the
4685    /// hard-delete by-ID path.
4686    pub async fn resolve_prefix_unfiltered_including_deleted(
4687        &self,
4688        prefix: &str,
4689    ) -> RuntimeResult<Option<Uuid>> {
4690        self.resolve_prefix_inner(None, prefix, true, false).await
4691    }
4692
4693    /// The configured multi-backend read inventory has completed base schema
4694    /// bootstrap. A missing table is a backend failure there, not an absent ID.
4695    pub(crate) async fn resolve_prefix_for_kg_read(
4696        &self,
4697        prefix: &str,
4698        include_deleted: bool,
4699    ) -> RuntimeResult<Option<Uuid>> {
4700        self.resolve_prefix_inner(None, prefix, include_deleted, true)
4701            .await
4702    }
4703
4704    /// Shared indexed prefix-range lookup over an explicit namespace set.
4705    ///
4706    /// `namespaces` selects the lookup scope: `Some(&[ns])` reproduces the
4707    /// historical primary-only behaviour (`resolve_prefix` /
4708    /// `resolve_prefix_including_deleted`); `None` applies
4709    /// no namespace predicate at all (`resolve_prefix_unfiltered*`).
4710    /// Ambiguity (a prefix matching more than one UUID, even across
4711    /// different namespaces in the set, or across all namespaces when
4712    /// unfiltered) is still an error: UUIDs are globally unique, so two
4713    /// distinct rows sharing a prefix always requires caller disambiguation —
4714    /// no cross-namespace dedup is needed or performed.
4715    async fn resolve_prefix_inner(
4716        &self,
4717        namespaces: Option<&[String]>,
4718        prefix: &str,
4719        include_deleted: bool,
4720        require_tables: bool,
4721    ) -> RuntimeResult<Option<Uuid>> {
4722        // Every caller is expected to pre-validate hex-only input, but this is
4723        // the single choke point every `resolve_prefix*` variant funnels
4724        // through, so re-validate here too. A prefix containing anything other
4725        // than hex digits and canonical hyphen separators (`%`, `_`, or other
4726        // injection-shaped input) never matches a real id and is rejected
4727        // before it can reach the range query.
4728        if !prefix.chars().all(|c| c.is_ascii_hexdigit() || c == '-') {
4729            return Ok(None);
4730        }
4731
4732        // Injection: check PREFIX_RESOLVE_FAIL_NS (armed by
4733        // `arm_prefix_resolve_fail_scoped(prefix)`), exercising the storage-failure path
4734        // a genuine pool checkout timeout or WAL contention would take.
4735        #[cfg(any(test, feature = "fault-injection"))]
4736        if consume_fault(&PREFIX_RESOLVE_FAIL_NS, prefix) {
4737            return Err(RuntimeError::Storage(
4738                khive_storage::StorageError::Timeout {
4739                    operation: "resolve_prefix".into(),
4740                },
4741            ));
4742        }
4743
4744        let Some((lower, upper)) = uuid_prefix_bounds(prefix) else {
4745            return Ok(None);
4746        };
4747
4748        let tables = [
4749            ("entities", true),
4750            ("notes", true),
4751            ("events", false),
4752            ("graph_edges", false),
4753        ];
4754
4755        // A UUID can legitimately exist in more than one scanned table
4756        // (e.g. an entity id string that also happens to be an edge id — the
4757        // lookup is a text-prefix range across independent tables, not a
4758        // substrate-exclusive lookup). Without dedup, a single record hit
4759        // twice across tables inflated `matches.len()` past 1 and produced a
4760        // false `AmbiguousPrefix` naming the SAME UUID twice. `seen` tracks
4761        // UUIDs already pushed so `matches` (and thus every length check,
4762        // including the early-exit below) reflects DISTINCT UUIDs only.
4763        let mut matches: Vec<String> = Vec::new();
4764        let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
4765        let mut reader = self.sql().reader().await.map_err(RuntimeError::Storage)?;
4766
4767        for (table, has_deleted_at) in tables {
4768            let sql = resolve_prefix_statement(
4769                table,
4770                has_deleted_at,
4771                include_deleted,
4772                namespaces,
4773                &lower,
4774                &upper,
4775            );
4776            match reader.query_all(sql).await {
4777                Ok(rows) => {
4778                    for row in rows {
4779                        if let Some(col) = row.columns.first() {
4780                            if let SqlValue::Text(s) = &col.value {
4781                                if seen.insert(s.clone()) {
4782                                    matches.push(s.clone());
4783                                }
4784                            }
4785                        }
4786                    }
4787                }
4788                Err(e) => {
4789                    let msg = e.to_string();
4790                    if !require_tables && msg.contains("no such table") {
4791                        continue;
4792                    }
4793                    return Err(RuntimeError::Storage(e));
4794                }
4795            }
4796            if matches.len() > 1 {
4797                break;
4798            }
4799        }
4800
4801        // Sidecar-resident event rows (events-split lane) are invisible to the
4802        // main-store scan above; a prefix naming one must still resolve, and a
4803        // prefix colliding across the two files must still read as ambiguous,
4804        // so the sidecar scan merges into the same `matches`/`seen` set.
4805        if matches.len() <= 1 {
4806            if let Some(sidecar_sql) = self.events_sidecar_sql_read_only()? {
4807                let mut params = vec![SqlValue::Text(lower.clone()), SqlValue::Text(upper.clone())];
4808                if let Some(ns) = namespaces {
4809                    params.extend(ns.iter().map(|n| SqlValue::Text(n.clone())));
4810                }
4811                let namespace_clause = namespaces.map(|namespaces| {
4812                    let placeholders: Vec<String> = (0..namespaces.len())
4813                        .map(|index| format!("?{}", index + 3))
4814                        .collect();
4815                    format!(" AND namespace IN ({})", placeholders.join(", "))
4816                });
4817                let sql = SqlStatement {
4818                    sql: format!(
4819                        "SELECT id FROM events \
4820                         WHERE id >= ?1 AND id < ?2{namespace_clause} LIMIT 2",
4821                        namespace_clause = namespace_clause.as_deref().unwrap_or("")
4822                    ),
4823                    params,
4824                    label: Some("resolve_prefix.events_sidecar".into()),
4825                };
4826                let mut sidecar_reader =
4827                    sidecar_sql.reader().await.map_err(RuntimeError::Storage)?;
4828                match sidecar_reader.query_all(sql).await {
4829                    Ok(rows) => {
4830                        for row in rows {
4831                            if let Some(col) = row.columns.first() {
4832                                if let SqlValue::Text(s) = &col.value {
4833                                    if seen.insert(s.clone()) {
4834                                        matches.push(s.clone());
4835                                    }
4836                                }
4837                            }
4838                        }
4839                    }
4840                    Err(e) => {
4841                        let msg = e.to_string();
4842                        if require_tables || !msg.contains("no such table") {
4843                            return Err(RuntimeError::Storage(e));
4844                        }
4845                    }
4846                }
4847            }
4848        }
4849
4850        match matches.len() {
4851            0 => Ok(None),
4852            1 => {
4853                let uuid = Uuid::from_str(&matches[0])
4854                    .map_err(|e| RuntimeError::Internal(format!("stored UUID is invalid: {e}")))?;
4855                Ok(Some(uuid))
4856            }
4857            _ => {
4858                let uuids: Vec<uuid::Uuid> = matches
4859                    .iter()
4860                    .filter_map(|s| Uuid::from_str(s).ok())
4861                    .collect();
4862                Err(RuntimeError::AmbiguousPrefix {
4863                    prefix: prefix.to_string(),
4864                    matches: uuids,
4865                })
4866            }
4867        }
4868    }
4869
4870    /// Resolve a UUID to its substrate kind with NO namespace filter.
4871    ///
4872    /// By-ID contract: UUID v4 is globally unique: by-ID substrate
4873    /// inference must return the record regardless of caller namespace.  Used by
4874    /// the public `update` and `delete` verb handlers when no explicit `kind` is
4875    /// supplied.
4876    ///
4877    /// Does NOT consult the visible set or the primary-namespace check.  The
4878    /// token is still required to route to the correct backend pool but its
4879    /// namespace value is not used as a filter.
4880    pub async fn resolve_by_id(
4881        &self,
4882        token: &NamespaceToken,
4883        id: Uuid,
4884    ) -> RuntimeResult<Option<Resolved>> {
4885        // Entity: direct by-UUID fetch (ID-only, no namespace check).
4886        if let Some(entity) = self.entities(token)?.get_entity(id).await? {
4887            return Ok(Some(Resolved::Entity(entity)));
4888        }
4889
4890        // Note: direct by-UUID fetch (ID-only).
4891        if let Some(note) = self.notes(token)?.get_note(id).await? {
4892            return Ok(Some(Resolved::Note(note)));
4893        }
4894
4895        // Edges and events are not returned here; the caller's `_` arm handles
4896        // those with a separate get_edge / get_event check.
4897        Ok(None)
4898    }
4899
4900    /// Resolve a UUID to its substrate kind with NO namespace filter, including
4901    /// soft-deleted rows.
4902    ///
4903    /// Used by the hard-delete path when no explicit `kind` is supplied, so
4904    /// already-soft-deleted records can still be located by UUID alone.
4905    pub async fn resolve_by_id_including_deleted(
4906        &self,
4907        token: &NamespaceToken,
4908        id: Uuid,
4909    ) -> RuntimeResult<Option<Resolved>> {
4910        // Entity: including soft-deleted, no namespace check.
4911        if let Some(entity) = self
4912            .entities(token)?
4913            .get_entity_including_deleted(id)
4914            .await?
4915        {
4916            return Ok(Some(Resolved::Entity(entity)));
4917        }
4918
4919        // Note: including soft-deleted, no namespace check.
4920        if let Some(note) = self.notes(token)?.get_note_including_deleted(id).await? {
4921            return Ok(Some(Resolved::Note(note)));
4922        }
4923
4924        // Edges and events are not returned here; the caller's `_` arm handles
4925        // those with a separate get_edge_including_deleted check.
4926        Ok(None)
4927    }
4928
4929    /// Resolve a UUID to its substrate kind by trying entity, then note, then event stores.
4930    ///
4931    /// Returns `None` if the UUID is not found in any substrate.
4932    /// Cost: at most 3 store lookups per call (cheap for v0.1).
4933    pub async fn resolve(
4934        &self,
4935        token: &NamespaceToken,
4936        id: Uuid,
4937    ) -> RuntimeResult<Option<Resolved>> {
4938        // Entity: use the namespace-checked getter (errors on mismatch/absent).
4939        match self.get_entity(token, id).await {
4940            Ok(entity) => return Ok(Some(Resolved::Entity(entity))),
4941            Err(RuntimeError::NotFound(_) | RuntimeError::NamespaceMismatch { .. }) => {}
4942            Err(e) => return Err(e),
4943        }
4944
4945        // Note: storage get_note is ID-only — verify against visible set.
4946        if let Some(note) = self.notes(token)?.get_note(id).await? {
4947            if Self::ensure_namespace_visible(&note.namespace, token).is_ok() {
4948                return Ok(Some(Resolved::Note(note)));
4949            }
4950        }
4951
4952        // Event: storage get_event is ID-only — verify against visible set.
4953        if let Some(event) = self.events(token)?.get_event(id).await? {
4954            if Self::ensure_namespace_visible(&event.namespace, token).is_ok() {
4955                return Ok(Some(Resolved::Event(event)));
4956            }
4957        }
4958
4959        Ok(None)
4960    }
4961
4962    /// Resolve a UUID to its substrate kind with NO namespace filter, for edge
4963    /// endpoint validation.
4964    ///
4965    /// `link` and `create`'s `annotates` targets consume by-ID endpoints, so
4966    /// their existence check must follow the same by-ID contract as `get()`:
4967    /// by-ID ops are namespace-agnostic: the Gate, not storage-layer
4968    /// filtering, is the authz seam. Mirrors `resolve_by_id`
4969    /// (entity + note, unfiltered) and additionally resolves events,
4970    /// unfiltered, so edge endpoint validation resolves exactly what `get()`
4971    /// resolves regardless of the caller's namespace.
4972    pub async fn resolve_edge_endpoint(
4973        &self,
4974        token: &NamespaceToken,
4975        id: Uuid,
4976    ) -> RuntimeResult<Option<Resolved>> {
4977        if let Some(resolved) = self.resolve_by_id(token, id).await? {
4978            return Ok(Some(resolved));
4979        }
4980        if let Some(event) = self.events(token)?.get_event(id).await? {
4981            return Ok(Some(Resolved::Event(event)));
4982        }
4983        Ok(None)
4984    }
4985
4986    /// Resolve a UUID to its substrate kind using primary-namespace-only enforcement.
4987    ///
4988    /// Unlike `resolve`, never consults the visible set. Use from GTD dependency
4989    /// validation paths where strict primary ownership is required.
4990    pub async fn resolve_primary(
4991        &self,
4992        token: &NamespaceToken,
4993        id: Uuid,
4994    ) -> RuntimeResult<Option<Resolved>> {
4995        let ns = token.namespace().as_str();
4996
4997        // Entity: primary-only check (exclude entities in visible-only namespaces).
4998        if let Some(entity) = self.entities(token)?.get_entity(id).await? {
4999            if Self::ensure_namespace(&entity.namespace, ns).is_ok() {
5000                return Ok(Some(Resolved::Entity(entity)));
5001            }
5002        }
5003
5004        // Note: primary-only check.
5005        if let Some(note) = self.notes(token)?.get_note(id).await? {
5006            if Self::ensure_namespace(&note.namespace, ns).is_ok() {
5007                return Ok(Some(Resolved::Note(note)));
5008            }
5009        }
5010
5011        // Event: primary-only check.
5012        if let Some(event) = self.events(token)?.get_event(id).await? {
5013            if Self::ensure_namespace(&event.namespace, ns).is_ok() {
5014                return Ok(Some(Resolved::Event(event)));
5015            }
5016        }
5017
5018        Ok(None)
5019    }
5020
5021    /// Resolve a UUID to its substrate kind, including soft-deleted rows.
5022    ///
5023    /// Used exclusively by the hard-delete path to locate records that have
5024    /// already been soft-deleted. Namespace isolation is still enforced.
5025    pub async fn resolve_including_deleted(
5026        &self,
5027        token: &NamespaceToken,
5028        id: Uuid,
5029    ) -> RuntimeResult<Option<Resolved>> {
5030        let ns = token.namespace().as_str();
5031
5032        if let Some(entity) = self
5033            .entities(token)?
5034            .get_entity_including_deleted(id)
5035            .await?
5036        {
5037            if Self::ensure_namespace(&entity.namespace, ns).is_ok() {
5038                return Ok(Some(Resolved::Entity(entity)));
5039            }
5040        }
5041
5042        if let Some(note) = self.notes(token)?.get_note_including_deleted(id).await? {
5043            if Self::ensure_namespace(&note.namespace, ns).is_ok() {
5044                return Ok(Some(Resolved::Note(note)));
5045            }
5046        }
5047
5048        if let Some(event) = self.events(token)?.get_event(id).await? {
5049            if Self::ensure_namespace(&event.namespace, ns).is_ok() {
5050                return Ok(Some(Resolved::Event(event)));
5051            }
5052        }
5053
5054        Ok(None)
5055    }
5056
5057    /// Hard-delete a single graph node (entity, note, or edge-as-node row) AND purge its
5058    /// incident edges in ONE write transaction — closes a race where a concurrent guarded
5059    /// write could insert a fresh edge against the endpoint between two separately
5060    /// committed calls; see docs/operations.md#atomic_hard_delete_with_edge_purge.
5061    ///
5062    /// `row_statement` is the exact hard-delete `DELETE` for the target row
5063    /// (entity, note, or edge). Before the incident-edge purge, the same plan
5064    /// appends any ADR-002 lineage-loss warnings from the still-present edge
5065    /// rows, so the warning payload and cascade commit or roll back together.
5066    /// Returns `Ok(true)` if the row was deleted, `Ok(false)` if it no longer
5067    /// existed (lost a race with a concurrent delete of the same row).
5068    async fn atomic_hard_delete_with_edge_purge(
5069        &self,
5070        row_statement: SqlStatement,
5071        node_id: Uuid,
5072        namespace: &str,
5073        actor: &str,
5074        substrate: SubstrateKind,
5075    ) -> RuntimeResult<bool> {
5076        let mut statements = vec![PlanStatement {
5077            statement: row_statement,
5078            guard: Some(AffectedRowGuard::exactly(1)),
5079        }];
5080        if substrate == SubstrateKind::Entity {
5081            statements.push(PlanStatement {
5082                statement: khive_db::stores::attachment::delete_record_attachments_statement(
5083                    node_id,
5084                    AttachmentSubstrate::Entity,
5085                ),
5086                guard: None,
5087            });
5088        }
5089        statements.extend(
5090            hard_delete_lineage_warning_statements(namespace, actor, node_id, substrate)
5091                .into_iter()
5092                .map(|statement| PlanStatement {
5093                    statement,
5094                    guard: None,
5095                }),
5096        );
5097        statements.push(PlanStatement {
5098            statement: purge_incident_edges_statement(node_id),
5099            guard: None,
5100        });
5101        let plan = AtomicOpPlan::Delete(DeletePlan {
5102            target_id: node_id,
5103            statements,
5104            post_commit: PostCommitEffect::None,
5105        });
5106        match run_atomic_unit(self.sql().as_ref(), vec![plan]).await {
5107            Ok(AtomicRunOutcome::Committed { .. }) => Ok(true),
5108            Ok(AtomicRunOutcome::RolledBack {
5109                failure: AtomicOpFailure::NoteConflict(conflict),
5110                ..
5111            }) => Err(conflict.into_error().into()),
5112            Ok(AtomicRunOutcome::RolledBack {
5113                failure: AtomicOpFailure::EntityConflict(conflict),
5114                ..
5115            }) => Err(conflict.into_error().into()),
5116            Ok(AtomicRunOutcome::RolledBack {
5117                failure: AtomicOpFailure::GuardFailed { .. },
5118                ..
5119            }) => Ok(false),
5120            Ok(AtomicRunOutcome::RolledBack {
5121                failure: AtomicOpFailure::SqlError { message, .. },
5122                ..
5123            }) => Err(RuntimeError::Internal(format!(
5124                "hard delete + edge purge for {node_id} failed: {message}"
5125            ))),
5126            Err(e) => Err(RuntimeError::Internal(format!(
5127                "hard delete + edge purge for {node_id}: atomic unit seam failure: {}",
5128                e.0
5129            ))),
5130        }
5131    }
5132
5133    /// Restore an entity tombstone owned by the caller's primary namespace.
5134    ///
5135    /// The restore is guarded by both the tombstone's id/namespace and the
5136    /// current uniqueness state, so a caller cannot resurrect over a newer
5137    /// live record. Indexes are rebuilt only after the row restore commits.
5138    pub async fn restore_entity(
5139        &self,
5140        token: &NamespaceToken,
5141        id: Uuid,
5142    ) -> RuntimeResult<Option<(Entity, bool)>> {
5143        let Some(entity) = self
5144            .entities(token)?
5145            .get_entity_including_deleted(id)
5146            .await?
5147        else {
5148            return Ok(None);
5149        };
5150        if entity.namespace != token.namespace().as_str() {
5151            return Ok(None);
5152        }
5153        // A merge tombstone is not a plain soft delete: the source row carries
5154        // merge provenance and its content already lives on the kept entity.
5155        // Clearing only `deleted_at` would bring the source back as a live
5156        // duplicate that still claims to have been merged. Refuse and name
5157        // the kept id; restore does not undo a merge.
5158        //
5159        // The merge check runs before the already-live short cut on purpose:
5160        // a live row that still carries `merged_into` is what an earlier
5161        // restore left behind before this guard existed, and answering it
5162        // "already live" would hide the invariant violation from the one
5163        // caller who is looking at the row. Name it instead.
5164        if let Some(kept_id) = entity.merged_into {
5165            if entity.deleted_at.is_none() {
5166                return Err(live_merged_entity_refused(id, kept_id));
5167            }
5168            return Err(merge_tombstone_restore_refused(id, kept_id));
5169        }
5170        if entity.deleted_at.is_none() {
5171            return Ok(Some((entity, false)));
5172        }
5173        let updated_at =
5174            Utc::now()
5175                .timestamp_micros()
5176                .max(entity.updated_at.checked_add(1).ok_or_else(|| {
5177                    RuntimeError::Internal(format!(
5178                        "entity {id} updated_at is already at i64::MAX and cannot advance"
5179                    ))
5180                })?);
5181        let mut restored = entity;
5182        restored.deleted_at = None;
5183        restored.updated_at = updated_at;
5184        restored.version = restored
5185            .version
5186            .checked_add(1)
5187            .ok_or_else(|| RuntimeError::InvalidInput("entity version overflow".into()))?;
5188        let mut statements = vec![PlanStatement {
5189            statement: SqlStatement {
5190                sql: "UPDATE entities SET deleted_at=NULL, updated_at=?1, version=version+1 \
5191                      WHERE id=?2 AND namespace=?3 AND deleted_at IS NOT NULL AND version=?4"
5192                    .into(),
5193                params: vec![
5194                    SqlValue::Integer(updated_at),
5195                    SqlValue::Text(id.to_string()),
5196                    SqlValue::Text(token.namespace().as_str().to_owned()),
5197                    SqlValue::Integer(restored.version - 1),
5198                ],
5199                label: Some("entity-restore".into()),
5200            },
5201            guard: Some(AffectedRowGuard::exactly(1)),
5202        }];
5203        // The soft delete removed the FTS row, so the text index is published
5204        // in the same unit as the row: a live row that search cannot find is
5205        // not a state this verb can leave behind. Order-sensitive pair — see
5206        // `insert_document_statements`'s adjacency contract.
5207        for statement in khive_db::stores::text::delete_document_statements(
5208            "fts_entities",
5209            &restored.namespace,
5210            id,
5211        )
5212        .into_iter()
5213        .chain(insert_document_statements(
5214            "fts_entities",
5215            &entity_fts_document(&restored),
5216        )) {
5217            statements.push(PlanStatement {
5218                statement,
5219                guard: None,
5220            });
5221        }
5222        let plan = AtomicOpPlan::Update(Box::new(UpdatePlan {
5223            graph_effects: Vec::new(),
5224            target_id: id,
5225            statements,
5226            post_commit: PostCommitEffect::None,
5227            edge_natural_key: None,
5228            idempotent_noop: false,
5229            entity_guard: None,
5230            note_guard: None,
5231            note_vector_purge: None,
5232            note_embedding_inheritance: None,
5233        }));
5234        match run_atomic_unit(self.sql().as_ref(), vec![plan]).await {
5235            Ok(AtomicRunOutcome::Committed { .. }) => {
5236                // Embeddings are rebuilt after the commit; the row and its
5237                // text index are already live, so a failure here names that.
5238                #[cfg(any(test, feature = "fault-injection"))]
5239                if consume_fault(&FTS_FAIL_NS, &restored.namespace) {
5240                    return Err(restore_reindex_failed(
5241                        "entity",
5242                        id,
5243                        RuntimeError::Internal("injected FTS failure".to_string()),
5244                    ));
5245                }
5246                self.reindex_entity(token, &restored)
5247                    .await
5248                    .map_err(|e| restore_reindex_failed("entity", id, e))?;
5249                Ok(Some((restored, true)))
5250            }
5251            Ok(AtomicRunOutcome::RolledBack { failure, .. }) => Err(RuntimeError::Internal(
5252                format!("entity restore rolled back: {failure:?}"),
5253            )),
5254            Err(error) => Err(RuntimeError::Storage(error.0)),
5255        }
5256    }
5257
5258    /// Restore a note tombstone owned by the caller's primary namespace.
5259    ///
5260    /// A live note holding the tombstone's `(namespace, kind, key)` refuses
5261    /// the operation before any row changes. The same condition is repeated
5262    /// in the guarded restore statement for the concurrent race.
5263    pub async fn restore_note(
5264        &self,
5265        token: &NamespaceToken,
5266        id: Uuid,
5267    ) -> RuntimeResult<Option<(Note, bool)>> {
5268        let Some(note) = self.notes(token)?.get_note_including_deleted(id).await? else {
5269            return Ok(None);
5270        };
5271        if note.namespace != token.namespace().as_str() {
5272            return Ok(None);
5273        }
5274        if note.deleted_at.is_none() {
5275            return Ok(Some((note, false)));
5276        }
5277        if let Some(key) = note.key.as_deref() {
5278            if let Some(holder) = self
5279                .notes(token)?
5280                .get_live_notes_by_key(&note.namespace, key, Some(&note.kind))
5281                .await?
5282                .into_iter()
5283                .find(|holder| holder.id != note.id)
5284            {
5285                return Err(restore_key_conflict(key, &holder));
5286            }
5287        }
5288        let updated_at =
5289            Utc::now()
5290                .timestamp_micros()
5291                .max(note.updated_at.checked_add(1).ok_or_else(|| {
5292                    RuntimeError::Internal(format!(
5293                        "note {id} updated_at is already at i64::MAX and cannot advance"
5294                    ))
5295                })?);
5296        let mut params = vec![
5297            SqlValue::Text("active".into()),
5298            SqlValue::Integer(updated_at),
5299            SqlValue::Text(id.to_string()),
5300            SqlValue::Text(note.namespace.clone()),
5301            SqlValue::Text(note.kind.clone()),
5302        ];
5303        let key_clause = if let Some(key) = note.key.as_deref() {
5304            params.push(SqlValue::Text(key.to_owned()));
5305            format!(
5306                " AND (key IS NULL OR NOT EXISTS (SELECT 1 FROM notes live \
5307                          WHERE live.namespace=?4 AND live.kind=?5 AND live.key=?{} \
5308                            AND live.deleted_at IS NULL AND live.id != notes.id))",
5309                params.len()
5310            )
5311        } else {
5312            String::new()
5313        };
5314        let mut restored = note.clone();
5315        restored.status = "active".into();
5316        restored.deleted_at = None;
5317        restored.updated_at = updated_at;
5318        restored.version = restored
5319            .version
5320            .checked_add(1)
5321            .ok_or_else(|| RuntimeError::Internal(format!("note {id} version is exhausted")))?;
5322        let mut statements = vec![PlanStatement {
5323            statement: SqlStatement {
5324                sql: format!(
5325                    "UPDATE notes SET status=?1, deleted_at=NULL, updated_at=?2 \
5326                     WHERE id=?3 AND namespace=?4 AND kind=?5 AND deleted_at IS NOT NULL{key_clause}"
5327                ),
5328                params,
5329                label: Some("note-restore".into()),
5330            },
5331            guard: Some(AffectedRowGuard::exactly(1)),
5332        }];
5333        // Text index published in the same unit as the row; see restore_entity.
5334        for statement in
5335            khive_db::stores::text::delete_document_statements("fts_notes", &restored.namespace, id)
5336                .into_iter()
5337                .chain(insert_document_statements(
5338                    "fts_notes",
5339                    &note_fts_document(&restored),
5340                ))
5341        {
5342            statements.push(PlanStatement {
5343                statement,
5344                guard: None,
5345            });
5346        }
5347        let plan = AtomicOpPlan::Update(Box::new(UpdatePlan {
5348            graph_effects: Vec::new(),
5349            target_id: id,
5350            statements,
5351            post_commit: PostCommitEffect::None,
5352            edge_natural_key: None,
5353            idempotent_noop: false,
5354            entity_guard: None,
5355            note_guard: None,
5356            note_vector_purge: None,
5357            note_embedding_inheritance: None,
5358        }));
5359        match run_atomic_unit(self.sql().as_ref(), vec![plan]).await {
5360            Ok(AtomicRunOutcome::Committed { .. }) => {
5361                #[cfg(any(test, feature = "fault-injection"))]
5362                if consume_fault(&FTS_FAIL_NS, &restored.namespace) {
5363                    return Err(restore_reindex_failed(
5364                        "note",
5365                        id,
5366                        RuntimeError::Internal("injected FTS failure".to_string()),
5367                    ));
5368                }
5369                self.reindex_note(token, &restored)
5370                    .await
5371                    .map_err(|e| restore_reindex_failed("note", id, e))?;
5372                Ok(Some((restored, true)))
5373            }
5374            Ok(AtomicRunOutcome::RolledBack {
5375                failure: AtomicOpFailure::GuardFailed { .. },
5376                ..
5377            }) => {
5378                if let Some(key) = note.key.as_deref() {
5379                    if let Some(holder) = self
5380                        .notes(token)?
5381                        .get_live_notes_by_key(&note.namespace, key, Some(&note.kind))
5382                        .await?
5383                        .into_iter()
5384                        .find(|holder| holder.id != note.id)
5385                    {
5386                        return Err(restore_key_conflict(key, &holder));
5387                    }
5388                }
5389                Err(RuntimeError::NotFound(format!(
5390                    "note {id} is no longer a caller-owned tombstone"
5391                )))
5392            }
5393            Ok(AtomicRunOutcome::RolledBack { failure, .. }) => Err(RuntimeError::Internal(
5394                format!("note restore rolled back: {failure:?}"),
5395            )),
5396            Err(error) => Err(RuntimeError::Storage(error.0)),
5397        }
5398    }
5399
5400    /// Restore an edge tombstone owned by the caller's primary namespace.
5401    pub async fn restore_edge(
5402        &self,
5403        token: &NamespaceToken,
5404        id: Uuid,
5405    ) -> RuntimeResult<Option<(Edge, bool)>> {
5406        let Some(edge) = self.get_edge_including_deleted(token, id).await? else {
5407            return Ok(None);
5408        };
5409        if edge.namespace != token.namespace().as_str() {
5410            return Ok(None);
5411        }
5412        if edge.deleted_at.is_none() {
5413            return Ok(Some((edge, false)));
5414        }
5415        let updated_at = Utc::now();
5416        let plan = AtomicOpPlan::Update(Box::new(UpdatePlan {
5417            graph_effects: Vec::new(),
5418            target_id: id,
5419            statements: vec![PlanStatement {
5420                statement: SqlStatement {
5421                    sql: "UPDATE graph_edges SET deleted_at=NULL, updated_at=?1 \
5422                          WHERE id=?2 AND namespace=?3 AND deleted_at IS NOT NULL"
5423                        .into(),
5424                    params: vec![
5425                        SqlValue::Integer(updated_at.timestamp_micros()),
5426                        SqlValue::Text(id.to_string()),
5427                        SqlValue::Text(edge.namespace.clone()),
5428                    ],
5429                    label: Some("edge-restore".into()),
5430                },
5431                guard: Some(AffectedRowGuard::exactly(1)),
5432            }],
5433            post_commit: PostCommitEffect::None,
5434            edge_natural_key: None,
5435            idempotent_noop: false,
5436            entity_guard: None,
5437            note_guard: None,
5438            note_vector_purge: None,
5439            note_embedding_inheritance: None,
5440        }));
5441        match run_atomic_unit(self.sql().as_ref(), vec![plan]).await {
5442            Ok(AtomicRunOutcome::Committed { .. }) => {
5443                let mut restored = edge;
5444                restored.deleted_at = None;
5445                restored.updated_at = updated_at;
5446                Ok(Some((restored, true)))
5447            }
5448            Ok(AtomicRunOutcome::RolledBack { failure, .. }) => Err(RuntimeError::Internal(
5449                format!("edge restore rolled back: {failure:?}"),
5450            )),
5451            Err(error) => Err(RuntimeError::Storage(error.0)),
5452        }
5453    }
5454
5455    /// Soft-delete or hard-delete a note by ID.
5456    ///
5457    /// On hard delete, cascades to remove all incident edges (both inbound and
5458    /// outbound) and cleans up FTS and vector indexes, preventing dangling
5459    /// references for `annotates` edges that target this note.
5460    /// Soft delete also cleans FTS and vector indexes; edges are left in place.
5461    ///
5462    /// UUID v4 is globally unique: no namespace filter on by-ID ops.
5463    /// Cascade and index cleanup target the RECORD's stored namespace, not the caller token's.
5464    /// Returns `Ok(false)` if the note does not exist.
5465    pub async fn delete_note(
5466        &self,
5467        token: &NamespaceToken,
5468        id: Uuid,
5469        hard: bool,
5470    ) -> RuntimeResult<bool> {
5471        let note_store = self.notes(token)?;
5472        let note = if hard {
5473            match note_store.get_note_including_deleted(id).await? {
5474                Some(n) => n,
5475                None => return Ok(false),
5476            }
5477        } else {
5478            match note_store.get_note(id).await? {
5479                Some(n) => n,
5480                None => return Ok(false),
5481            }
5482        };
5483        if let Some(error) = self.stream_member_error(&note).await? {
5484            return Err(error);
5485        }
5486        let mode = if hard {
5487            DeleteMode::Hard
5488        } else {
5489            DeleteMode::Soft
5490        };
5491
5492        // Route index cleanup through the RECORD's namespace, not the caller's.
5493        let record_tok = token.with_namespace(
5494            khive_types::Namespace::parse(&note.namespace)
5495                .map_err(|e| RuntimeError::Internal(format!("note namespace invalid: {e}")))?,
5496        );
5497        let record_ns = note.namespace.clone();
5498        let actor = format!("{}:{}", token.actor().kind, token.actor().id);
5499
5500        // On hard delete, the row delete and the incident-edge cascade (including
5501        // already-soft-deleted edges) run as ONE write transaction: see
5502        // `atomic_hard_delete_with_edge_purge`. Index cleanup follows the
5503        // commit; it is best-effort and idempotent, unlike the row/edge pair.
5504        let deleted = if hard {
5505            let deleted = self
5506                .atomic_hard_delete_with_edge_purge(
5507                    note_hard_delete_statement(id),
5508                    id,
5509                    &record_ns,
5510                    &actor,
5511                    SubstrateKind::Note,
5512                )
5513                .await?;
5514            self.text_for_notes(&record_tok)?
5515                .delete_document(&record_ns, id)
5516                .await?;
5517            // Scoped delete: iterate over EVERY registered embedding model's
5518            // vector store so non-default vectors don't orphan when the note is deleted.
5519            for model_name in self.registered_embedding_model_names() {
5520                self.vectors_for_model(&record_tok, &model_name)?
5521                    .delete(id)
5522                    .await?;
5523            }
5524            deleted
5525        } else {
5526            let deleted = note_store.delete_note(id, mode).await?;
5527            if deleted {
5528                self.text_for_notes(&record_tok)?
5529                    .delete_document(&record_ns, id)
5530                    .await?;
5531                for model_name in self.registered_embedding_model_names() {
5532                    self.vectors_for_model(&record_tok, &model_name)?
5533                        .delete(id)
5534                        .await?;
5535                }
5536            }
5537            deleted
5538        };
5539        if deleted {
5540            let event_store = self.events(&record_tok)?;
5541            let event = khive_storage::event::Event::new(
5542                record_ns.clone(),
5543                "delete",
5544                EventKind::NoteDeleted,
5545                SubstrateKind::Note,
5546                "",
5547            )
5548            .with_target(id)
5549            .with_payload(serde_json::json!({"id": id, "namespace": record_ns, "hard": hard}));
5550            event_store.append_event(event).await.map_err(|e| {
5551                RuntimeError::Internal(format!("delete_note: event store write failed: {e}"))
5552            })?;
5553            // A soft OR hard delete removes the note's vectors/FTS document
5554            // above: any pack-owned vector-derived cache (e.g.
5555            // khive-pack-memory's warm ANN index) needs to know the corpus
5556            // changed, reached via this generic hook so khive-runtime never
5557            // takes a dependency on khive-pack-memory. No-op when no pack has
5558            // installed a hook.
5559            self.fire_note_mutation_hook(&note.kind, id).await;
5560        }
5561        Ok(deleted)
5562    }
5563
5564    /// Row-first compensating delete for rolling back a partially-written note
5565    /// (e.g. `dual_write_message` rollback after a later delivery step fails).
5566    /// Unlike [`KhiveRuntime::delete_note`], which cleans up graph/FTS/
5567    /// vector indexes *before* removing the row, this removes the row first so
5568    /// that a cleanup failure afterward cannot leave the compensated note live.
5569    ///
5570    /// Returns `Ok(())` once the row is gone (whether or not cleanup fully
5571    /// succeeded). Returns `Err(RuntimeError::Internal)` naming the failed
5572    /// cleanup legs when row removal succeeded but cleanup did not — the
5573    /// message is gone, but stale index entries may remain and should be
5574    /// surfaced to the caller rather than silently discarded.
5575    ///
5576    /// Returns `Ok(())` immediately, with no cleanup attempted, if the note
5577    /// does not exist (nothing to compensate).
5578    ///
5579    /// Not a general-purpose replacement for `delete_note(..., hard=true)`:
5580    /// normal hard delete still needs cleanup-first semantics (no dangling
5581    /// references) since a caller-visible error there should not remove the row.
5582    pub async fn delete_note_row_first_for_compensation(
5583        &self,
5584        token: &NamespaceToken,
5585        id: Uuid,
5586    ) -> RuntimeResult<()> {
5587        let note_store = self.notes(token)?;
5588        let Some(note) = note_store.get_note_including_deleted(id).await? else {
5589            return Ok(());
5590        };
5591        let record_tok = NamespaceToken::for_namespace(
5592            khive_types::Namespace::parse(&note.namespace)
5593                .map_err(|e| RuntimeError::Internal(format!("note namespace invalid: {e}")))?,
5594        );
5595        let record_ns = note.namespace.clone();
5596
5597        // Critical ordering: remove the row before any cleanup that can fail.
5598        note_store.delete_note(id, DeleteMode::Hard).await?;
5599
5600        #[cfg(any(test, feature = "fault-injection"))]
5601        {
5602            let armed = ROLLBACK_CLEANUP_FAIL_NS.lock().unwrap().take();
5603            if armed.as_deref() == Some(record_ns.as_str()) {
5604                return Err(RuntimeError::Internal(
5605                    "row removed but compensation cleanup failed: injected=true".to_string(),
5606                ));
5607            }
5608        }
5609
5610        let mut cleanup_errors = Vec::new();
5611        if let Err(e) = self.graph(&record_tok)?.purge_incident_edges(id).await {
5612            cleanup_errors.push(format!("graph={e}"));
5613        }
5614        if let Err(e) = self
5615            .text_for_notes(&record_tok)?
5616            .delete_document(&record_ns, id)
5617            .await
5618        {
5619            cleanup_errors.push(format!("fts={e}"));
5620        }
5621        for model_name in self.registered_embedding_model_names() {
5622            if let Err(e) = self
5623                .vectors_for_model(&record_tok, &model_name)?
5624                .delete(id)
5625                .await
5626            {
5627                cleanup_errors.push(format!("vector[{model_name}]={e}"));
5628            }
5629        }
5630        if cleanup_errors.is_empty() {
5631            Ok(())
5632        } else {
5633            Err(RuntimeError::Internal(format!(
5634                "row removed but compensation cleanup failed: {}",
5635                cleanup_errors.join("; ")
5636            )))
5637        }
5638    }
5639}
5640
5641/// Result of a GQL/SPARQL query with optional validation warnings.
5642#[derive(Clone, Debug, Serialize)]
5643pub struct QueryResult {
5644    pub rows: Vec<SqlRow>,
5645    #[serde(skip_serializing_if = "Vec::is_empty")]
5646    pub warnings: Vec<String>,
5647    /// Zero-based offset of this deterministic result page.
5648    pub offset: usize,
5649    /// Effective payload bound after composing query `LIMIT` and server page size.
5650    pub page_size: usize,
5651    /// `true` when at least one additional match exists after this page.
5652    pub has_more: bool,
5653    /// GQL continuation offset. Present exactly when GQL has another page.
5654    #[serde(skip_serializing_if = "Option::is_none")]
5655    pub next_offset: Option<usize>,
5656    /// Backward-compatible alias for `has_more` (#1168, #1247, #1601).
5657    pub truncated: bool,
5658}
5659
5660/// Outcome of [`KhiveRuntime::update_edge_symmetric_dml`]'s in-transaction DML.
5661#[derive(Debug)]
5662enum SymmetricEdgeUpdateOutcome {
5663    /// A canonical row already existed (ADR-039 DO NOTHING): the
5664    /// requested edge was deleted, the existing canonical row (this id)
5665    /// left untouched.
5666    Absorbed(String),
5667    /// No conflict; the requested edge was updated in place.
5668    Updated,
5669    /// No conflict, but the in-place `UPDATE` matched zero rows because
5670    /// the row's revision or deletion marker moved after it was fetched —
5671    /// a concurrent writer raced this update and must be refused, not
5672    /// silently overwritten by a stale full-row write.
5673    Stale,
5674}
5675
5676impl KhiveRuntime {
5677    // ---- Query operations ----
5678
5679    /// Execute a GQL or SPARQL query string, returning raw SQL rows.
5680    ///
5681    /// The query is compiled to SQL with the namespace scope applied.
5682    /// GQL syntax: `MATCH (a:concept)-[e:extends]->(b) RETURN a, b LIMIT 10`
5683    /// SPARQL syntax: `SELECT ?a WHERE { ?a :kind "concept" . }`
5684    pub async fn query(&self, token: &NamespaceToken, query: &str) -> RuntimeResult<Vec<SqlRow>> {
5685        Ok(self
5686            .query_with_metadata(token, query, khive_query::CompileOptions::default())
5687            .await?
5688            .rows)
5689    }
5690
5691    /// Execute a GQL/SPARQL query, returning rows and any validation warnings.
5692    pub async fn query_with_metadata(
5693        &self,
5694        token: &NamespaceToken,
5695        query: &str,
5696        mut opts: khive_query::CompileOptions,
5697    ) -> RuntimeResult<QueryResult> {
5698        use khive_query::QueryValue;
5699        use khive_storage::types::SqlValue;
5700
5701        let (language, ast) = khive_query::language::parse_auto_with_language(query)?;
5702        if opts.max_limit == 0 {
5703            return Err(RuntimeError::InvalidInput(
5704                "query page size must be at least 1".into(),
5705            ));
5706        }
5707        let offset = ast.offset;
5708        let page_size = ast.limit.unwrap_or(opts.max_limit).min(opts.max_limit);
5709        opts.scopes = token
5710            .visible_namespaces()
5711            .iter()
5712            .map(|ns| ns.as_str().to_string())
5713            .collect();
5714        let compiled = khive_query::compile(&ast, &opts)?;
5715        let mut warnings = compiled.warnings;
5716        let truncation_check = compiled.truncation_check;
5717
5718        warnings.extend(self.with_pack_edge_rules(|pack_rules| {
5719            static_impossible_edge_pattern_warnings(language, &ast.pattern, pack_rules)
5720        }));
5721
5722        // Convert QueryValue params (query-layer type) to SqlValue (storage-layer type)
5723        // at the query–storage boundary.
5724        let params: Vec<SqlValue> = compiled
5725            .params
5726            .into_iter()
5727            .map(|qv| match qv {
5728                QueryValue::Null => SqlValue::Null,
5729                QueryValue::Integer(n) => SqlValue::Integer(n),
5730                QueryValue::Float(f) => SqlValue::Float(f),
5731                QueryValue::Text(s) => SqlValue::Text(s),
5732                QueryValue::Blob(b) => SqlValue::Blob(b),
5733            })
5734            .collect();
5735
5736        let mut reader = self.sql().reader().await?;
5737        let stmt = SqlStatement {
5738            sql: compiled.sql,
5739            params,
5740            label: None,
5741        };
5742        let mut rows = reader.query_all(stmt).await?;
5743
5744        // When the effective page size was the binding constraint, the compiled
5745        // SQL asked for one extra (sentinel) row. Its presence in the actual
5746        // result set — not the requested LIMIT — is the continuation signal.
5747        let mut truncated = false;
5748        if let Some(check) = truncation_check {
5749            if rows.len() > check.max_limit {
5750                rows.truncate(check.max_limit);
5751                truncated = true;
5752            }
5753        }
5754
5755        let next_offset = if truncated && language == khive_query::QueryLanguage::Gql {
5756            let next = offset.checked_add(rows.len()).ok_or_else(|| {
5757                RuntimeError::InvalidInput("GQL next_offset exceeds usize::MAX".into())
5758            })?;
5759            if next == offset {
5760                return Err(RuntimeError::InvalidInput(
5761                    "query page did not advance; page size must be at least 1".into(),
5762                ));
5763            }
5764            i64::try_from(next).map_err(|_| {
5765                RuntimeError::InvalidInput("GQL next_offset exceeds i64::MAX".into())
5766            })?;
5767            Some(next)
5768        } else {
5769            None
5770        };
5771
5772        if truncated {
5773            let Some(check) = truncation_check else {
5774                return Err(RuntimeError::Internal(
5775                    "truncated query result is missing sentinel metadata".into(),
5776                ));
5777            };
5778            let bound = match check.requested_limit {
5779                Some(requested) => {
5780                    format!("requested query LIMIT {requested} exceeds the effective page size")
5781                }
5782                None => "the query has no explicit LIMIT".to_string(),
5783            };
5784            let warning = match language {
5785                khive_query::QueryLanguage::Gql => {
5786                    let Some(next) = next_offset else {
5787                        return Err(RuntimeError::Internal(
5788                            "truncated GQL result is missing its continuation offset".into(),
5789                        ));
5790                    };
5791                    format!(
5792                        "result page capped at {} rows because {bound}; more matches exist. \
5793                         Continue the same GQL query with `SKIP {next}` (the machine-readable \
5794                         `next_offset`) and keep the same page size.",
5795                        check.max_limit
5796                    )
5797                }
5798                khive_query::QueryLanguage::Sparql => format!(
5799                    "result page capped at {} rows because {bound}; more matches exist. \
5800                     SPARQL OFFSET paging is not part of the supported dialect.",
5801                    check.max_limit
5802                ),
5803            };
5804            warnings.push(warning);
5805        }
5806
5807        Ok(QueryResult {
5808            rows,
5809            warnings,
5810            offset,
5811            page_size,
5812            has_more: truncated,
5813            next_offset,
5814            truncated,
5815        })
5816    }
5817
5818    /// Soft-delete or hard-delete an entity by ID (soft delete by default).
5819    ///
5820    /// On hard delete, cascades to remove all incident edges (both inbound and
5821    /// outbound) to prevent dangling references. Soft delete also cleans FTS
5822    /// and vector indexes; edges are left in place.
5823    /// Routed attachment cleanup is performed by the registry after ownership resolution.
5824    ///
5825    /// UUID v4 is globally unique: no namespace filter on by-ID ops.
5826    pub async fn delete_entity(
5827        &self,
5828        token: &NamespaceToken,
5829        id: Uuid,
5830        hard: bool,
5831    ) -> RuntimeResult<bool> {
5832        let entity = if hard {
5833            match self
5834                .entities(token)?
5835                .get_entity_including_deleted(id)
5836                .await?
5837            {
5838                Some(e) => e,
5839                None => return Ok(false),
5840            }
5841        } else {
5842            match self.entities(token)?.get_entity(id).await? {
5843                Some(e) => e,
5844                None => return Ok(false),
5845            }
5846        };
5847        let mode = if hard {
5848            DeleteMode::Hard
5849        } else {
5850            DeleteMode::Soft
5851        };
5852
5853        // Route cascade and index cleanup through the RECORD's namespace, not the caller's.
5854        let record_tok = token.with_namespace(
5855            khive_types::Namespace::parse(&entity.namespace)
5856                .map_err(|e| RuntimeError::Internal(format!("entity namespace invalid: {e}")))?,
5857        );
5858        let actor = format!("{}:{}", token.actor().kind, token.actor().id);
5859
5860        // On hard delete, the row delete and the incident-edge cascade (including
5861        // already-soft-deleted edges) run as ONE write transaction: see
5862        // `atomic_hard_delete_with_edge_purge`. Index cleanup follows the
5863        // commit; it is best-effort and idempotent, unlike the row/edge pair.
5864        let deleted = if hard {
5865            let deleted = self
5866                .atomic_hard_delete_with_edge_purge(
5867                    entity_hard_delete_statement(id),
5868                    id,
5869                    &entity.namespace,
5870                    &actor,
5871                    SubstrateKind::Entity,
5872                )
5873                .await?;
5874            // Cross-backend attachment cleanup requires the registry's ownership check.
5875            self.remove_from_indexes(&record_tok, id).await?;
5876            deleted
5877        } else {
5878            let deleted = self.entities(token)?.delete_entity(id, mode).await?;
5879            if deleted {
5880                self.remove_from_indexes(&record_tok, id).await?;
5881            }
5882            deleted
5883        };
5884        if deleted {
5885            let event_store = self.events(&record_tok)?;
5886            let ns = entity.namespace.clone();
5887            let event = khive_storage::event::Event::new(
5888                ns.clone(),
5889                "delete",
5890                EventKind::EntityDeleted,
5891                SubstrateKind::Entity,
5892                "",
5893            )
5894            .with_target(id)
5895            .with_payload(serde_json::json!({"id": id, "namespace": ns, "hard": hard}));
5896            event_store.append_event(event).await.map_err(|e| {
5897                RuntimeError::Internal(format!("delete_entity: event store write failed: {e}"))
5898            })?;
5899        }
5900        Ok(deleted)
5901    }
5902
5903    pub(crate) async fn delete_entity_attachments_on_core(&self, id: Uuid) -> RuntimeResult<bool> {
5904        let core = self.core();
5905        drop(core.attachments()?);
5906        let statement = khive_db::stores::attachment::delete_record_attachments_statement(
5907            id,
5908            AttachmentSubstrate::Entity,
5909        );
5910        Ok(core.sql().writer().await?.execute(statement).await? > 0)
5911    }
5912
5913    /// Count entities in a namespace, optionally filtered.
5914    pub async fn count_entities(
5915        &self,
5916        token: &NamespaceToken,
5917        kind: Option<&str>,
5918    ) -> RuntimeResult<u64> {
5919        let ns_strs: Vec<String> = token
5920            .visible_namespaces()
5921            .iter()
5922            .map(|ns| ns.as_str().to_owned())
5923            .collect();
5924        let filter = EntityFilter {
5925            kinds: match kind {
5926                Some(k) => vec![k.to_string()],
5927                None => vec![],
5928            },
5929            namespaces: ns_strs,
5930            ..Default::default()
5931        };
5932        Ok(self
5933            .entities(token)?
5934            .count_entities(token.namespace().as_str(), filter)
5935            .await?)
5936    }
5937
5938    // ---- Edge CRUD operations ----
5939
5940    /// Fetch a single edge by id.
5941    ///
5942    /// UUID v4 is globally unique: returns the edge regardless of which
5943    /// namespace the token carries. `Ok(None)` means the edge does not exist at all.
5944    pub async fn get_edge(
5945        &self,
5946        _token: &NamespaceToken,
5947        edge_id: Uuid,
5948    ) -> RuntimeResult<Option<Edge>> {
5949        let mut reader = self.sql().reader().await?;
5950        let record_ns = reader
5951            .query_scalar(SqlStatement {
5952                sql: "SELECT namespace FROM graph_edges \
5953                      WHERE id = ?1 AND deleted_at IS NULL LIMIT 1"
5954                    .into(),
5955                params: vec![SqlValue::Text(edge_id.to_string())],
5956                label: Some("get_edge_namespace".into()),
5957            })
5958            .await?;
5959
5960        let Some(SqlValue::Text(record_ns)) = record_ns else {
5961            return Ok(None);
5962        };
5963        // Route the storage fetch through the record's own namespace — the token is
5964        // just the caller context; by-ID ops cross namespace boundaries.
5965        let record_tok = NamespaceToken::for_namespace(
5966            khive_types::Namespace::parse(&record_ns)
5967                .map_err(|e| RuntimeError::Internal(format!("edge namespace invalid: {e}")))?,
5968        );
5969        Ok(self
5970            .graph(&record_tok)?
5971            .get_edge(LinkId::from(edge_id))
5972            .await?)
5973    }
5974
5975    /// Fetch a single edge by id.
5976    ///
5977    /// Delegates to `get_edge`: no visible-set check.  By-ID ops are
5978    /// namespace-agnostic; UUID v4 is globally unique.
5979    pub async fn get_edge_visible(
5980        &self,
5981        token: &NamespaceToken,
5982        edge_id: Uuid,
5983    ) -> RuntimeResult<Option<Edge>> {
5984        self.get_edge(token, edge_id).await
5985    }
5986
5987    /// Fetch an edge by UUID including soft-deleted rows.
5988    ///
5989    /// Returns the edge regardless of which namespace the token carries:
5990    /// UUID v4 is globally unique. Used by the hard-delete path so that a
5991    /// soft-deleted edge can still be purged via its edge ID.
5992    pub async fn get_edge_including_deleted(
5993        &self,
5994        _token: &NamespaceToken,
5995        edge_id: Uuid,
5996    ) -> RuntimeResult<Option<Edge>> {
5997        let mut reader = self.sql().reader().await?;
5998        let record_ns = reader
5999            .query_scalar(SqlStatement {
6000                sql: "SELECT namespace FROM graph_edges WHERE id = ?1 LIMIT 1".into(),
6001                params: vec![SqlValue::Text(edge_id.to_string())],
6002                label: Some("get_edge_including_deleted_namespace".into()),
6003            })
6004            .await?;
6005
6006        let Some(SqlValue::Text(record_ns)) = record_ns else {
6007            return Ok(None);
6008        };
6009        // Route through the record's own namespace store (no namespace equality check).
6010        let record_tok = NamespaceToken::for_namespace(
6011            khive_types::Namespace::parse(&record_ns)
6012                .map_err(|e| RuntimeError::Internal(format!("edge namespace invalid: {e}")))?,
6013        );
6014        Ok(self
6015            .graph(&record_tok)?
6016            .get_edge_including_deleted(LinkId::from(edge_id))
6017            .await?)
6018    }
6019
6020    /// Fetch an edge by natural key (namespace, canonical source/target, relation)
6021    /// including soft-deleted rows. Unlike [`Self::list_edges`]/[`Self::list_edges_after`],
6022    /// which always filter `deleted_at IS NULL`, this can render a tombstoned symmetric-edge
6023    /// survivor (ADR-039 DO NOTHING conflict absorption) — used by the atomic-apply
6024    /// post-commit result renderer, which otherwise reports "not found" for a committed
6025    /// update whose surviving row happens to be soft-deleted.
6026    ///
6027    /// `token` selects the store instance; `namespace` is the natural key's own
6028    /// namespace and is bound into the query explicitly. These can legitimately differ:
6029    /// the record namespace is fixed at prepare time (`EdgeNaturalKey::namespace`) and by-ID
6030    /// edge updates are namespace-agnostic (ADR-007 Rev 6), so the caller's ambient `token`
6031    /// namespace is never a safe substitute for the record's own — the prior implicit
6032    /// `self.namespace` scoping is exactly the bug this parameter closes (khive#1213/#1214).
6033    pub async fn get_edge_by_natural_key_including_deleted(
6034        &self,
6035        token: &NamespaceToken,
6036        namespace: &str,
6037        source_id: Uuid,
6038        target_id: Uuid,
6039        relation: EdgeRelation,
6040    ) -> RuntimeResult<Option<Edge>> {
6041        Ok(self
6042            .graph(token)?
6043            .get_edge_by_natural_key_including_deleted(namespace, source_id, target_id, relation)
6044            .await?)
6045    }
6046
6047    /// Maximum rows returned by a single [`Self::list_edges`] /
6048    /// [`Self::list_edges_after`] page. A lower bound the docs promise callers
6049    /// can rely on; kept as a named constant so tests can exercise pagination
6050    /// (page tiling, out-of-range offsets) without needing >1000 real rows.
6051    pub const EDGE_LIST_MAX_LIMIT: u32 = 1000;
6052
6053    /// List edges matching `filter`, paging by `offset`. `limit` is capped at
6054    /// [`Self::EDGE_LIST_MAX_LIMIT`]; defaults to 100. For O(1)-at-depth walks
6055    /// over large edge populations, prefer [`Self::list_edges_after`] instead
6056    /// of paging offset deep.
6057    pub async fn list_edges(
6058        &self,
6059        token: &NamespaceToken,
6060        filter: crate::curation::EdgeListFilter,
6061        limit: u32,
6062        offset: u32,
6063    ) -> RuntimeResult<Vec<Edge>> {
6064        let limit = limit.min(Self::EDGE_LIST_MAX_LIMIT);
6065        let visible = token.visible_namespaces();
6066
6067        // Common case: a single visible namespace — page directly against the
6068        // store so `offset`/`limit` reach SQL unmodified.
6069        if let [ns] = visible {
6070            let temp = NamespaceToken::for_namespace(ns.clone());
6071            let page = self
6072                .graph(&temp)?
6073                .query_edges(
6074                    filter.into(),
6075                    vec![SortOrder {
6076                        field: EdgeSortField::CreatedAt,
6077                        direction: khive_storage::types::SortDirection::Asc,
6078                    }],
6079                    PageRequest {
6080                        offset: offset.into(),
6081                        limit,
6082                    },
6083                )
6084                .await?;
6085            return Ok(page.items);
6086        }
6087
6088        // Multi-namespace visibility: one deterministic query with
6089        // `namespace IN (...)` and real SQL paging, mirroring
6090        // `list_entities`. Fetching per-namespace prefixes and slicing a
6091        // client-side merge re-sorted by UUID floats the offset window
6092        // between calls — pages silently duplicate and skip rows, so
6093        // enumeration never covers the set (#2088).
6094        let ns_strs: Vec<String> = visible.iter().map(|ns| ns.as_str().to_owned()).collect();
6095        let sort = vec![SortOrder {
6096            field: EdgeSortField::CreatedAt,
6097            direction: khive_storage::types::SortDirection::Asc,
6098        }];
6099        let graph = self.graph(token)?;
6100        match graph
6101            .query_edges_in_namespaces(
6102                &ns_strs,
6103                filter.clone().into(),
6104                sort.clone(),
6105                PageRequest {
6106                    offset: offset.into(),
6107                    limit,
6108                },
6109            )
6110            .await
6111        {
6112            Ok(page) => Ok(page.items),
6113            Err(khive_storage::StorageError::Unsupported { operation, .. })
6114                if operation == "query_edges_in_namespaces" =>
6115            {
6116                // Backend exercises the trait default (no batched
6117                // namespace query support): fall back to one `query_edges`
6118                // call per namespace. Unlike the pre-image fix for #2088,
6119                // this fetches an `offset + limit` prefix from every
6120                // namespace and merges by the *same* `(created_at, id)` key
6121                // each per-namespace fetch already orders by — the
6122                // pre-image bug sorted the merged set by UUID alone, a key
6123                // unrelated to the order each per-namespace prefix was cut
6124                // at, which floated the offset window and silently
6125                // duplicated/skipped rows across pages. Sorting by the
6126                // fetch's own order key keeps the top `offset + limit` of
6127                // the merge exactly equal to the true global prefix.
6128                let fetch_limit = offset.saturating_add(limit);
6129                let mut namespace_prefixes = Vec::new();
6130                for ns in visible {
6131                    let temp = NamespaceToken::for_namespace(ns.clone());
6132                    let page = self
6133                        .graph(&temp)?
6134                        .query_edges(
6135                            filter.clone().into(),
6136                            sort.clone(),
6137                            PageRequest {
6138                                offset: 0,
6139                                limit: fetch_limit,
6140                            },
6141                        )
6142                        .await?;
6143                    namespace_prefixes.push(page.items);
6144                }
6145                Ok(Self::merge_paged_namespace_edges(
6146                    namespace_prefixes,
6147                    offset,
6148                    limit,
6149                ))
6150            }
6151            Err(error) => Err(error.into()),
6152        }
6153    }
6154
6155    /// Merge per-namespace `(created_at, id)`-ordered edge prefixes (each
6156    /// already fetched up to `offset + limit` from its own namespace, as
6157    /// [`Self::list_edges`]'s trait-default fallback does) into one global
6158    /// `[offset, offset + limit)` page.
6159    ///
6160    /// Sorting by the *same key each prefix was already cut at* is what
6161    /// keeps this exact: the top `offset + limit` of the merged set is then
6162    /// provably equal to the true global prefix (a standard k-way merge
6163    /// argument — no element beyond position `offset + limit` in any single
6164    /// namespace can appear before that position in the global order). The
6165    /// pre-image #2088 bug instead re-sorted the merged set by UUID alone —
6166    /// a key unrelated to the order each namespace's prefix was fetched in —
6167    /// which floated the offset window and silently duplicated/skipped rows
6168    /// across pages.
6169    fn merge_paged_namespace_edges(
6170        namespace_prefixes: Vec<Vec<Edge>>,
6171        offset: u32,
6172        limit: u32,
6173    ) -> Vec<Edge> {
6174        let mut results: Vec<Edge> = namespace_prefixes.into_iter().flatten().collect();
6175        results.sort_by_key(|e| (e.created_at, Uuid::from(e.id)));
6176        let start = (offset as usize).min(results.len());
6177        let end = (start + limit as usize).min(results.len());
6178        results[start..end].to_vec()
6179    }
6180
6181    /// Keyset (seek) page of edges matching `filter`, ordered by immutable
6182    /// database-assigned insertion sequence. `after` is the last edge id from the
6183    /// previous page (exclusive); omit to start from the beginning. Returns
6184    /// `(items, next_after)` — `next_after` is `Some` when more rows remain
6185    /// past this page.
6186    ///
6187    /// Unlike [`Self::list_edges`], this is O(log n + limit) at any depth and
6188    /// genuinely new inserts are appended after already-issued boundaries. The
6189    /// cursor row is resolved including tombstones; a hard-deleted or
6190    /// out-of-scope cursor fails explicitly rather than hiding an incomplete
6191    /// traversal.
6192    pub async fn list_edges_after(
6193        &self,
6194        token: &NamespaceToken,
6195        filter: crate::curation::EdgeListFilter,
6196        after: Option<Uuid>,
6197        limit: u32,
6198    ) -> RuntimeResult<(Vec<Edge>, Option<Uuid>)> {
6199        let limit = limit.clamp(1, Self::EDGE_LIST_MAX_LIMIT);
6200        let visible = token.visible_namespaces();
6201        let limit_usize = limit as usize;
6202        let cursor_store = self.graph(token)?;
6203        let after = match after {
6204            Some(id) => {
6205                let edge = self
6206                    .get_edge_including_deleted(token, id)
6207                    .await?
6208                    .ok_or_else(|| RuntimeError::NotFound(format!("edge cursor {id}")))?;
6209                Self::ensure_namespace_visible(&edge.namespace, token)?;
6210                let sequence = cursor_store.edge_sequence(id).await?.ok_or_else(|| {
6211                    RuntimeError::Internal(format!(
6212                        "edge cursor {id} has no insertion-sequence ledger row"
6213                    ))
6214                })?;
6215                Some(SeekCursor { sequence, id })
6216            }
6217            None => None,
6218        };
6219
6220        if let [ns] = visible {
6221            let temp = NamespaceToken::for_namespace(ns.clone());
6222            let page = self
6223                .graph(&temp)?
6224                .query_edges_sequence_after(filter.into(), after, limit)
6225                .await?;
6226            return Ok((page.items, page.next_after.map(|cursor| cursor.id)));
6227        }
6228
6229        // Multi-namespace visibility: seek each namespace from the same
6230        // immutable boundary, merge in global insertion order, then take the head of
6231        // the merged set as this page.
6232        let probe_limit = limit.saturating_add(1);
6233        let mut results = Vec::new();
6234        for ns in visible {
6235            let temp = NamespaceToken::for_namespace(ns.clone());
6236            let page = self
6237                .graph(&temp)?
6238                .query_edges_sequence_after(filter.clone().into(), after, probe_limit)
6239                .await?;
6240            results.extend(page.items);
6241        }
6242        let ids = results
6243            .iter()
6244            .map(|edge| Uuid::from(edge.id))
6245            .collect::<Vec<_>>();
6246        let sequences = cursor_store
6247            .edge_sequences(&ids)
6248            .await?
6249            .into_iter()
6250            .collect::<HashMap<_, _>>();
6251        if let Some(missing) = ids.iter().find(|id| !sequences.contains_key(id)) {
6252            return Err(RuntimeError::Internal(format!(
6253                "edge {missing} has no insertion-sequence ledger row"
6254            )));
6255        }
6256        results.sort_by_key(|edge| {
6257            let id = Uuid::from(edge.id);
6258            (sequences[&id], id)
6259        });
6260        results.dedup_by_key(|e| Uuid::from(e.id));
6261        let has_more = results.len() > limit_usize;
6262        if has_more {
6263            results.truncate(limit_usize);
6264        }
6265        let next_after = if has_more {
6266            results.last().map(|e| Uuid::from(e.id))
6267        } else {
6268            None
6269        };
6270        Ok((results, next_after))
6271    }
6272
6273    /// Count edges by relation, ignoring soft-deleted rows. Used by
6274    /// `stats()` to report the true per-relation population so full-graph
6275    /// audits know what they're sampling from before they walk it.
6276    pub async fn count_edges_by_relation(
6277        &self,
6278        token: &NamespaceToken,
6279    ) -> RuntimeResult<std::collections::HashMap<String, u64>> {
6280        let namespaces: Vec<String> = token
6281            .visible_namespaces()
6282            .iter()
6283            .map(|namespace| namespace.as_str().to_owned())
6284            .collect();
6285        let graph = self.graph(token)?;
6286        let counts = match graph
6287            .count_edges_by_relation_in_namespaces(&namespaces)
6288            .await
6289        {
6290            Ok(counts) => counts,
6291            Err(khive_storage::StorageError::Unsupported { operation, .. })
6292                if operation == "count_edges_by_relation_in_namespaces" =>
6293            {
6294                let mut totals = HashMap::new();
6295                for namespace in token.visible_namespaces() {
6296                    let scoped = NamespaceToken::for_namespace(namespace.clone());
6297                    for (relation, count) in self.graph(&scoped)?.count_edges_by_relation().await? {
6298                        *totals.entry(relation).or_insert(0) += count;
6299                    }
6300                }
6301                return Ok(totals
6302                    .into_iter()
6303                    .map(|(relation, count)| (relation.to_string(), count))
6304                    .collect());
6305            }
6306            Err(error) => return Err(error.into()),
6307        };
6308        Ok(counts
6309            .into_iter()
6310            .map(|(relation, count)| (relation.to_string(), count))
6311            .collect())
6312    }
6313
6314    /// Count edges by the base each endpoint resolves against. Used by
6315    /// `stats()` so a caller can name the denominator of a density figure
6316    /// instead of inheriting the flat edge total, which on a real store is
6317    /// mostly provenance.
6318    ///
6319    /// The per-namespace fallback sums the same buckets, so the aggregate and
6320    /// the fallback are checkable against each other and against
6321    /// `count_edges` by the invariant that the buckets sum to the total.
6322    pub async fn count_edges_by_endpoint_base(
6323        &self,
6324        token: &NamespaceToken,
6325    ) -> RuntimeResult<khive_storage::types::EdgeEndpointBaseCounts> {
6326        use khive_storage::types::EdgeEndpointBaseCounts;
6327
6328        let namespaces: Vec<String> = token
6329            .visible_namespaces()
6330            .iter()
6331            .map(|namespace| namespace.as_str().to_owned())
6332            .collect();
6333        let graph = self.graph(token)?;
6334        match graph
6335            .count_edges_by_endpoint_base_in_namespaces(&namespaces)
6336            .await
6337        {
6338            Ok(counts) => Ok(counts),
6339            Err(khive_storage::StorageError::Unsupported { operation, .. })
6340                if operation == "count_edges_by_endpoint_base_in_namespaces"
6341                    || operation == "count_edges_by_endpoint_base" =>
6342            {
6343                let mut totals = EdgeEndpointBaseCounts::default();
6344                for namespace in token.visible_namespaces() {
6345                    let scoped = NamespaceToken::for_namespace(namespace.clone());
6346                    let counts = self.graph(&scoped)?.count_edges_by_endpoint_base().await?;
6347                    totals.entity_entity =
6348                        totals.entity_entity.saturating_add(counts.entity_entity);
6349                    totals.entity_note = totals.entity_note.saturating_add(counts.entity_note);
6350                    totals.note_entity = totals.note_entity.saturating_add(counts.note_entity);
6351                    totals.note_note = totals.note_note.saturating_add(counts.note_note);
6352                    totals.unresolved = totals.unresolved.saturating_add(counts.unresolved);
6353                }
6354                Ok(totals)
6355            }
6356            Err(error) => Err(error.into()),
6357        }
6358    }
6359
6360    /// DML-only body of the symmetric-relation conflict-resolution path in
6361    /// [`Self::update_edge`]. Runs the conflict-check SELECT, then either the
6362    /// DELETE+UPDATE (case b, a canonical row already exists) or the
6363    /// in-place UPDATE (case a, no conflict). Callers own the surrounding transaction
6364    /// boundary — this function issues DML only, no `BEGIN`/`COMMIT`/`ROLLBACK`.
6365    ///
6366    /// The in-place update is guarded on the fetched snapshot's revision and
6367    /// deletion marker (mirrors the non-symmetric `replace_edge_if_unchanged`
6368    /// guard) and requires the replacement revision to strictly advance.
6369    /// Shares its DML text with the atomic `prepare_update_edge` symmetric
6370    /// branch — see docs/operations.md#update_edge_symmetric_dml.
6371    #[allow(clippy::too_many_arguments)]
6372    fn update_edge_symmetric_dml(
6373        conn: &rusqlite::Connection,
6374        ns: &str,
6375        edge_id_str: &str,
6376        canon_src_str: &str,
6377        canon_tgt_str: &str,
6378        relation_str: &str,
6379        weight: f64,
6380        metadata: Option<String>,
6381        expected_updated_at_micros: i64,
6382        expected_deleted_at_micros: Option<i64>,
6383    ) -> Result<SymmetricEdgeUpdateOutcome, SqliteError> {
6384        // `updated_at` is stored in MICROSECONDS on `graph_edges` (every other
6385        // write path — `edge_upsert_statement`, `edge_soft_delete_statement` —
6386        // uses `timestamp_micros()`; the column is read back via
6387        // `micros_to_datetime`). `timestamp()` (seconds) here was a
6388        // pre-existing bug in this raw-SQL path, found while unifying it with
6389        // the atomic builder (which already used `timestamp_micros()`
6390        // correctly).
6391        //
6392        // The replacement revision must strictly advance past the snapshot
6393        // even when two operations land inside one clock microsecond;
6394        // saturating to i64::MAX would let the CAS accept a write without
6395        // advancing its revision, so that is not a valid fallback (mirrors
6396        // the note path).
6397        let minimum_updated_at_micros =
6398            expected_updated_at_micros.checked_add(1).ok_or_else(|| {
6399                SqliteError::InvalidData(format!(
6400                    "update_edge: edge {edge_id_str} updated_at is already at i64::MAX \
6401                         and cannot advance"
6402                ))
6403            })?;
6404        let now_ts = chrono::Utc::now()
6405            .timestamp_micros()
6406            .max(minimum_updated_at_micros);
6407
6408        // Check for a conflicting canonical row (same namespace + natural key,
6409        // different id). This catches conflicts whether or not endpoints were flipped.
6410        let conflict_id: Option<String> = conn
6411            .query_row(
6412                khive_db::stores::graph::EDGE_SYMMETRIC_CONFLICT_PROBE_SQL,
6413                rusqlite::params![
6414                    &ns,
6415                    &canon_src_str,
6416                    &canon_tgt_str,
6417                    &relation_str,
6418                    &edge_id_str
6419                ],
6420                |row| row.get(0),
6421            )
6422            .optional()
6423            .map_err(SqliteError::Rusqlite)?;
6424
6425        if let Some(existing_id) = conflict_id {
6426            // Case (b): canonical row already exists — ADR-039's edge-conflict
6427            // contract is ON CONFLICT DO NOTHING: drop the non-canonical edge
6428            // and leave the existing canonical row untouched (live or
6429            // tombstoned). Refreshing it from the discarded edge's
6430            // weight/target_backend/metadata and forcing deleted_at = NULL
6431            // would silently overwrite the survivor and resurrect a
6432            // tombstone — the same defect already fixed on the merge-rewire
6433            // path (`merge_entity_sql`/`merge_note_sql`); this path binds the
6434            // same shared `EDGE_SYMMETRIC_*_SQL` text and must honor the same
6435            // contract. Return the surviving id unchanged so the caller
6436            // re-fetches its real (unmodified) attributes.
6437            //
6438            // Guarded on the fetched snapshot's revision and deletion marker:
6439            // a concurrent writer that changed this edge between fetch and
6440            // this write must be refused, not silently deleted just because
6441            // a canonical survivor happens to exist. Zero affected rows here
6442            // means stale, not "no conflict" — the probe above already
6443            // confirmed a conflicting canonical row exists.
6444            let affected = conn
6445                .execute(
6446                    khive_db::stores::graph::EDGE_SYMMETRIC_DELETE_NONCANONICAL_GUARDED_SQL,
6447                    rusqlite::params![
6448                        &ns,
6449                        &edge_id_str,
6450                        expected_updated_at_micros,
6451                        expected_deleted_at_micros,
6452                    ],
6453                )
6454                .map_err(SqliteError::Rusqlite)?;
6455            if affected == 0 {
6456                return Ok(SymmetricEdgeUpdateOutcome::Stale);
6457            }
6458            Ok(SymmetricEdgeUpdateOutcome::Absorbed(existing_id))
6459        } else {
6460            // Case (a): no conflict — update source_id/target_id in-place,
6461            // preserving the original edge UUID. Guarded on the fetched
6462            // snapshot's revision and deletion marker: a concurrent writer
6463            // that moved this edge between fetch and this write must be
6464            // refused, not silently overwritten by a stale full-row update.
6465            let affected = conn
6466                .execute(
6467                    khive_db::stores::graph::EDGE_SYMMETRIC_UPDATE_INPLACE_SQL,
6468                    rusqlite::params![
6469                        &canon_src_str,
6470                        &canon_tgt_str,
6471                        &relation_str,
6472                        weight,
6473                        now_ts,
6474                        metadata,
6475                        &ns,
6476                        &edge_id_str,
6477                        expected_updated_at_micros,
6478                        expected_deleted_at_micros,
6479                    ],
6480                )
6481                .map_err(SqliteError::Rusqlite)?;
6482            if affected == 0 {
6483                return Ok(SymmetricEdgeUpdateOutcome::Stale);
6484            }
6485            Ok(SymmetricEdgeUpdateOutcome::Updated)
6486        }
6487    }
6488
6489    /// Patch-style edge update. Only `Some(_)` fields are applied.
6490    ///
6491    /// When `relation` is `Some(new_rel)`, validates that the edge's existing endpoints
6492    /// are legal for `new_rel` before persisting. Weight-only updates (`relation = None`)
6493    /// skip validation. Returns `InvalidInput` if the new relation would violate the
6494    /// three-case endpoint contract; the edge is NOT mutated on error.
6495    ///
6496    /// For symmetric relations (`competes_with`, `composed_with`), endpoint order is
6497    /// canonicalised to `source_uuid < target_uuid` after validation. If a canonical
6498    /// row already exists at the target triple, the non-canonical edge is deleted and
6499    /// the existing canonical row is preserved unchanged (ADR-039 ON CONFLICT DO
6500    /// NOTHING, mirroring `merge_entity_sql`) — its attributes, including a soft-deleted
6501    /// `deleted_at`, are never overwritten by the discarded edge's patch.
6502    pub async fn update_edge(
6503        &self,
6504        token: &NamespaceToken,
6505        edge_id: Uuid,
6506        patch: crate::curation::EdgePatch,
6507    ) -> RuntimeResult<Edge> {
6508        // Fetch the edge by UUID: ID-only, no namespace check.
6509        // get_edge already uses the record's stored namespace internally.
6510        let graph_for_fetch = self.graph(token)?;
6511        let mut edge = graph_for_fetch
6512            .get_edge(LinkId::from(edge_id))
6513            .await?
6514            .ok_or_else(|| crate::RuntimeError::NotFound(format!("edge {edge_id}")))?;
6515        let expected_updated_at = edge.updated_at;
6516        let expected_deleted_at = edge.deleted_at;
6517        #[cfg(test)]
6518        crate::curation::race_seam::pause_after_read().await;
6519
6520        // After fetching, all mutations and validation must use the
6521        // RECORD's namespace, not the caller's.  Derive record_tok from the stored edge
6522        // namespace so that endpoint validation, raw-SQL predicates, and graph routing
6523        // all address the correct backend partition.
6524        let record_ns: String = edge.namespace.clone();
6525        let record_tok = token.with_namespace(
6526            khive_types::Namespace::parse(&record_ns)
6527                .map_err(|e| RuntimeError::Internal(format!("edge namespace invalid: {e}")))?,
6528        );
6529        let graph = self.graph(&record_tok)?;
6530
6531        let mut changed_fields: Vec<&'static str> = Vec::new();
6532        if let Some(r) = patch.relation {
6533            // Validate before mutating — use the existing endpoints with the new relation.
6534            // Use record_tok so that endpoint existence checks look in the edge's own namespace.
6535            self.validate_edge_relation_endpoints(&record_tok, edge.source_id, edge.target_id, r)
6536                .await?;
6537            edge.relation = r;
6538            changed_fields.push("relation");
6539        }
6540        if let Some(w) = patch.weight {
6541            // Reject non-finite or out-of-range weight explicitly; do not silently
6542            // clamp invalid caller input (coding-standards §608-622).
6543            if !w.is_finite() || !(0.0..=1.0).contains(&w) {
6544                return Err(RuntimeError::InvalidInput(format!(
6545                    "edge weight must be a finite value in [0.0, 1.0]; got {w}"
6546                )));
6547            }
6548            edge.weight = w;
6549            changed_fields.push("weight");
6550        }
6551        if let Some(props) = patch.properties {
6552            crate::secret_gate::reject_reserved_secret_gate_property(Some(&props))?;
6553            edge.metadata = Some(props);
6554        }
6555
6556        // For symmetric relations, canonicalise endpoint order and check
6557        // for natural-key conflicts regardless of whether endpoints were flipped.
6558        //
6559        // The raw-SQL path is used for ALL symmetric relations because `upsert_edge`
6560        // resolves ON CONFLICT(namespace,id) first and cannot detect a duplicate at
6561        // the natural key (namespace, source_id, target_id, relation) with a different
6562        // id. Bug-fix: this path must also run when endpoints are already canonical
6563        // (endpoints_flipped=false) to catch conflicts arising from a relation change
6564        // that collides with an existing canonical row.
6565        let (canon_src, canon_tgt) =
6566            canonical_edge_endpoints(edge.relation, edge.source_id, edge.target_id);
6567
6568        if edge.relation.is_symmetric() {
6569            // Raw-SQL path (mirrors merge_entity_sql).
6570            // Use record_ns (the stored edge namespace) — NOT token.namespace() — so that
6571            // WHERE namespace = ?N predicates match the actual row.
6572            let ns = record_ns.clone();
6573            let edge_id_str = edge_id.to_string();
6574            let relation_str = edge.relation.to_string();
6575            let canon_src_str = canon_src.to_string();
6576            let canon_tgt_str = canon_tgt.to_string();
6577            let weight = edge.weight;
6578            let metadata = edge
6579                .metadata
6580                .as_ref()
6581                .map(|v| serde_json::to_string(v).unwrap_or_default());
6582
6583            let expected_updated_at_micros = expected_updated_at.timestamp_micros();
6584            let expected_deleted_at_micros = expected_deleted_at.map(|v| v.timestamp_micros());
6585
6586            let pool = self.backend().pool_arc();
6587            let writer_task = pool
6588                .writer_task_for_runtime_write(RuntimeWriteOperation::UpdateSymmetricEdge)
6589                .map_err(RuntimeError::Storage)?;
6590
6591            let outcome: SymmetricEdgeUpdateOutcome = if let Some(writer_task) = writer_task {
6592                writer_task
6593                    .send(move |conn| {
6594                        Self::update_edge_symmetric_dml(
6595                            conn,
6596                            &ns,
6597                            &edge_id_str,
6598                            &canon_src_str,
6599                            &canon_tgt_str,
6600                            &relation_str,
6601                            weight,
6602                            metadata,
6603                            expected_updated_at_micros,
6604                            expected_deleted_at_micros,
6605                        )
6606                        .map_err(|e| {
6607                            khive_storage::StorageError::driver(
6608                                khive_storage::StorageCapability::Graph,
6609                                "update_edge",
6610                                e,
6611                            )
6612                        })
6613                    })
6614                    .await
6615                    .map_err(RuntimeError::Storage)?
6616            } else {
6617                tokio::task::spawn_blocking(move || {
6618                    let guard = pool.writer()?;
6619                    guard.transaction(|conn| {
6620                        Self::update_edge_symmetric_dml(
6621                            conn,
6622                            &ns,
6623                            &edge_id_str,
6624                            &canon_src_str,
6625                            &canon_tgt_str,
6626                            &relation_str,
6627                            weight,
6628                            metadata,
6629                            expected_updated_at_micros,
6630                            expected_deleted_at_micros,
6631                        )
6632                    })
6633                })
6634                .await
6635                .map_err(|e| {
6636                    RuntimeError::Internal(format!("update_edge: spawn_blocking join: {e}"))
6637                })?
6638                .map_err(RuntimeError::Sqlite)?
6639            };
6640
6641            match outcome {
6642                SymmetricEdgeUpdateOutcome::Absorbed(sid) => {
6643                    // A conflict was absorbed (ADR-039 DO NOTHING): re-fetch the surviving
6644                    // canonical row so the caller receives its real, UNMODIFIED attributes —
6645                    // including soft-deleted rows, since the survivor's tombstone state (if
6646                    // any) must not be resurrected by the absorbed update either. Use
6647                    // record_tok — the surviving row lives in the same namespace as the
6648                    // original.
6649                    let surviving_uuid = Uuid::parse_str(&sid).map_err(|e| {
6650                        RuntimeError::Internal(format!(
6651                            "update_edge: surviving id parse failed: {e}"
6652                        ))
6653                    })?;
6654                    edge = self
6655                        .get_edge_including_deleted(&record_tok, surviving_uuid)
6656                        .await?
6657                        .ok_or_else(|| {
6658                            RuntimeError::Internal(format!(
6659                                "update_edge: surviving canonical row {surviving_uuid} vanished after update"
6660                            ))
6661                        })?;
6662                }
6663                SymmetricEdgeUpdateOutcome::Updated => {
6664                    // Reflect canonical endpoints in the returned edge (no conflict absorbed).
6665                    edge.source_id = canon_src;
6666                    edge.target_id = canon_tgt;
6667                }
6668                SymmetricEdgeUpdateOutcome::Stale => {
6669                    return Err(crate::curation::stale_edge_snapshot_error(edge_id));
6670                }
6671            }
6672        } else {
6673            // Non-symmetric: replace_edge_if_unchanged takes namespace from edge.namespace
6674            // (not from the graph store's routing namespace), so this is already
6675            // record-namespace correct. `graph` is already self.graph(&record_tok)?.
6676            // Guarded on the fetched snapshot's revision — a concurrent writer that moved
6677            // this edge between the fetch above and this write must be refused, not
6678            // silently overwritten by a full-row replacement derived from stale state.
6679            // `updated_at` must advance past the snapshot: the guard requires the
6680            // replacement revision to be strictly greater than the persisted one.
6681            // Make it strictly advance even when two operations land inside one
6682            // clock microsecond; saturating to i64::MAX would let the CAS accept
6683            // a write without advancing its revision, so that is not a valid
6684            // fallback (mirrors the note path).
6685            let minimum_updated_at_micros = expected_updated_at
6686                .timestamp_micros()
6687                .checked_add(1)
6688                .ok_or_else(|| {
6689                RuntimeError::Internal(format!(
6690                    "edge {edge_id} updated_at is already at i64::MAX and cannot advance"
6691                ))
6692            })?;
6693            let now_micros = chrono::Utc::now()
6694                .timestamp_micros()
6695                .max(minimum_updated_at_micros);
6696            edge.updated_at =
6697                chrono::DateTime::from_timestamp_micros(now_micros).ok_or_else(|| {
6698                    RuntimeError::Internal(format!(
6699                        "edge {edge_id}: computed updated_at {now_micros} is not a valid timestamp"
6700                    ))
6701                })?;
6702            let persisted = graph
6703                .replace_edge_if_unchanged(edge.clone(), expected_updated_at, expected_deleted_at)
6704                .await?;
6705            if !persisted {
6706                return Err(crate::curation::stale_edge_snapshot_error(edge_id));
6707            }
6708        }
6709
6710        // Audit event: use the record's namespace (record_ns) for the event payload.
6711        let event_store = self.events(&record_tok)?;
6712        let event = khive_storage::event::Event::new(
6713            record_ns.clone(),
6714            "update",
6715            EventKind::EdgeUpdated,
6716            SubstrateKind::Entity,
6717            "",
6718        )
6719        .with_target(edge_id)
6720        .with_payload(
6721            serde_json::json!({"id": edge_id, "namespace": record_ns, "changed_fields": changed_fields}),
6722        );
6723        event_store.append_event(event).await.map_err(|e| {
6724            RuntimeError::Internal(format!("update_edge: event store write failed: {e}"))
6725        })?;
6726
6727        Ok(edge)
6728    }
6729
6730    /// Hard-delete an edge by id.
6731    ///
6732    /// Cascades to remove any `annotates` edges whose target is the deleted edge
6733    /// (`annotates` is note → anything; deleting an edge target leaves annotation
6734    /// edges dangling if not cleaned up). Returns `true` if the primary
6735    /// edge was removed.
6736    ///
6737    /// If `edge_id` does not refer to an edge (e.g. the caller passes an entity or
6738    /// note UUID by mistake), this method returns `Ok(false)` immediately with no
6739    /// side effects — it does **not** cascade inbound edges of the non-edge record.
6740    pub async fn delete_edge(
6741        &self,
6742        token: &NamespaceToken,
6743        edge_id: Uuid,
6744        hard: bool,
6745    ) -> RuntimeResult<bool> {
6746        let mode = if hard {
6747            DeleteMode::Hard
6748        } else {
6749            DeleteMode::Soft
6750        };
6751
6752        // Fetch the edge first to obtain the record's own namespace.
6753        // By-ID ops cross namespace boundaries; all graph routing and audit
6754        // events must use the record namespace, not the caller's (mirrors update_edge).
6755        // For hard delete we also check soft-deleted rows so a soft-deleted edge
6756        // can still be purged via its edge ID.
6757        let edge = if hard {
6758            self.get_edge_including_deleted(token, edge_id).await?
6759        } else {
6760            self.get_edge(token, edge_id).await?
6761        };
6762        let Some(edge) = edge else {
6763            return Ok(false);
6764        };
6765
6766        // Derive record_ns / record_tok from the fetched edge (mirrors update_edge).
6767        let record_ns: String = edge.namespace.clone();
6768        let record_tok = token.with_namespace(
6769            khive_types::Namespace::parse(&record_ns)
6770                .map_err(|e| RuntimeError::Internal(format!("edge namespace invalid: {e}")))?,
6771        );
6772        let graph = self.graph(&record_tok)?;
6773        let actor = format!("{}:{}", token.actor().kind, token.actor().id);
6774
6775        // Cascade: on hard delete, remove ALL annotates edges targeting this edge — including
6776        // already-soft-deleted ones: to prevent dangling graph_edges rows. The row
6777        // delete and the cascade purge run as ONE write transaction: see
6778        // `atomic_hard_delete_with_edge_purge`.
6779        // On soft delete the cascade is skipped (data-vs-view principle: soft-deleting the base
6780        // edge does not cascade to annotation edges; only a hard purge cleans up incident rows).
6781        let deleted = if hard {
6782            self.atomic_hard_delete_with_edge_purge(
6783                edge_hard_delete_statement(edge_id),
6784                edge_id,
6785                &record_ns,
6786                &actor,
6787                SubstrateKind::Entity,
6788            )
6789            .await?
6790        } else {
6791            graph.delete_edge(LinkId::from(edge_id), mode).await?
6792        };
6793        if deleted {
6794            // Audit event: use the record's namespace (record_ns), not the caller's namespace.
6795            let event_store = self.events(&record_tok)?;
6796            let event = khive_storage::event::Event::new(
6797                record_ns.clone(),
6798                "delete",
6799                EventKind::EdgeDeleted,
6800                SubstrateKind::Entity,
6801                "",
6802            )
6803            .with_target(edge_id)
6804            .with_payload(serde_json::json!({"id": edge_id, "namespace": record_ns, "hard": hard}));
6805            event_store.append_event(event).await.map_err(|e| {
6806                RuntimeError::Internal(format!("delete_edge: event store write failed: {e}"))
6807            })?;
6808        }
6809        Ok(deleted)
6810    }
6811
6812    /// Count edges matching `filter` across the caller's visible namespaces.
6813    pub async fn count_edges(
6814        &self,
6815        token: &NamespaceToken,
6816        filter: crate::curation::EdgeListFilter,
6817    ) -> RuntimeResult<u64> {
6818        let namespaces: Vec<String> = token
6819            .visible_namespaces()
6820            .iter()
6821            .map(|namespace| namespace.as_str().to_owned())
6822            .collect();
6823        let graph = self.graph(token)?;
6824        match graph
6825            .count_edges_in_namespaces(&namespaces, filter.clone().into())
6826            .await
6827        {
6828            Ok(count) => Ok(count),
6829            Err(khive_storage::StorageError::Unsupported { operation, .. })
6830                if operation == "count_edges_in_namespaces" =>
6831            {
6832                let mut total = 0;
6833                for namespace in token.visible_namespaces() {
6834                    let scoped = NamespaceToken::for_namespace(namespace.clone());
6835                    total += self
6836                        .graph(&scoped)?
6837                        .count_edges(filter.clone().into())
6838                        .await?;
6839                }
6840                Ok(total)
6841            }
6842            Err(error) => Err(error.into()),
6843        }
6844    }
6845
6846    /// Validate and construct an edge from a [`LinkSpec`] without writing to storage.
6847    ///
6848    /// Applies the full edge contract (endpoint validation, symmetric
6849    /// canonicalization, `dependency_kind` inference and metadata validation).
6850    /// Returns the constructed `Edge` on success; the caller is responsible for
6851    /// persisting it (e.g. via `upsert_edge` or `link_many`).
6852    ///
6853    /// The `token` must be a pre-authorized namespace token from the dispatch
6854    /// layer. If `spec.namespace` is set it must match `token.namespace()`;
6855    /// a mismatch returns `RuntimeError::InvalidInput`.
6856    pub async fn build_edge(&self, token: &NamespaceToken, spec: &LinkSpec) -> RuntimeResult<Edge> {
6857        let ns_str = match &spec.namespace {
6858            Some(s) => {
6859                let spec_ns = crate::Namespace::parse(s)
6860                    .map_err(|e| RuntimeError::InvalidInput(format!("invalid namespace: {e}")))?;
6861                if &spec_ns != token.namespace() {
6862                    return Err(RuntimeError::InvalidInput(
6863                        "LinkSpec namespace does not match token namespace".into(),
6864                    ));
6865                }
6866                s.as_str()
6867            }
6868            None => token.namespace().as_str(),
6869        };
6870        self.validate_edge_relation_endpoints(token, spec.source_id, spec.target_id, spec.relation)
6871            .await?;
6872        let (source_id, target_id) =
6873            canonical_edge_endpoints(spec.relation, spec.source_id, spec.target_id);
6874        let metadata = if spec.relation == EdgeRelation::DependsOn {
6875            // By-ID, unfiltered — matches the namespace-agnostic endpoint validation
6876            // above. The visible-set-scoped `resolve` would silently drop the
6877            // dependency_kind inference for endpoints validation now allows outside
6878            // the caller's visible set.
6879            match (
6880                self.resolve_edge_endpoint(token, source_id).await?,
6881                self.resolve_edge_endpoint(token, target_id).await?,
6882            ) {
6883                (Some(Resolved::Entity(src_e)), Some(Resolved::Entity(tgt_e))) => {
6884                    merge_dependency_kind(&src_e.kind, &tgt_e.kind, spec.metadata.clone())
6885                }
6886                _ => spec.metadata.clone(),
6887            }
6888        } else {
6889            spec.metadata.clone()
6890        };
6891        validate_edge_metadata(spec.relation, metadata.as_ref())?;
6892        let now = chrono::Utc::now();
6893        Ok(Edge {
6894            id: LinkId::from(Uuid::new_v4()),
6895            namespace: ns_str.to_string(),
6896            source_id,
6897            target_id,
6898            relation: spec.relation,
6899            weight: spec.weight,
6900            created_at: now,
6901            updated_at: now,
6902            deleted_at: None,
6903            metadata,
6904            target_backend: None,
6905        })
6906    }
6907
6908    /// Validate and atomically upsert a batch of edges.
6909    ///
6910    /// All edges are validated and constructed with `build_edge` before any
6911    /// write. If validation fails for any entry the entire batch is rejected
6912    /// (no writes occur). On success, all edges are persisted in a single
6913    /// atomic transaction via `upsert_edges`.
6914    ///
6915    /// After the bulk upsert, each edge is read back by its natural key
6916    /// (namespace, source_id, target_id, relation) so that the returned IDs
6917    /// are always the persisted row IDs, not the locally-generated UUIDs that
6918    /// may have been displaced by an ON CONFLICT DO UPDATE. This mirrors the
6919    /// same read-back applied to singleton `link()` and prevents phantom-ID
6920    /// exposure when callers upsert overlapping triples with `verbose=true`.
6921    ///
6922    /// All specs must share the same namespace; the namespace is taken from
6923    /// `token` (or validated against it if `spec.namespace` is set).
6924    pub async fn link_many(
6925        &self,
6926        token: &NamespaceToken,
6927        specs: Vec<LinkSpec>,
6928    ) -> RuntimeResult<Vec<Edge>> {
6929        self.link_many_observed(token, specs)
6930            .await
6931            .map(|rows| rows.into_iter().map(|row| row.edge).collect())
6932    }
6933
6934    /// Observed all-or-nothing bulk link upsert. Every row carries its own
6935    /// create/update/resurrection disposition, and every tombstone policy is
6936    /// preflighted inside the same writer transaction before any mutation.
6937    pub async fn link_many_observed(
6938        &self,
6939        token: &NamespaceToken,
6940        specs: Vec<LinkSpec>,
6941    ) -> RuntimeResult<Vec<EdgeUpsertResult>> {
6942        if specs.is_empty() {
6943            return Ok(vec![]);
6944        }
6945        let mut edges = Vec::with_capacity(specs.len());
6946        for spec in &specs {
6947            edges.push(self.build_edge(token, spec).await?);
6948        }
6949        // `upsert_edges_guarded` re-checks every edge's endpoints as part of the
6950        // same write, not the separate per-spec `build_edge` validation reads
6951        // above. A concurrent hard-delete of any endpoint landing between those
6952        // reads and this write aborts the whole batch (all-or-nothing, no
6953        // partial write) instead of persisting a dangling edge. The failing
6954        // entry's index and its missing endpoint(s) come from the guard's own
6955        // in-transaction pre-check (`GuardedBatchOutcome::refused`), not a
6956        // post-hoc re-read of the batch after the write already failed.
6957        let requests = edges
6958            .into_iter()
6959            .zip(specs.iter())
6960            .map(|(edge, spec)| EdgeUpsertRequest {
6961                edge,
6962                resurrect: spec.resurrect,
6963            })
6964            .collect();
6965        let outcome = self
6966            .graph(token)?
6967            .upsert_edges_guarded_observed(requests)
6968            .await?;
6969        if let Some(refusal) = outcome.refusal {
6970            return match refusal.reason {
6971                EdgeUpsertRefusal::MissingEndpoints(missing) => {
6972                    Err(RuntimeError::GuardedWriteFailed(guarded_link_batch_failure(
6973                        &specs[refusal.entry_index],
6974                        refusal.entry_index,
6975                        missing,
6976                    )))
6977                }
6978                EdgeUpsertRefusal::ResurrectionRequired { edge } => {
6979                    Err(RuntimeError::InvalidInput(format!(
6980                        "batch entry {} targets soft-deleted edge {}; pass resurrect=true for that link",
6981                        refusal.entry_index, edge.id
6982                    )))
6983                }
6984            };
6985        }
6986        for row in &outcome.rows {
6987            self.append_link_mutation_event(token, row).await?;
6988        }
6989        Ok(outcome.rows)
6990    }
6991
6992    /// Create a batch of entities atomically.
6993    ///
6994    /// All specs are validated before any write. If ANY spec fails validation
6995    /// (unknown kind, empty name, secret-gate violation), the method returns
6996    /// that error and no entities are written.
6997    ///
6998    /// Entity rows and their FTS documents are written in one SQLite transaction.
6999    /// Any statement failure rolls back the entire batch across both surfaces.
7000    /// Embedding is intentionally skipped: bulk structural ingest is the expected
7001    /// use-case, and dense vectors are backfilled later via a `reindex` call.
7002    pub async fn create_many(
7003        &self,
7004        token: &NamespaceToken,
7005        specs: Vec<EntityCreateSpec>,
7006    ) -> RuntimeResult<Vec<Entity>> {
7007        if specs.is_empty() {
7008            return Ok(vec![]);
7009        }
7010        let ns = token.namespace().as_str();
7011
7012        // Phase 1: validate ALL specs before any write.
7013        // Includes entity-type validation via the pack-installed validator when available.
7014        // Any validation failure here guarantees zero rows are written.
7015        let mut entities = Vec::with_capacity(specs.len());
7016        for (index, spec) in specs.iter().enumerate() {
7017            entities.push(self.validate_bulk_entity(ns, spec, &format!("entity[{index}]"))?);
7018        }
7019
7020        #[cfg(any(test, feature = "fault-injection"))]
7021        let fts_many_inject = consume_fault(&FTS_FAIL_MANY_NS, ns);
7022        #[cfg(not(any(test, feature = "fault-injection")))]
7023        let fts_many_inject = false;
7024
7025        #[cfg(any(test, feature = "fault-injection"))]
7026        let fts_many_inject_partial = consume_fault(&FTS_FAIL_MANY_PARTIAL_NS, ns);
7027        #[cfg(not(any(test, feature = "fault-injection")))]
7028        let fts_many_inject_partial = false;
7029
7030        let injected_failure_index = if fts_many_inject {
7031            Some(0)
7032        } else if fts_many_inject_partial {
7033            Some(usize::from(entities.len() > 1))
7034        } else {
7035            None
7036        };
7037
7038        let _ = self.entities(token)?;
7039        let _ = self.text(token)?;
7040
7041        let plans = entities
7042            .iter()
7043            .enumerate()
7044            .map(|(index, entity)| {
7045                let mut plan = bulk_entity_plan(entity);
7046                if injected_failure_index == Some(index) {
7047                    // Keep the guarded row insert; replace its FTS pair with the fault.
7048                    plan.statements.truncate(1);
7049                    plan.statements.push(PlanStatement {
7050                        statement: SqlStatement {
7051                            sql:
7052                                "INSERT INTO __khive_create_many_injected_failure__ DEFAULT VALUES"
7053                                    .to_string(),
7054                            params: vec![],
7055                            label: Some("fts-insert-injected-failure".to_string()),
7056                        },
7057                        guard: None,
7058                    });
7059                }
7060                AtomicOpPlan::AddEntity(plan)
7061            })
7062            .collect();
7063
7064        match run_atomic_unit(self.sql().as_ref(), plans).await {
7065            Ok(AtomicRunOutcome::Committed { .. }) => Ok(entities),
7066            Ok(AtomicRunOutcome::RolledBack {
7067                failed_op_index,
7068                failure,
7069            }) => Err(RuntimeError::Internal(format!(
7070                "create_many: atomic batch rolled back at entity index {failed_op_index}: \
7071                 {failure:?}"
7072            ))),
7073            Err(e) => Err(RuntimeError::Internal(format!(
7074                "create_many: atomic batch failed: {}",
7075                e.0
7076            ))),
7077        }
7078    }
7079
7080    /// One bulk entity spec's pre-write checks and its row, shared by
7081    /// `create_many` and [`Self::prepare_bulk_entity_plan`] so the two bulk
7082    /// entity paths cannot drift apart: kind, entity_type, a nonempty name,
7083    /// the reserved secret-gate property and the secret gate itself.
7084    fn validate_bulk_entity(
7085        &self,
7086        ns: &str,
7087        spec: &EntityCreateSpec,
7088        record: &str,
7089    ) -> RuntimeResult<Entity> {
7090        self.validate_entity_kind(&spec.kind)?;
7091        // Validate entity_type at the runtime layer via pack-installed callback.
7092        // When no validator is installed (bare runtime, unit tests without packs),
7093        // the type passes through unchanged, the same skip-when-absent pattern as
7094        // validate_entity_kind. The handler layer remains the primary enforcement point.
7095        let validated_type =
7096            self.validate_entity_type_for_kind(&spec.kind, spec.entity_type.as_deref())?;
7097        if spec.name.trim().is_empty() {
7098            return Err(RuntimeError::InvalidInput("name must not be empty".into()));
7099        }
7100        crate::secret_gate::reject_reserved_secret_gate_property(spec.properties.as_ref())?;
7101        crate::secret_gate::check_at(&spec.name, record, "name")?;
7102        if let Some(d) = &spec.description {
7103            crate::secret_gate::check_at(d, record, "description")?;
7104        }
7105        if let Some(ref p) = spec.properties {
7106            crate::secret_gate::check_json_at(p, record, "properties")?;
7107        }
7108        crate::secret_gate::check_tags_at(&spec.tags, record, "tags")?;
7109
7110        let mut entity =
7111            Entity::new(ns, &spec.kind, &spec.name).with_entity_type(validated_type.as_deref());
7112        if let Some(d) = &spec.description {
7113            entity = entity.with_description(d);
7114        }
7115        if let Some(p) = spec.properties.clone() {
7116            entity = entity.with_properties(p);
7117        }
7118        if !spec.tags.is_empty() {
7119            entity = entity.with_tags(spec.tags.clone());
7120        }
7121        Ok(entity)
7122    }
7123
7124    /// Validate and prepare one entity item for a bulk `create(items=[...])`
7125    /// write: the same admission and row/FTS plan as [`Self::create_many`],
7126    /// with no scheduled reindex, so the vector is deferred to a later
7127    /// `reindex` exactly as for `create_many`. The bulk create handler uses
7128    /// this for every entity item so entity and note plans can join one
7129    /// `run_atomic_unit` call.
7130    pub async fn prepare_bulk_entity_plan(
7131        &self,
7132        token: &NamespaceToken,
7133        spec: EntityCreateSpec,
7134    ) -> RuntimeResult<(Entity, AtomicOpPlan)> {
7135        let entity = self.validate_bulk_entity(token.namespace().as_str(), &spec, "entity")?;
7136        let _ = self.entities(token)?;
7137        let _ = self.text(token)?;
7138
7139        let plan = AtomicOpPlan::AddEntity(bulk_entity_plan(&entity));
7140        Ok((entity, plan))
7141    }
7142
7143    /// Validate and prepare one note item for a bulk `create(items=[...])`
7144    /// write. The note goes through the preparation a singleton note create
7145    /// uses (`validate_head`, then `prepare_atomic_notes`: kind validation,
7146    /// owned-identity derivation, secret gate, salience range, row and FTS
7147    /// statements) with embedding switched off, so the plan writes the row
7148    /// and its FTS document and no vector. A later `reindex` backfills the
7149    /// vector, as it does for bulk entities. The caller commits the plan,
7150    /// alone or joined with its siblings in one `run_atomic_unit` call.
7151    pub async fn prepare_bulk_note_plan(
7152        &self,
7153        token: &NamespaceToken,
7154        spec: NoteCreateSpec,
7155    ) -> RuntimeResult<(Note, AtomicOpPlan)> {
7156        let mut candidate = Note::new(token.namespace().as_str(), &spec.kind, &spec.content);
7157        candidate.name = spec.name.clone();
7158        candidate.properties = spec.properties.clone();
7159        crate::note_write::validate_head(&candidate)?;
7160        let mut prepared = crate::atomic_message::prepare_atomic_notes(
7161            self,
7162            vec![crate::atomic_message::AtomicNoteSpec {
7163                token,
7164                id: None,
7165                kind: &spec.kind,
7166                name: spec.name.as_deref(),
7167                content: &spec.content,
7168                properties: spec.properties,
7169            }],
7170            crate::atomic_message::AtomicNoteOptions {
7171                salience: spec.salience,
7172                embed: Some(false),
7173                ..Default::default()
7174            },
7175        )
7176        .await?;
7177        match (prepared.notes.pop(), prepared.plans.pop()) {
7178            (Some(note), Some(plan)) if prepared.notes.is_empty() && prepared.plans.is_empty() => {
7179                Ok((note, plan))
7180            }
7181            _ => Err(RuntimeError::Internal(
7182                "bulk note preparation must yield exactly one note and one plan".into(),
7183            )),
7184        }
7185    }
7186}
7187
7188/// One note item for [`KhiveRuntime::prepare_bulk_note_plan`], the note
7189/// analogue of [`EntityCreateSpec`]. The caller has already run the kind's
7190/// own preparation hook, so owner-kind policy such as the memory pack's
7191/// creation refusal happens before this point.
7192#[derive(Clone, Debug)]
7193pub struct NoteCreateSpec {
7194    pub kind: String,
7195    pub name: Option<String>,
7196    pub content: String,
7197    pub salience: Option<f64>,
7198    pub properties: Option<serde_json::Value>,
7199}
7200
7201fn bulk_entity_plan(entity: &Entity) -> AddEntityPlan {
7202    let mut statements = vec![PlanStatement {
7203        statement: entity_upsert_statement(entity),
7204        guard: Some(AffectedRowGuard::exactly(1)),
7205    }];
7206    // The FTS insert and rowid-map insert must remain adjacent on one connection.
7207    statements.extend(
7208        insert_document_statements("fts_entities", &entity_fts_document(entity))
7209            .into_iter()
7210            .map(|statement| PlanStatement {
7211                statement,
7212                guard: None,
7213            }),
7214    );
7215    AddEntityPlan {
7216        entity_id: entity.id,
7217        statements,
7218        post_commit: PostCommitEffect::None,
7219    }
7220}
7221
7222fn guarded_link_batch_failure(
7223    spec: &LinkSpec,
7224    entry_index: usize,
7225    missing: khive_storage::MissingEndpoints,
7226) -> GuardedWriteFailure {
7227    // Storage flags describe the canonical edge built from this spec, not the
7228    // caller's potentially reversed spelling of a symmetric relation.
7229    let (source_id, target_id) =
7230        canonical_edge_endpoints(spec.relation, spec.source_id, spec.target_id);
7231    GuardedWriteFailure {
7232        entry_index: Some(entry_index),
7233        missing_source: missing.source.then_some(source_id),
7234        missing_target: missing.target.then_some(target_id),
7235    }
7236}
7237
7238/// Fully specified edge creation request — input to [`KhiveRuntime::build_edge`]
7239/// and [`KhiveRuntime::link_many`].
7240#[derive(Clone, Debug)]
7241pub struct LinkSpec {
7242    pub namespace: Option<String>,
7243    pub source_id: Uuid,
7244    pub target_id: Uuid,
7245    pub relation: EdgeRelation,
7246    pub weight: f64,
7247    pub metadata: Option<serde_json::Value>,
7248    pub resurrect: bool,
7249}
7250
7251/// Fully specified entity creation request — input to [`KhiveRuntime::create_many`].
7252///
7253/// `entity_type` is validated at the runtime layer by the pack-installed
7254/// entity-type validator. When a validator
7255/// is installed (e.g. by `KgPack`), unknown types are rejected with the valid
7256/// set listed. When no validator is installed (bare runtime without packs),
7257/// the value passes through — the handler layer is the primary enforcement point.
7258#[derive(Clone, Debug)]
7259pub struct EntityCreateSpec {
7260    pub kind: String,
7261    pub entity_type: Option<String>,
7262    pub name: String,
7263    pub description: Option<String>,
7264    pub properties: Option<serde_json::Value>,
7265    pub tags: Vec<String>,
7266}
7267
7268// INLINE TEST JUSTIFICATION: tests here exercise private helpers (canonical_edge_endpoints,
7269// validate_edge_metadata, merge_dependency_kind, link-fail injection) and runtime methods
7270// that require pub(crate) KhiveRuntime construction. Moving them to tests/ would require
7271// pub-exporting those private helpers, which would widen the crate's public API surface
7272// undesirably. Broad behavioral tests live in tests/integration.rs.
7273#[cfg(test)]
7274mod tests {
7275    use super::*;
7276    use crate::curation::EdgeListFilter;
7277    use crate::embedder_registry::{BlockingEmbeddingService, EmbedderProvider};
7278    use crate::error::RuntimeError;
7279    use crate::runtime::{KhiveRuntime, NamespaceToken};
7280    use crate::{ActorRef, Namespace};
7281    use async_trait::async_trait;
7282    use khive_storage::types::{PathNode, SqlValue};
7283    use lattice_embed::{EmbedError, EmbeddingModel, EmbeddingService, MAX_TEXT_BYTES};
7284    use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
7285    use std::sync::Arc;
7286
7287    fn rt() -> KhiveRuntime {
7288        KhiveRuntime::memory().unwrap()
7289    }
7290
7291    #[test]
7292    fn dependency_kind_inference_preserves_unmatched_metadata() {
7293        let metadata = serde_json::json!({"note": "caller supplied"});
7294        assert_eq!(
7295            merge_dependency_kind("concept", "concept", Some(metadata.clone())),
7296            Some(metadata)
7297        );
7298        assert_eq!(merge_dependency_kind("concept", "concept", None), None);
7299    }
7300
7301    #[test]
7302    fn salience_rank_uses_fixed_point_at_q32_boundaries() {
7303        for (input, expected) in [(6, 4), (-6, -4), (1, 0), (-1, 0)] {
7304            for salience in [Some(0.5), None] {
7305                assert_eq!(
7306                    salience_weighted_rank(DeterministicScore::from_raw(input), salience).to_raw(),
7307                    expected
7308                );
7309            }
7310        }
7311        let one = DeterministicScore::from_raw(1_i64 << 32);
7312        for (salience, expected) in [
7313            (1.0 / 4_294_967_296.0, 2_147_483_648),
7314            (3.0 / 4_294_967_296.0, 2_147_483_649),
7315        ] {
7316            assert_eq!(
7317                salience_weighted_rank(one, Some(salience)).to_raw(),
7318                expected
7319            );
7320        }
7321    }
7322
7323    #[test]
7324    fn salience_rank_preserves_identity_and_saturates() {
7325        let exact = DeterministicScore::from_raw((1_i64 << 53) + 1);
7326        assert_eq!(salience_weighted_rank(exact, Some(1.0)), exact);
7327        assert_eq!(
7328            salience_weighted_rank(DeterministicScore::from_raw(6), Some(0.0)).to_raw(),
7329            3
7330        );
7331        assert_eq!(
7332            salience_weighted_rank(DeterministicScore::MAX, Some(3.0)),
7333            DeterministicScore::MAX
7334        );
7335        assert_eq!(
7336            salience_weighted_rank(DeterministicScore::NEG_INF, Some(3.0)),
7337            DeterministicScore::NEG_INF
7338        );
7339        assert_eq!(
7340            salience_weighted_rank(DeterministicScore::ZERO, None),
7341            DeterministicScore::ZERO
7342        );
7343    }
7344
7345    #[tokio::test]
7346    async fn list_composed_type_filters_apply_alias_and_disjoint_sets_before_pagination() {
7347        let runtime = rt();
7348        let token = runtime.authorize(Namespace::local()).unwrap();
7349        let store = runtime.entities(&token).unwrap();
7350        let mut expected = Vec::new();
7351        for (kind, column, property, matches) in [
7352            ("document", Some("paper"), "ignored", true),
7353            ("document", None, "preprint", true),
7354            ("document", Some("preprint"), "ignored", true),
7355            ("document", Some("report"), "preprint", false),
7356            ("concept", None, "preprint", false),
7357        ] {
7358            let row = Entity::new("local", kind, "type predicate")
7359                .with_entity_type(column)
7360                .with_properties(serde_json::json!({"type": property}));
7361            if matches {
7362                expected.push(row.id);
7363            }
7364            store.upsert_entity(row).await.unwrap();
7365        }
7366        store
7367            .upsert_entity(
7368                Entity::new("foreign", "document", "foreign alias")
7369                    .with_properties(serde_json::json!({"type":"preprint"})),
7370            )
7371            .await
7372            .unwrap();
7373        for (values, should_match) in [
7374            (vec!["paper".to_string(), "preprint".to_string()], true),
7375            (vec!["absent".to_string()], false),
7376            (Vec::new(), false),
7377        ] {
7378            let filter = EntityFilter {
7379                entity_types_by_kind: [("document".to_string(), values)].into_iter().collect(),
7380                legacy_entity_type_fallback: true,
7381                namespaces: vec!["foreign".to_string()], // Runtime supplies token visibility.
7382                ..Default::default()
7383            };
7384            let mut offset_ids = Vec::new();
7385            for offset in 0..=expected.len() {
7386                let page = runtime
7387                    .list_entities_filtered(&token, filter.clone(), 1, offset as u32)
7388                    .await
7389                    .unwrap();
7390                offset_ids.extend(page.into_iter().map(|row| row.id));
7391            }
7392            let mut cursor_ids = Vec::new();
7393            let mut after = None;
7394            for _ in 0..=expected.len() {
7395                let (page, next) = runtime
7396                    .list_entities_after_filtered(&token, filter.clone(), after, 1)
7397                    .await
7398                    .unwrap();
7399                cursor_ids.extend(page.into_iter().map(|row| row.id));
7400                after = next;
7401                if after.is_none() {
7402                    break;
7403                }
7404            }
7405            let mut wanted = if should_match {
7406                expected.clone()
7407            } else {
7408                Vec::new()
7409            };
7410            wanted.sort_unstable();
7411            offset_ids.sort_unstable();
7412            cursor_ids.sort_unstable();
7413            assert_eq!(offset_ids, wanted);
7414            assert_eq!(cursor_ids, wanted);
7415        }
7416        // Existing scalar callers retain literal matching, including legacy fallback.
7417        assert_eq!(
7418            runtime
7419                .list_entities(&token, None, Some("paper"), 20, 0)
7420                .await
7421                .unwrap()
7422                .len(),
7423            1
7424        );
7425        assert_eq!(
7426            runtime
7427                .list_entities_after(&token, None, Some("paper"), &[], None, 20)
7428                .await
7429                .unwrap()
7430                .0
7431                .len(),
7432            1
7433        );
7434    }
7435
7436    #[tokio::test]
7437    async fn resolve_prefix_query_plan_uses_primary_key_range_seeks() {
7438        let rt = rt();
7439        let mut reader = rt.sql().reader().await.expect("prefix plan reader");
7440        let mut plans = Vec::new();
7441        let (lower, upper) =
7442            super::uuid_prefix_bounds("0b6cf134").expect("valid compact UUID prefix");
7443
7444        for (table, has_deleted_at) in [
7445            ("entities", true),
7446            ("notes", true),
7447            ("events", false),
7448            ("graph_edges", false),
7449        ] {
7450            let rows = reader
7451                .explain(super::resolve_prefix_statement(
7452                    table,
7453                    has_deleted_at,
7454                    false,
7455                    None,
7456                    &lower,
7457                    &upper,
7458                ))
7459                .await
7460                .expect("explain prefix query");
7461            let details: Vec<String> = rows
7462                .iter()
7463                .filter_map(|row| match row.get("detail") {
7464                    Some(SqlValue::Text(detail)) => Some(detail.clone()),
7465                    _ => None,
7466                })
7467                .collect();
7468            plans.push((table, details));
7469        }
7470
7471        assert!(
7472            plans.iter().all(|(table, details)| {
7473                details.iter().any(|detail| {
7474                    detail.contains(&format!("SEARCH {table}"))
7475                        && detail.contains("id>?")
7476                        && detail.contains("id<?")
7477                }) && !details
7478                    .iter()
7479                    .any(|detail| detail.contains(&format!("SCAN {table}")))
7480            }),
7481            "every short-id table must use a primary-key range seek: {plans:?}"
7482        );
7483    }
7484
7485    #[test]
7486    fn fts_fault_arm_disarms_namespace_when_scope_panics() {
7487        let ns = format!("fault-arm-drop-{}", uuid::Uuid::new_v4().as_simple());
7488
7489        let panic_result = std::panic::catch_unwind(|| {
7490            let _arm = arm_fts_fail_scoped(&ns);
7491            panic!("leave the armed scope before consumption");
7492        });
7493
7494        assert!(panic_result.is_err());
7495        assert!(
7496            !consume_fault(&FTS_FAIL_NS, &ns),
7497            "unwinding an armed scope must remove its namespace"
7498        );
7499    }
7500
7501    #[test]
7502    fn fault_arm_set_rejects_entries_over_capacity() {
7503        static BOUNDED_ARMS: std::sync::LazyLock<FaultArmSet> =
7504            std::sync::LazyLock::new(|| std::sync::Mutex::new(std::collections::HashMap::new()));
7505        let first_ns = format!("fault-arm-bound-a-{}", uuid::Uuid::new_v4().as_simple());
7506        let overflow_ns = format!("fault-arm-bound-b-{}", uuid::Uuid::new_v4().as_simple());
7507        let arm = arm_fault(&BOUNDED_ARMS, &first_ns, 1);
7508
7509        let overflow = std::panic::catch_unwind(|| arm_fault(&BOUNDED_ARMS, &overflow_ns, 1));
7510
7511        assert!(
7512            overflow.is_err(),
7513            "an arm set must reject entries over its bound"
7514        );
7515        drop(arm);
7516        assert!(BOUNDED_ARMS.lock().unwrap().is_empty());
7517    }
7518
7519    // ── Custom embedder fan-out regression ──────────────────────────────────
7520    // A runtime with no `config.embedding_model` but a custom registered
7521    // embedder must fan out create_note through that embedder and store a
7522    // vector so recall can find the note.
7523
7524    /// Trivial constant-vector embedding service.  The model argument is ignored;
7525    /// the service always returns a synthetic `dims × 1.0f32` vector.
7526    struct ConstVecService {
7527        dims: usize,
7528    }
7529
7530    #[async_trait]
7531    impl EmbeddingService for ConstVecService {
7532        async fn embed(
7533            &self,
7534            texts: &[String],
7535            _model: EmbeddingModel,
7536        ) -> std::result::Result<Vec<Vec<f32>>, EmbedError> {
7537            Ok(texts.iter().map(|_| vec![1.0_f32; self.dims]).collect())
7538        }
7539
7540        fn supports_model(&self, _model: EmbeddingModel) -> bool {
7541            true
7542        }
7543
7544        fn name(&self) -> &'static str {
7545            "const-vec"
7546        }
7547    }
7548
7549    struct ConstVecProvider {
7550        provider_name: String,
7551        dims: usize,
7552        pub build_count: Arc<AtomicUsize>,
7553    }
7554
7555    impl ConstVecProvider {
7556        fn new(name: &str, dims: usize) -> (Self, Arc<AtomicUsize>) {
7557            let counter = Arc::new(AtomicUsize::new(0));
7558            let provider = Self {
7559                provider_name: name.to_owned(),
7560                dims,
7561                build_count: Arc::clone(&counter),
7562            };
7563            (provider, counter)
7564        }
7565    }
7566
7567    #[async_trait]
7568    impl EmbedderProvider for ConstVecProvider {
7569        fn name(&self) -> &str {
7570            &self.provider_name
7571        }
7572
7573        fn dimensions(&self) -> usize {
7574            self.dims
7575        }
7576
7577        async fn build(&self) -> crate::error::RuntimeResult<Arc<dyn EmbeddingService>> {
7578            self.build_count.fetch_add(1, Ordering::SeqCst);
7579            Ok(Arc::new(ConstVecService { dims: self.dims }))
7580        }
7581    }
7582
7583    /// Embedding service that sleeps briefly before returning, so its spawned
7584    /// embed task is still in flight when a sibling model's task resolves
7585    /// first — used to exercise the drain-before-return invariant on the
7586    /// multi-model embed fan-out.
7587    struct SlowVecService {
7588        dims: usize,
7589    }
7590
7591    #[async_trait]
7592    impl EmbeddingService for SlowVecService {
7593        async fn embed(
7594            &self,
7595            texts: &[String],
7596            _model: EmbeddingModel,
7597        ) -> std::result::Result<Vec<Vec<f32>>, EmbedError> {
7598            tokio::time::sleep(std::time::Duration::from_millis(50)).await;
7599            Ok(texts.iter().map(|_| vec![1.0_f32; self.dims]).collect())
7600        }
7601
7602        fn supports_model(&self, _model: EmbeddingModel) -> bool {
7603            true
7604        }
7605
7606        fn name(&self) -> &'static str {
7607            "slow-vec"
7608        }
7609    }
7610
7611    struct SlowVecProvider {
7612        provider_name: String,
7613        dims: usize,
7614    }
7615
7616    impl SlowVecProvider {
7617        fn new(name: &str, dims: usize) -> Self {
7618            Self {
7619                provider_name: name.to_owned(),
7620                dims,
7621            }
7622        }
7623    }
7624
7625    #[async_trait]
7626    impl EmbedderProvider for SlowVecProvider {
7627        fn name(&self) -> &str {
7628            &self.provider_name
7629        }
7630
7631        fn dimensions(&self) -> usize {
7632            self.dims
7633        }
7634
7635        async fn build(&self) -> crate::error::RuntimeResult<Arc<dyn EmbeddingService>> {
7636            Ok(Arc::new(SlowVecService { dims: self.dims }))
7637        }
7638    }
7639
7640    /// Embedder that fails inference immediately unless configured to wait for
7641    /// a sibling's entry signal first.
7642    struct FailFastProvider {
7643        provider_name: String,
7644        wait_for: Option<Arc<AtomicBool>>,
7645    }
7646
7647    impl FailFastProvider {
7648        fn new(name: &str) -> Self {
7649            Self {
7650                provider_name: name.to_owned(),
7651                wait_for: None,
7652            }
7653        }
7654
7655        fn after_signal(name: &str, wait_for: Arc<AtomicBool>) -> Self {
7656            Self {
7657                provider_name: name.to_owned(),
7658                wait_for: Some(wait_for),
7659            }
7660        }
7661    }
7662
7663    #[async_trait]
7664    impl EmbedderProvider for FailFastProvider {
7665        fn name(&self) -> &str {
7666            &self.provider_name
7667        }
7668
7669        fn dimensions(&self) -> usize {
7670            4
7671        }
7672
7673        async fn build(&self) -> crate::error::RuntimeResult<Arc<dyn EmbeddingService>> {
7674            Ok(Arc::new(FailFastService {
7675                wait_for: self.wait_for.clone(),
7676            }))
7677        }
7678    }
7679
7680    struct FailFastService {
7681        wait_for: Option<Arc<AtomicBool>>,
7682    }
7683
7684    #[async_trait]
7685    impl EmbeddingService for FailFastService {
7686        async fn embed(
7687            &self,
7688            _texts: &[String],
7689            _model: EmbeddingModel,
7690        ) -> std::result::Result<Vec<Vec<f32>>, EmbedError> {
7691            if let Some(wait_for) = &self.wait_for {
7692                while !wait_for.load(Ordering::Acquire) {
7693                    tokio::task::yield_now().await;
7694                }
7695            }
7696            Err(EmbedError::InferenceFailed(
7697                "injected embed failure".to_string(),
7698            ))
7699        }
7700
7701        fn supports_model(&self, _model: EmbeddingModel) -> bool {
7702            true
7703        }
7704
7705        fn name(&self) -> &'static str {
7706            "fail-fast"
7707        }
7708    }
7709
7710    /// Embedder whose synchronous inference section is controlled by a
7711    /// condition variable, matching native inference that cannot observe task
7712    /// cancellation until the encode call returns.
7713    struct BlockingVecProvider {
7714        provider_name: String,
7715        dims: usize,
7716        release: Arc<(std::sync::Mutex<bool>, std::sync::Condvar)>,
7717        entered: Arc<AtomicBool>,
7718    }
7719
7720    struct BlockingVecControls {
7721        release: Arc<(std::sync::Mutex<bool>, std::sync::Condvar)>,
7722        entered: Arc<AtomicBool>,
7723    }
7724
7725    impl BlockingVecProvider {
7726        fn new(name: &str, dims: usize) -> (Self, BlockingVecControls) {
7727            let release = Arc::new((std::sync::Mutex::new(false), std::sync::Condvar::new()));
7728            let entered = Arc::new(AtomicBool::new(false));
7729            (
7730                Self {
7731                    provider_name: name.to_owned(),
7732                    dims,
7733                    release: Arc::clone(&release),
7734                    entered: Arc::clone(&entered),
7735                },
7736                BlockingVecControls { release, entered },
7737            )
7738        }
7739    }
7740
7741    #[async_trait]
7742    impl EmbedderProvider for BlockingVecProvider {
7743        fn name(&self) -> &str {
7744            &self.provider_name
7745        }
7746
7747        fn dimensions(&self) -> usize {
7748            self.dims
7749        }
7750
7751        async fn build(&self) -> crate::error::RuntimeResult<Arc<dyn EmbeddingService>> {
7752            let service = Arc::new(BlockingVecService {
7753                dims: self.dims,
7754                release: Arc::clone(&self.release),
7755                entered: Arc::clone(&self.entered),
7756            });
7757            Ok(Arc::new(BlockingEmbeddingService::new(service)))
7758        }
7759    }
7760
7761    struct BlockingVecService {
7762        dims: usize,
7763        release: Arc<(std::sync::Mutex<bool>, std::sync::Condvar)>,
7764        entered: Arc<AtomicBool>,
7765    }
7766
7767    #[async_trait]
7768    impl EmbeddingService for BlockingVecService {
7769        async fn embed(
7770            &self,
7771            texts: &[String],
7772            _model: EmbeddingModel,
7773        ) -> std::result::Result<Vec<Vec<f32>>, EmbedError> {
7774            self.entered.store(true, Ordering::Release);
7775            let (released, wake) = &*self.release;
7776            let guard = released.lock().expect("release lock must not be poisoned");
7777            let _guard = wake
7778                .wait_while(guard, |released| !*released)
7779                .expect("release lock must not be poisoned");
7780            Ok(texts.iter().map(|_| vec![1.0_f32; self.dims]).collect())
7781        }
7782
7783        fn supports_model(&self, _model: EmbeddingModel) -> bool {
7784            true
7785        }
7786
7787        fn name(&self) -> &'static str {
7788            "blocking-vec"
7789        }
7790    }
7791
7792    /// Embedder whose `embed` parks until a release that never comes — models
7793    /// a hung provider. Only task abort can end its embed future. `entered`
7794    /// receives a permit when `embed` is reached, so a test can wait until the
7795    /// parked task is provably past the dispatch point before acting on it.
7796    struct ParkedVecProvider {
7797        provider_name: String,
7798        dims: usize,
7799        release: Arc<tokio::sync::Notify>,
7800        entered: Arc<tokio::sync::Notify>,
7801    }
7802
7803    impl ParkedVecProvider {
7804        fn new(
7805            name: &str,
7806            dims: usize,
7807        ) -> (Self, Arc<tokio::sync::Notify>, Arc<tokio::sync::Notify>) {
7808            let release = Arc::new(tokio::sync::Notify::new());
7809            let entered = Arc::new(tokio::sync::Notify::new());
7810            (
7811                Self {
7812                    provider_name: name.to_owned(),
7813                    dims,
7814                    release: Arc::clone(&release),
7815                    entered: Arc::clone(&entered),
7816                },
7817                release,
7818                entered,
7819            )
7820        }
7821    }
7822
7823    #[async_trait]
7824    impl EmbedderProvider for ParkedVecProvider {
7825        fn name(&self) -> &str {
7826            &self.provider_name
7827        }
7828
7829        fn dimensions(&self) -> usize {
7830            self.dims
7831        }
7832
7833        async fn build(&self) -> crate::error::RuntimeResult<Arc<dyn EmbeddingService>> {
7834            Ok(Arc::new(ParkedVecService {
7835                dims: self.dims,
7836                release: Arc::clone(&self.release),
7837                entered: Arc::clone(&self.entered),
7838            }))
7839        }
7840    }
7841
7842    struct ParkedVecService {
7843        dims: usize,
7844        release: Arc<tokio::sync::Notify>,
7845        entered: Arc<tokio::sync::Notify>,
7846    }
7847
7848    #[async_trait]
7849    impl EmbeddingService for ParkedVecService {
7850        async fn embed(
7851            &self,
7852            texts: &[String],
7853            _model: EmbeddingModel,
7854        ) -> std::result::Result<Vec<Vec<f32>>, EmbedError> {
7855            // notify_one stores a permit when no waiter is registered yet, so
7856            // the entered signal cannot be lost to a start-order race.
7857            self.entered.notify_one();
7858            self.release.notified().await;
7859            Ok(texts.iter().map(|_| vec![1.0_f32; self.dims]).collect())
7860        }
7861
7862        fn supports_model(&self, _model: EmbeddingModel) -> bool {
7863            true
7864        }
7865
7866        fn name(&self) -> &'static str {
7867            "parked-vec"
7868        }
7869    }
7870
7871    /// Custom embedder with no lattice model in config must participate in
7872    /// fan-out: the gate must check `registered_embedding_model_names()`, not
7873    /// `config().embedding_model.is_some()`: the latter falls through to
7874    /// `vec![]` when only a custom provider is registered.
7875    #[tokio::test]
7876    async fn custom_embedder_only_runtime_fanout_stores_vector() {
7877        const MODEL_NAME: &str = "test-custom-encoder";
7878        const DIMS: usize = 8;
7879
7880        // Build a runtime with no lattice embedding_model.
7881        let rt = KhiveRuntime::memory().unwrap();
7882
7883        // Register the custom provider — this is the only embedder configured.
7884        let (provider, _counter) = ConstVecProvider::new(MODEL_NAME, DIMS);
7885        rt.register_embedder(provider);
7886
7887        // Sanity: config.embedding_model is None, but the registry has one entry.
7888        assert!(rt.config().embedding_model.is_none());
7889        assert_eq!(rt.registered_embedding_model_names(), vec![MODEL_NAME]);
7890
7891        let tok = NamespaceToken::local();
7892
7893        // create_note should fan out to the custom embedder and store a vector.
7894        let note = rt
7895            .create_note(
7896                &tok,
7897                "memory",
7898                None,
7899                "custom embedder integration test content",
7900                Some(0.7),
7901                None,
7902                vec![],
7903            )
7904            .await
7905            .expect("create_note with custom-only embedder must succeed");
7906
7907        // Verify: a vector was written in the custom model's store.
7908        use khive_storage::types::VectorSearchRequest;
7909        let query_vec = vec![1.0_f32; DIMS];
7910        let hits = rt
7911            .vectors_for_model(&tok, MODEL_NAME)
7912            .expect("vector store for custom model must be accessible")
7913            .search(VectorSearchRequest {
7914                query_vectors: vec![query_vec],
7915                top_k: 5,
7916                namespace: Some(tok.namespace().as_str().to_string()),
7917                kind: Some(khive_types::SubstrateKind::Note),
7918                embedding_model: Some(MODEL_NAME.to_string()),
7919                filter: None,
7920                backend_hints: None,
7921            })
7922            .await
7923            .expect("vector search succeeds");
7924
7925        assert!(
7926            hits.iter().any(|h| h.subject_id == note.id),
7927            "custom embedder must have written a vector for note {}: hits={hits:?}",
7928            note.id
7929        );
7930    }
7931
7932    /// Custom-only embedder participates in `embed_with_model` so recall
7933    /// fan-out also works: the lattice alias parse must be optional, with
7934    /// the embedder registry consulted directly, since requiring a lattice
7935    /// alias would reject valid custom provider names with `UnknownModel`.
7936    #[tokio::test]
7937    async fn embed_with_model_accepts_custom_provider_name() {
7938        const MODEL_NAME: &str = "my-custom-enc";
7939        const DIMS: usize = 4;
7940
7941        let rt = KhiveRuntime::memory().unwrap();
7942        let (provider, _counter) = ConstVecProvider::new(MODEL_NAME, DIMS);
7943        rt.register_embedder(provider);
7944
7945        let result = rt
7946            .embed_with_model(MODEL_NAME, "hello world")
7947            .await
7948            .expect("embed_with_model must accept custom provider names");
7949
7950        assert_eq!(
7951            result.len(),
7952            DIMS,
7953            "embedding dimension must match provider"
7954        );
7955        assert!(
7956            result.iter().all(|&v| (v - 1.0_f32).abs() < 1e-6),
7957            "ConstVecService must produce all-ones vector; got: {result:?}"
7958        );
7959    }
7960
7961    /// `embed_with_model` must still reject names that are not in the
7962    /// registry (neither lattice aliases nor custom providers).
7963    #[tokio::test]
7964    async fn embed_with_model_rejects_unregistered_name() {
7965        let rt = KhiveRuntime::memory().unwrap();
7966        let result = rt.embed_with_model("nonexistent-model", "hello").await;
7967        assert!(
7968            matches!(result.unwrap_err(), RuntimeError::UnknownModel(ref n) if n == "nonexistent-model"),
7969            "unregistered model name must return UnknownModel"
7970        );
7971    }
7972
7973    // ── No-embeddings config regression ─────────────────────────────────────
7974    // `RuntimeConfig::no_embeddings()` must register zero embedders, so
7975    // `create_note` never attempts to lazily build a lattice embedding model —
7976    // this is what lets `memory.remember` succeed on a machine with no local
7977    // model files present.
7978
7979    #[tokio::test]
7980    async fn no_embeddings_config_registers_zero_embedders() {
7981        let config = crate::config::RuntimeConfig {
7982            db_path: None,
7983            packs: vec!["kg".to_string()],
7984            ..crate::config::RuntimeConfig::no_embeddings()
7985        };
7986        let rt = KhiveRuntime::new(config).expect("runtime construction must succeed");
7987
7988        assert!(rt.config().embedding_model.is_none());
7989        assert!(
7990            rt.registered_embedding_model_names().is_empty(),
7991            "no_embeddings() runtime must register zero embedders"
7992        );
7993    }
7994
7995    #[tokio::test]
7996    async fn no_embeddings_runtime_create_note_succeeds_without_model_fanout() {
7997        let config = crate::config::RuntimeConfig {
7998            db_path: None,
7999            packs: vec!["kg".to_string()],
8000            ..crate::config::RuntimeConfig::no_embeddings()
8001        };
8002        let rt = KhiveRuntime::new(config).expect("runtime construction must succeed");
8003        let tok = NamespaceToken::local();
8004
8005        // With zero registered embedders, create_note's embed fan-out list is
8006        // empty and no lattice model build is ever attempted -- the write must
8007        // succeed, degrading to FTS-only.
8008        let note = rt
8009            .create_note(
8010                &tok,
8011                "memory",
8012                None,
8013                "issue-396 regression: model-less remember must succeed",
8014                Some(0.7),
8015                None,
8016                vec![],
8017            )
8018            .await
8019            .expect("create_note must succeed with zero registered embedders");
8020
8021        assert_eq!(
8022            note.content,
8023            "issue-396 regression: model-less remember must succeed"
8024        );
8025    }
8026
8027    #[tokio::test]
8028    async fn update_edge_changes_weight() {
8029        let rt = rt();
8030        let tok = NamespaceToken::local();
8031        let a = rt
8032            .create_entity(&tok, "concept", None, "A", None, None, vec![])
8033            .await
8034            .unwrap();
8035        let b = rt
8036            .create_entity(&tok, "concept", None, "B", None, None, vec![])
8037            .await
8038            .unwrap();
8039        let edge = rt
8040            .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
8041            .await
8042            .unwrap();
8043        let edge_id: Uuid = edge.id.into();
8044
8045        let updated = rt
8046            .update_edge(
8047                &tok,
8048                edge_id,
8049                crate::curation::EdgePatch {
8050                    weight: Some(0.5),
8051                    ..Default::default()
8052                },
8053            )
8054            .await
8055            .unwrap();
8056        assert!((updated.weight - 0.5).abs() < 0.001);
8057    }
8058
8059    /// Regression for the non-symmetric edge lost-update race (khive #1753).
8060    /// Two readers fetch the SAME edge revision (nothing else writes between
8061    /// these two `get_edge` calls, so this is a deterministic "two reads from
8062    /// one revision", not a timing assumption), each derives an independent
8063    /// patch (weight vs. metadata), and the two writes commit in a fixed
8064    /// order. Before the guarded `replace_edge_if_unchanged` primitive,
8065    /// `update_edge`'s non-symmetric branch called the unconditional
8066    /// `graph.upsert_edge`, so both commits "succeeded" and B's write
8067    /// silently discarded A's weight change. This reddens if the
8068    /// `updated_at = ?11 AND deleted_at IS ?12 AND ?6 > updated_at` predicate
8069    /// is dropped from `edge_replace_if_unchanged_statement`: B's write would
8070    /// then also return `true` and A's weight update would be lost.
8071    ///
8072    /// SCOPE: this exercises the STORE PRIMITIVE directly and never invokes
8073    /// `update_edge`, so it stays green if the production caller is reverted
8074    /// to an unconditional write. The wiring is covered separately by
8075    /// `production_update_edge_non_symmetric_refuses_concurrent_stale_writer`;
8076    /// both are required, neither substitutes for the other.
8077    #[tokio::test]
8078    async fn concurrent_edge_patches_from_one_revision_only_one_survives() {
8079        let rt = rt();
8080        let tok = NamespaceToken::local();
8081        let a = rt
8082            .create_entity(&tok, "concept", None, "A", None, None, vec![])
8083            .await
8084            .unwrap();
8085        let b = rt
8086            .create_entity(&tok, "concept", None, "B", None, None, vec![])
8087            .await
8088            .unwrap();
8089        let edge = rt
8090            .link(&tok, a.id, b.id, EdgeRelation::Extends, 0.5, None)
8091            .await
8092            .unwrap();
8093        let edge_id: Uuid = edge.id.into();
8094
8095        let graph = rt.graph(&tok).expect("graph store");
8096        let snapshot_for_a = graph
8097            .get_edge(LinkId::from(edge_id))
8098            .await
8099            .unwrap()
8100            .expect("edge exists");
8101        let snapshot_for_b = graph
8102            .get_edge(LinkId::from(edge_id))
8103            .await
8104            .unwrap()
8105            .expect("edge exists");
8106        assert_eq!(
8107            snapshot_for_a.updated_at, snapshot_for_b.updated_at,
8108            "both readers must observe the same pre-write revision for this to be a real race"
8109        );
8110        let expected_updated_at = snapshot_for_a.updated_at;
8111        let expected_deleted_at = snapshot_for_a.deleted_at;
8112
8113        let mut edge_a = snapshot_for_a;
8114        edge_a.weight = 0.9;
8115        edge_a.updated_at = expected_updated_at + chrono::Duration::microseconds(1);
8116
8117        let mut edge_b = snapshot_for_b;
8118        edge_b.metadata = Some(serde_json::json!({"note": "from B"}));
8119        edge_b.updated_at = expected_updated_at + chrono::Duration::microseconds(1);
8120
8121        assert!(
8122            graph
8123                .replace_edge_if_unchanged(edge_a, expected_updated_at, expected_deleted_at)
8124                .await
8125                .expect("writer A CAS query"),
8126            "the first committer from a shared revision must win"
8127        );
8128        assert!(
8129            !graph
8130                .replace_edge_if_unchanged(edge_b, expected_updated_at, expected_deleted_at)
8131                .await
8132                .expect("writer B CAS query"),
8133            "the second committer from the SAME stale revision must be refused, not merged"
8134        );
8135
8136        let final_edge = graph
8137            .get_edge(LinkId::from(edge_id))
8138            .await
8139            .unwrap()
8140            .expect("edge still exists");
8141        assert!(
8142            (final_edge.weight - 0.9).abs() < 0.001,
8143            "writer A's weight change must survive: {final_edge:?}"
8144        );
8145        assert!(
8146            final_edge.metadata.is_none(),
8147            "writer B's metadata must not be silently merged into the persisted row: {final_edge:?}"
8148        );
8149    }
8150
8151    /// Same race as `concurrent_edge_patches_from_one_revision_only_one_survives`,
8152    /// but driven entirely through the PRODUCTION entry point (`update_edge`,
8153    /// non-symmetric branch) rather than `replace_edge_if_unchanged` directly.
8154    /// This closes a gap the primitive-level test cannot: it would still pass
8155    /// unchanged if `update_edge` were reverted to an unconditional write,
8156    /// since it never invokes `update_edge` at all. Uses
8157    /// `crate::curation::race_seam::pause_after_read` (test-only, compiled
8158    /// out of non-test builds) to force both concurrent callers to observe
8159    /// the identical pre-write revision deterministically — no sleeps, no
8160    /// reliance on scheduler ordering.
8161    #[tokio::test]
8162    async fn production_update_edge_non_symmetric_refuses_concurrent_stale_writer() {
8163        let rt = std::sync::Arc::new(rt());
8164        let tok = NamespaceToken::local();
8165        let a = rt
8166            .create_entity(&tok, "concept", None, "A", None, None, vec![])
8167            .await
8168            .unwrap();
8169        let b = rt
8170            .create_entity(&tok, "concept", None, "B", None, None, vec![])
8171            .await
8172            .unwrap();
8173        let edge = rt
8174            .link(&tok, a.id, b.id, EdgeRelation::Extends, 0.5, None)
8175            .await
8176            .unwrap();
8177        let edge_id: Uuid = edge.id.into();
8178
8179        let barrier = std::sync::Arc::new(tokio::sync::Barrier::new(2));
8180
8181        let writer_a = {
8182            let rt = std::sync::Arc::clone(&rt);
8183            let tok = tok.clone();
8184            let barrier = std::sync::Arc::clone(&barrier);
8185            tokio::spawn(crate::curation::race_seam::AFTER_READ_BARRIER.scope(
8186                barrier,
8187                async move {
8188                    rt.update_edge(
8189                        &tok,
8190                        edge_id,
8191                        crate::curation::EdgePatch {
8192                            weight: Some(0.9),
8193                            ..Default::default()
8194                        },
8195                    )
8196                    .await
8197                },
8198            ))
8199        };
8200        let writer_b = {
8201            let rt = std::sync::Arc::clone(&rt);
8202            let tok = tok.clone();
8203            let barrier = std::sync::Arc::clone(&barrier);
8204            tokio::spawn(crate::curation::race_seam::AFTER_READ_BARRIER.scope(
8205                barrier,
8206                async move {
8207                    rt.update_edge(
8208                        &tok,
8209                        edge_id,
8210                        crate::curation::EdgePatch {
8211                            properties: Some(serde_json::json!({"note": "from B"})),
8212                            ..Default::default()
8213                        },
8214                    )
8215                    .await
8216                },
8217            ))
8218        };
8219
8220        let result_a = writer_a.await.expect("writer A task");
8221        let result_b = writer_b.await.expect("writer B task");
8222        let successes = [result_a.is_ok(), result_b.is_ok()]
8223            .into_iter()
8224            .filter(|ok| *ok)
8225            .count();
8226        assert_eq!(
8227            successes, 1,
8228            "exactly one production caller must win the race; the other must be refused: \
8229             a={result_a:?} b={result_b:?}"
8230        );
8231        let refused = if result_a.is_err() {
8232            result_a
8233        } else {
8234            result_b
8235        };
8236        match &refused {
8237            Err(RuntimeError::Khive(khive_error)) => {
8238                assert_eq!(
8239                    khive_error.kind(),
8240                    khive_types::ErrorKind::Conflict,
8241                    "the losing production caller must surface a typed conflict, not \
8242                     silently overwrite: {refused:?}"
8243                );
8244            }
8245            other => panic!("expected a typed conflict error, got {other:?}"),
8246        }
8247
8248        let final_edge = rt
8249            .graph(&tok)
8250            .expect("graph store")
8251            .get_edge(LinkId::from(edge_id))
8252            .await
8253            .unwrap()
8254            .expect("edge still exists");
8255        assert!(
8256            !((final_edge.weight - 0.9).abs() < 0.001 && final_edge.metadata.is_some()),
8257            "both racers' changes must never both land: that would mean the loser's stale \
8258             write silently succeeded: {final_edge:?}"
8259        );
8260    }
8261
8262    /// Symmetric-relation counterpart of
8263    /// `production_update_edge_non_symmetric_refuses_concurrent_stale_writer`:
8264    /// two production `update_edge` callers race a `competes_with` edge from
8265    /// the same pre-write revision. Before the symmetric CAS guard, the
8266    /// in-place `UPDATE` carried no expected-revision predicate, so both
8267    /// commits would "succeed" and the second would silently discard the
8268    /// first's weight change — this reddens if that guard (or its wiring
8269    /// into `update_edge_symmetric_dml`) regresses.
8270    #[tokio::test]
8271    async fn production_update_edge_symmetric_refuses_concurrent_stale_writer() {
8272        let rt = std::sync::Arc::new(rt());
8273        let tok = NamespaceToken::local();
8274        let a = rt
8275            .create_entity(&tok, "concept", None, "A", None, None, vec![])
8276            .await
8277            .unwrap();
8278        let b = rt
8279            .create_entity(&tok, "concept", None, "B", None, None, vec![])
8280            .await
8281            .unwrap();
8282        let edge = rt
8283            .link(&tok, a.id, b.id, EdgeRelation::CompetesWith, 0.5, None)
8284            .await
8285            .unwrap();
8286        let edge_id: Uuid = edge.id.into();
8287
8288        let barrier = std::sync::Arc::new(tokio::sync::Barrier::new(2));
8289
8290        let writer_a = {
8291            let rt = std::sync::Arc::clone(&rt);
8292            let tok = tok.clone();
8293            let barrier = std::sync::Arc::clone(&barrier);
8294            tokio::spawn(crate::curation::race_seam::AFTER_READ_BARRIER.scope(
8295                barrier,
8296                async move {
8297                    rt.update_edge(
8298                        &tok,
8299                        edge_id,
8300                        crate::curation::EdgePatch {
8301                            weight: Some(0.9),
8302                            ..Default::default()
8303                        },
8304                    )
8305                    .await
8306                },
8307            ))
8308        };
8309        let writer_b = {
8310            let rt = std::sync::Arc::clone(&rt);
8311            let tok = tok.clone();
8312            let barrier = std::sync::Arc::clone(&barrier);
8313            tokio::spawn(crate::curation::race_seam::AFTER_READ_BARRIER.scope(
8314                barrier,
8315                async move {
8316                    rt.update_edge(
8317                        &tok,
8318                        edge_id,
8319                        crate::curation::EdgePatch {
8320                            weight: Some(0.1),
8321                            ..Default::default()
8322                        },
8323                    )
8324                    .await
8325                },
8326            ))
8327        };
8328
8329        let result_a = writer_a.await.expect("writer A task");
8330        let result_b = writer_b.await.expect("writer B task");
8331        let successes = [result_a.is_ok(), result_b.is_ok()]
8332            .into_iter()
8333            .filter(|ok| *ok)
8334            .count();
8335        assert_eq!(
8336            successes, 1,
8337            "exactly one production caller must win the symmetric-edge race; the other must \
8338             be refused: a={result_a:?} b={result_b:?}"
8339        );
8340        let refused = if result_a.is_err() {
8341            result_a
8342        } else {
8343            result_b
8344        };
8345        match &refused {
8346            Err(RuntimeError::Khive(khive_error)) => {
8347                assert_eq!(
8348                    khive_error.kind(),
8349                    khive_types::ErrorKind::Conflict,
8350                    "the losing production caller must surface a typed conflict, not \
8351                     silently overwrite: {refused:?}"
8352                );
8353            }
8354            other => panic!("expected a typed conflict error, got {other:?}"),
8355        }
8356    }
8357
8358    /// The symmetric absorption delete (case (b): a canonical survivor `S`
8359    /// already exists at the target natural key) must refuse a stale writer
8360    /// the same way the in-place update arm (case (a)) already does. `E`'s
8361    /// own revision advances after a would-be stale writer's snapshot read;
8362    /// that snapshot is then fed straight into the exact DML `update_edge`
8363    /// runs, reproducing the race deterministically (no scheduler-dependent
8364    /// concurrency needed: the outcome depends only on which snapshot the
8365    /// delete is guarded against, not on wall-clock interleaving).
8366    #[tokio::test]
8367    async fn update_edge_symmetric_absorption_refuses_stale_snapshot_when_survivor_exists() {
8368        let rt = rt();
8369        let tok = NamespaceToken::local();
8370        let a = rt
8371            .create_entity(&tok, "concept", None, "A", None, None, vec![])
8372            .await
8373            .unwrap();
8374        let b = rt
8375            .create_entity(&tok, "concept", None, "B", None, None, vec![])
8376            .await
8377            .unwrap();
8378
8379        // Survivor S already owns the canonical natural key before the
8380        // stale writer's snapshot is even read.
8381        let survivor = rt
8382            .link(&tok, a.id, b.id, EdgeRelation::CompetesWith, 0.6, None)
8383            .await
8384            .unwrap();
8385        let survivor_id: Uuid = survivor.id.into();
8386
8387        // E: the edge a stale writer will try to move onto that same
8388        // natural key.
8389        let e = rt
8390            .link(&tok, a.id, b.id, EdgeRelation::Extends, 0.2, None)
8391            .await
8392            .unwrap();
8393        let e_id: Uuid = e.id.into();
8394        let stale_updated_at_micros = e.updated_at.timestamp_micros();
8395        let stale_deleted_at_micros = e.deleted_at.map(|t| t.timestamp_micros());
8396
8397        // A concurrent writer advances E's own revision after that snapshot
8398        // was captured.
8399        let advanced = rt
8400            .update_edge(
8401                &tok,
8402                e_id,
8403                crate::curation::EdgePatch {
8404                    weight: Some(0.77),
8405                    ..Default::default()
8406                },
8407            )
8408            .await
8409            .expect("concurrent writer update");
8410        assert_ne!(
8411            advanced.updated_at.timestamp_micros(),
8412            stale_updated_at_micros,
8413            "test setup: the concurrent write must actually advance the revision"
8414        );
8415
8416        // Prove every non-target premise BEFORE the DML runs. Asserting them
8417        // afterwards cannot distinguish "the survivor already held the canonical
8418        // key and the conflict predicate refused" from "the survivor appeared
8419        // later", and it cannot show which conjunct did the refusing.
8420        assert_eq!(
8421            stale_deleted_at_micros, None,
8422            "fixture premise: the stale snapshot must be of a LIVE edge, otherwise \
8423             `deleted_at IS ?14` would refuse and this stops being an isolating fixture"
8424        );
8425        let survivor_before = rt.get_edge(&tok, survivor_id).await.unwrap().expect(
8426            "fixture premise: the survivor must already own the canonical natural key \
8427                 before the stale DML runs",
8428        );
8429        assert_eq!(
8430            survivor_before.relation,
8431            EdgeRelation::CompetesWith,
8432            "fixture premise: the survivor must occupy the canonical natural key this stale \
8433             writer is about to target, otherwise there is no conflict to absorb and the \
8434             refusal would be attributable to something else: {survivor_before:?}"
8435        );
8436        assert_ne!(
8437            survivor_id, e_id,
8438            "fixture premise: the survivor and the stale writer's edge must be distinct rows"
8439        );
8440
8441        // Reproduce the exact DML `update_edge` runs for the stale writer,
8442        // using the snapshot it captured BEFORE the concurrent write above.
8443        let pool = rt.backend().pool_arc();
8444        let guard = pool.writer().expect("writer guard");
8445        let (canon_src, canon_tgt) =
8446            canonical_edge_endpoints(EdgeRelation::CompetesWith, a.id, b.id);
8447        let outcome = guard
8448            .transaction(|conn| {
8449                KhiveRuntime::update_edge_symmetric_dml(
8450                    conn,
8451                    "local",
8452                    &e_id.to_string(),
8453                    &canon_src.to_string(),
8454                    &canon_tgt.to_string(),
8455                    "competes_with",
8456                    0.9,
8457                    None,
8458                    stale_updated_at_micros,
8459                    stale_deleted_at_micros,
8460                )
8461            })
8462            .expect("dml call must not error");
8463        drop(guard);
8464        assert!(
8465            matches!(outcome, SymmetricEdgeUpdateOutcome::Stale),
8466            "a stale writer must be refused, not silently absorbed into the survivor: {outcome:?}"
8467        );
8468
8469        let e_after = rt
8470            .get_edge(&tok, e_id)
8471            .await
8472            .unwrap()
8473            .expect("E must still exist after the refused absorption");
8474        assert_eq!(
8475            e_after.relation,
8476            EdgeRelation::Extends,
8477            "E must be untouched by the refused absorption: {e_after:?}"
8478        );
8479        assert!(
8480            (e_after.weight - 0.77).abs() < 1e-9,
8481            "the concurrent writer's weight must survive the refused absorption: {e_after:?}"
8482        );
8483
8484        let s_after = rt
8485            .get_edge(&tok, survivor_id)
8486            .await
8487            .unwrap()
8488            .expect("S must still exist after the refused absorption");
8489        assert_eq!(
8490            s_after.weight, 0.6,
8491            "the survivor must be untouched by the refused absorption: {s_after:?}"
8492        );
8493    }
8494
8495    /// `updated_at` on this path must use `timestamp_micros()`, matching every other
8496    /// `graph_edges` write path (`edge_upsert_statement`, `edge_soft_delete_statement`);
8497    /// a seconds value misread as microseconds round-trips to a date a few minutes
8498    /// after the Unix epoch, not "now".
8499    #[tokio::test]
8500    async fn update_edge_symmetric_relation_stores_microsecond_updated_at() {
8501        let rt = rt();
8502        let tok = NamespaceToken::local();
8503        let a = rt
8504            .create_entity(&tok, "concept", None, "A", None, None, vec![])
8505            .await
8506            .unwrap();
8507        let b = rt
8508            .create_entity(&tok, "concept", None, "B", None, None, vec![])
8509            .await
8510            .unwrap();
8511        let edge = rt
8512            .link(&tok, a.id, b.id, EdgeRelation::CompetesWith, 1.0, None)
8513            .await
8514            .unwrap();
8515        let edge_id: Uuid = edge.id.into();
8516
8517        let before = chrono::Utc::now();
8518        let updated = rt
8519            .update_edge(
8520                &tok,
8521                edge_id,
8522                crate::curation::EdgePatch {
8523                    weight: Some(0.5),
8524                    ..Default::default()
8525                },
8526            )
8527            .await
8528            .unwrap();
8529
8530        let drift = (updated.updated_at - before).num_seconds().abs();
8531        assert!(
8532            drift < 60,
8533            "updated_at must round-trip as a recent timestamp (micros, not \
8534             seconds); got {:?}, expected within 60s of {:?}",
8535            updated.updated_at,
8536            before
8537        );
8538    }
8539
8540    #[tokio::test]
8541    async fn update_edge_changes_relation() {
8542        let rt = rt();
8543        let tok = NamespaceToken::local();
8544        let a = rt
8545            .create_entity(&tok, "concept", None, "A", None, None, vec![])
8546            .await
8547            .unwrap();
8548        let b = rt
8549            .create_entity(&tok, "concept", None, "B", None, None, vec![])
8550            .await
8551            .unwrap();
8552        let edge = rt
8553            .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
8554            .await
8555            .unwrap();
8556        let edge_id: Uuid = edge.id.into();
8557
8558        let updated = rt
8559            .update_edge(
8560                &tok,
8561                edge_id,
8562                crate::curation::EdgePatch {
8563                    relation: Some(EdgeRelation::VariantOf),
8564                    ..Default::default()
8565                },
8566            )
8567            .await
8568            .unwrap();
8569        assert_eq!(updated.relation, EdgeRelation::VariantOf);
8570    }
8571
8572    /// A symmetric-relation update whose canonical natural key collides
8573    /// with an existing edge must delete the requested (non-canonical)
8574    /// row and leave the surviving canonical row's attributes untouched
8575    /// (ADR-039 ON CONFLICT DO NOTHING) — the discarded edge's patched
8576    /// weight must never overwrite the survivor (khive#1213).
8577    #[tokio::test]
8578    async fn update_edge_symmetric_conflict_keeps_survivor_attributes() {
8579        let rt = rt();
8580        let tok = NamespaceToken::local();
8581        let a = rt
8582            .create_entity(&tok, "concept", None, "A", None, None, vec![])
8583            .await
8584            .unwrap();
8585        let b = rt
8586            .create_entity(&tok, "concept", None, "B", None, None, vec![])
8587            .await
8588            .unwrap();
8589
8590        let requested = rt
8591            .link(&tok, a.id, b.id, EdgeRelation::Extends, 0.2, None)
8592            .await
8593            .unwrap();
8594        let requested_id: Uuid = requested.id.into();
8595
8596        let canonical = rt
8597            .link(&tok, a.id, b.id, EdgeRelation::CompetesWith, 0.6, None)
8598            .await
8599            .unwrap();
8600        let canonical_id: Uuid = canonical.id.into();
8601
8602        let updated = rt
8603            .update_edge(
8604                &tok,
8605                requested_id,
8606                crate::curation::EdgePatch {
8607                    relation: Some(EdgeRelation::CompetesWith),
8608                    weight: Some(0.9),
8609                    ..Default::default()
8610                },
8611            )
8612            .await
8613            .unwrap();
8614
8615        // The requested (non-canonical) row was absorbed into the survivor.
8616        assert_eq!(Uuid::from(updated.id), canonical_id);
8617        assert_eq!(
8618            updated.weight, 0.6,
8619            "survivor weight must not be overwritten by the discarded edge's patch"
8620        );
8621
8622        let requested_after = rt
8623            .get_edge_including_deleted(&tok, requested_id)
8624            .await
8625            .unwrap();
8626        assert!(
8627            requested_after.is_none(),
8628            "the non-canonical requested row must be deleted, not just tombstoned"
8629        );
8630    }
8631
8632    /// A soft-deleted surviving canonical row must not be resurrected by a
8633    /// conflicting symmetric-relation update (khive#1213).
8634    #[tokio::test]
8635    async fn update_edge_symmetric_conflict_does_not_resurrect_tombstoned_survivor() {
8636        let rt = rt();
8637        let tok = NamespaceToken::local();
8638        let a = rt
8639            .create_entity(&tok, "concept", None, "A", None, None, vec![])
8640            .await
8641            .unwrap();
8642        let b = rt
8643            .create_entity(&tok, "concept", None, "B", None, None, vec![])
8644            .await
8645            .unwrap();
8646
8647        let requested = rt
8648            .link(&tok, a.id, b.id, EdgeRelation::Extends, 0.2, None)
8649            .await
8650            .unwrap();
8651        let requested_id: Uuid = requested.id.into();
8652
8653        let canonical = rt
8654            .link(&tok, a.id, b.id, EdgeRelation::CompetesWith, 0.6, None)
8655            .await
8656            .unwrap();
8657        let canonical_id: Uuid = canonical.id.into();
8658        rt.delete_edge(&tok, canonical_id, false).await.unwrap();
8659
8660        rt.update_edge(
8661            &tok,
8662            requested_id,
8663            crate::curation::EdgePatch {
8664                relation: Some(EdgeRelation::CompetesWith),
8665                weight: Some(0.9),
8666                ..Default::default()
8667            },
8668        )
8669        .await
8670        .unwrap();
8671
8672        let requested_after = rt
8673            .get_edge_including_deleted(&tok, requested_id)
8674            .await
8675            .unwrap();
8676        assert!(
8677            requested_after.is_none(),
8678            "the non-canonical requested row must be deleted, not just tombstoned"
8679        );
8680
8681        let canonical_after = rt.get_edge(&tok, canonical_id).await.unwrap();
8682        assert!(
8683            canonical_after.is_none(),
8684            "a tombstoned survivor must not be resurrected by a conflicting update"
8685        );
8686    }
8687
8688    // ---- update_edge endpoint validation ----
8689
8690    // update_edge: note→entity annotates → set relation=Supersedes → InvalidInput (crossing).
8691    // Edge must NOT be mutated in the store.
8692    #[tokio::test]
8693    async fn update_edge_annotates_note_to_entity_set_supersedes_returns_invalid_input() {
8694        let rt = rt();
8695        let tok = NamespaceToken::local();
8696        let note = rt
8697            .create_note(&tok, "observation", None, "a note", Some(0.5), None, vec![])
8698            .await
8699            .unwrap();
8700        let entity = rt
8701            .create_entity(&tok, "concept", None, "E", None, None, vec![])
8702            .await
8703            .unwrap();
8704        // Create a valid note→entity annotates edge.
8705        let edge = rt
8706            .link(&tok, note.id, entity.id, EdgeRelation::Annotates, 1.0, None)
8707            .await
8708            .unwrap();
8709        let edge_id: Uuid = edge.id.into();
8710
8711        // Attempt to change relation to Supersedes (crossing substrates → invalid).
8712        let result = rt
8713            .update_edge(
8714                &tok,
8715                edge_id,
8716                crate::curation::EdgePatch {
8717                    relation: Some(EdgeRelation::Supersedes),
8718                    ..Default::default()
8719                },
8720            )
8721            .await;
8722        assert!(
8723            matches!(result, Err(RuntimeError::InvalidInput(_))),
8724            "update to Supersedes on note→entity edge must return InvalidInput, got {result:?}"
8725        );
8726
8727        // Edge must NOT be mutated — re-fetch and verify relation unchanged.
8728        let fetched = rt.get_edge(&tok, edge_id).await.unwrap().unwrap();
8729        assert_eq!(
8730            fetched.relation,
8731            EdgeRelation::Annotates,
8732            "edge relation must be unchanged after failed update"
8733        );
8734    }
8735
8736    // update_edge: entity→entity extends → set relation=Annotates → InvalidInput
8737    // (annotates source must be a note).
8738    #[tokio::test]
8739    async fn update_edge_entity_to_entity_set_annotates_returns_invalid_input() {
8740        let rt = rt();
8741        let tok = NamespaceToken::local();
8742        let a = rt
8743            .create_entity(&tok, "concept", None, "A", None, None, vec![])
8744            .await
8745            .unwrap();
8746        let b = rt
8747            .create_entity(&tok, "concept", None, "B", None, None, vec![])
8748            .await
8749            .unwrap();
8750        let edge = rt
8751            .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
8752            .await
8753            .unwrap();
8754        let edge_id: Uuid = edge.id.into();
8755
8756        let result = rt
8757            .update_edge(
8758                &tok,
8759                edge_id,
8760                crate::curation::EdgePatch {
8761                    relation: Some(EdgeRelation::Annotates),
8762                    ..Default::default()
8763                },
8764            )
8765            .await;
8766        assert!(
8767            matches!(result, Err(RuntimeError::InvalidInput(_))),
8768            "update to Annotates on entity→entity edge must return InvalidInput, got {result:?}"
8769        );
8770    }
8771
8772    // update_edge: entity→entity extends → set relation=Supersedes → Ok
8773    // (entity→entity is valid for supersedes).
8774    #[tokio::test]
8775    async fn update_edge_entity_to_entity_set_supersedes_succeeds() {
8776        let rt = rt();
8777        let tok = NamespaceToken::local();
8778        let a = rt
8779            .create_entity(&tok, "concept", None, "A", None, None, vec![])
8780            .await
8781            .unwrap();
8782        let b = rt
8783            .create_entity(&tok, "concept", None, "B", None, None, vec![])
8784            .await
8785            .unwrap();
8786        let edge = rt
8787            .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
8788            .await
8789            .unwrap();
8790        let edge_id: Uuid = edge.id.into();
8791
8792        let updated = rt
8793            .update_edge(
8794                &tok,
8795                edge_id,
8796                crate::curation::EdgePatch {
8797                    relation: Some(EdgeRelation::Supersedes),
8798                    ..Default::default()
8799                },
8800            )
8801            .await
8802            .unwrap();
8803        assert_eq!(updated.relation, EdgeRelation::Supersedes);
8804
8805        // Verify persisted.
8806        let fetched = rt.get_edge(&tok, edge_id).await.unwrap().unwrap();
8807        assert_eq!(fetched.relation, EdgeRelation::Supersedes);
8808    }
8809
8810    // update_edge: weight-only (relation = None) → Ok, no validation, unchanged relation.
8811    #[tokio::test]
8812    async fn update_edge_weight_only_skips_validation() {
8813        let rt = rt();
8814        let tok = NamespaceToken::local();
8815        let a = rt
8816            .create_entity(&tok, "concept", None, "A", None, None, vec![])
8817            .await
8818            .unwrap();
8819        let b = rt
8820            .create_entity(&tok, "concept", None, "B", None, None, vec![])
8821            .await
8822            .unwrap();
8823        let edge = rt
8824            .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
8825            .await
8826            .unwrap();
8827        let edge_id: Uuid = edge.id.into();
8828
8829        let updated = rt
8830            .update_edge(
8831                &tok,
8832                edge_id,
8833                crate::curation::EdgePatch {
8834                    weight: Some(0.3),
8835                    ..Default::default()
8836                },
8837            )
8838            .await
8839            .unwrap();
8840        assert_eq!(updated.relation, EdgeRelation::Extends);
8841        assert!((updated.weight - 0.3).abs() < 0.001);
8842    }
8843
8844    // update_edge: entity→entity extends → set relation=VariantOf (same class) → Ok.
8845    #[tokio::test]
8846    async fn update_edge_same_class_relation_change_succeeds() {
8847        let rt = rt();
8848        let tok = NamespaceToken::local();
8849        let a = rt
8850            .create_entity(&tok, "concept", None, "A", None, None, vec![])
8851            .await
8852            .unwrap();
8853        let b = rt
8854            .create_entity(&tok, "concept", None, "B", None, None, vec![])
8855            .await
8856            .unwrap();
8857        let edge = rt
8858            .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
8859            .await
8860            .unwrap();
8861        let edge_id: Uuid = edge.id.into();
8862
8863        let updated = rt
8864            .update_edge(
8865                &tok,
8866                edge_id,
8867                crate::curation::EdgePatch {
8868                    relation: Some(EdgeRelation::VariantOf),
8869                    ..Default::default()
8870                },
8871            )
8872            .await
8873            .unwrap();
8874        assert_eq!(updated.relation, EdgeRelation::VariantOf);
8875    }
8876
8877    /// #2088 regression at the runtime seam; this fixture was rebuilt
8878    /// deterministically for #2089 because the previous fixture created edges
8879    /// through the normal `link()` path (random UUIDs, wall-clock
8880    /// timestamps), so whether it exposed the pre-image bug — sorting the
8881    /// per-namespace merge by UUID alone, a key unrelated to the
8882    /// `created_at` order each per-namespace fetch was actually cut at —
8883    /// depended on the RNG; it could pass against pre-image code by pure
8884    /// luck.
8885    ///
8886    /// This fixture pins both: every `ns-a` edge gets a UUID numerically
8887    /// greater than every `ns-b` edge, while `created_at` strictly
8888    /// interleaves the two namespaces (a, b, a, b, a, b). A UUID-only sort
8889    /// therefore reliably produces "every ns-b edge, then every ns-a edge"
8890    /// — provably different from the true `created_at` interleaving — so
8891    /// asserting this exact expected sequence (not just the resulting set)
8892    /// is what a reverted pre-image implementation would fail to
8893    /// reproduce.
8894    #[tokio::test]
8895    async fn list_edges_multi_namespace_offset_paging_enumerates_exactly() {
8896        let rt = rt();
8897        let ns_a = Namespace::parse("ns-a").unwrap();
8898        let ns_b = Namespace::parse("ns-b").unwrap();
8899        let tok_a = NamespaceToken::for_namespace(ns_a.clone());
8900        let tok_b = NamespaceToken::for_namespace(ns_b.clone());
8901
8902        let source = rt
8903            .create_entity(&tok_a, "concept", None, "Source", None, None, vec![])
8904            .await
8905            .unwrap();
8906        let mut targets = Vec::new();
8907        for index in 0..6 {
8908            targets.push(
8909                rt.create_entity(
8910                    &tok_a,
8911                    "concept",
8912                    None,
8913                    &format!("Target{index}"),
8914                    None,
8915                    None,
8916                    vec![],
8917                )
8918                .await
8919                .unwrap(),
8920            );
8921        }
8922
8923        // Every `a_uuids` entry is numerically greater than every
8924        // `b_uuids` entry (leading nibble `f` vs `0`).
8925        let a_uuids = [
8926            Uuid::parse_str("ffffffff-ffff-4fff-8fff-ffffffffff01").unwrap(),
8927            Uuid::parse_str("ffffffff-ffff-4fff-8fff-ffffffffff02").unwrap(),
8928            Uuid::parse_str("ffffffff-ffff-4fff-8fff-ffffffffff03").unwrap(),
8929        ];
8930        let b_uuids = [
8931            Uuid::parse_str("00000000-0000-4000-8000-000000000001").unwrap(),
8932            Uuid::parse_str("00000000-0000-4000-8000-000000000002").unwrap(),
8933            Uuid::parse_str("00000000-0000-4000-8000-000000000003").unwrap(),
8934        ];
8935
8936        let graph_a = rt.graph(&tok_a).unwrap();
8937        let graph_b = rt.graph(&tok_b).unwrap();
8938        let mut expected_order: Vec<Uuid> = Vec::new();
8939        for i in 0..3usize {
8940            let a_created_at = chrono::DateTime::<chrono::Utc>::from_timestamp_micros(
8941                1_000_000 + (2 * i as i64) * 1_000_000,
8942            )
8943            .unwrap();
8944            graph_a
8945                .upsert_edge(Edge {
8946                    id: a_uuids[i].into(),
8947                    namespace: "ns-a".into(),
8948                    source_id: source.id,
8949                    target_id: targets[2 * i].id,
8950                    relation: EdgeRelation::Extends,
8951                    weight: 0.5,
8952                    created_at: a_created_at,
8953                    updated_at: a_created_at,
8954                    deleted_at: None,
8955                    metadata: None,
8956                    target_backend: None,
8957                })
8958                .await
8959                .unwrap();
8960            expected_order.push(a_uuids[i]);
8961
8962            let b_created_at = chrono::DateTime::<chrono::Utc>::from_timestamp_micros(
8963                2_000_000 + (2 * i as i64) * 1_000_000,
8964            )
8965            .unwrap();
8966            graph_b
8967                .upsert_edge(Edge {
8968                    id: b_uuids[i].into(),
8969                    namespace: "ns-b".into(),
8970                    source_id: source.id,
8971                    target_id: targets[2 * i + 1].id,
8972                    relation: EdgeRelation::Extends,
8973                    weight: 0.5,
8974                    created_at: b_created_at,
8975                    updated_at: b_created_at,
8976                    deleted_at: None,
8977                    metadata: None,
8978                    target_backend: None,
8979                })
8980                .await
8981                .unwrap();
8982            expected_order.push(b_uuids[i]);
8983        }
8984
8985        let multi = NamespaceToken::mint_with_visibility(ns_a, vec![ns_b], ActorRef::anonymous());
8986        let mut seen: Vec<Uuid> = Vec::new();
8987        let mut offset = 0u32;
8988        loop {
8989            let page = rt
8990                .list_edges(&multi, EdgeListFilter::default(), 2, offset)
8991                .await
8992                .unwrap();
8993            if page.is_empty() {
8994                break;
8995            }
8996            seen.extend(page.iter().map(|e| Uuid::from(e.id)));
8997            offset += 2;
8998        }
8999
9000        assert_eq!(
9001            seen, expected_order,
9002            "must enumerate in exact created_at order, not UUID order"
9003        );
9004        let distinct: std::collections::HashSet<Uuid> = seen.iter().copied().collect();
9005        assert_eq!(distinct.len(), 6, "no duplicates across pages");
9006        let expected_set: std::collections::HashSet<Uuid> =
9007            expected_order.iter().copied().collect();
9008        assert_eq!(
9009            distinct, expected_set,
9010            "enumerated set must equal the seeded set"
9011        );
9012    }
9013
9014    /// #2089: `list_edges`'s trait-default fallback
9015    /// (taken when a `GraphStore` backend returns `Unsupported` from
9016    /// `query_edges_in_namespaces` — i.e. it does not override the batched
9017    /// namespace query, per [`khive_storage::GraphStore`]'s default) merges
9018    /// independently-fetched per-namespace prefixes through
9019    /// [`KhiveRuntime::merge_paged_namespace_edges`]. This exercises that
9020    /// merge directly: `ns_a`'s edges all carry numerically-greater UUIDs
9021    /// than every `ns_b` edge, while `created_at` interleaves the two
9022    /// namespaces. Sorting the merge by UUID alone (the pre-image #2088 bug)
9023    /// would put every `ns_b` edge before every `ns_a` edge regardless of
9024    /// `created_at` — provably different from this fixture's expected
9025    /// order — so an exact-sequence assertion here is sufficient to catch a
9026    /// regression back to that bug.
9027    #[test]
9028    fn merge_paged_namespace_edges_orders_by_created_at_not_uuid() {
9029        fn edge(id_hex_suffix: &str, created_at_micros: i64) -> Edge {
9030            let created_at =
9031                chrono::DateTime::<chrono::Utc>::from_timestamp_micros(created_at_micros).unwrap();
9032            Edge {
9033                id: Uuid::parse_str(&format!("00000000-0000-4000-8000-{id_hex_suffix}"))
9034                    .unwrap()
9035                    .into(),
9036                namespace: "irrelevant".into(),
9037                source_id: Uuid::nil(),
9038                target_id: Uuid::nil(),
9039                relation: EdgeRelation::Extends,
9040                weight: 0.5,
9041                created_at,
9042                updated_at: created_at,
9043                deleted_at: None,
9044                metadata: None,
9045                target_backend: None,
9046            }
9047        }
9048
9049        // Every `a*` UUID (leading nibble `f`) is numerically greater than
9050        // every `b*` UUID (leading nibble `0`), but `created_at` interleaves
9051        // them: a0 < b0 < a1 < b1.
9052        let a0 = edge("ffffffffffff", 1_000_000);
9053        let b0 = edge("000000000001", 2_000_000);
9054        let a1 = edge("fffffffffffe", 3_000_000);
9055        let b1 = edge("000000000002", 4_000_000);
9056
9057        let namespace_prefixes = vec![vec![a0.clone(), a1.clone()], vec![b0.clone(), b1.clone()]];
9058
9059        let full_page = KhiveRuntime::merge_paged_namespace_edges(namespace_prefixes.clone(), 0, 4);
9060        let ids: Vec<Uuid> = full_page.iter().map(|e| Uuid::from(e.id)).collect();
9061        assert_eq!(
9062            ids,
9063            vec![
9064                Uuid::from(a0.id),
9065                Uuid::from(b0.id),
9066                Uuid::from(a1.id),
9067                Uuid::from(b1.id)
9068            ],
9069            "must order the merge by created_at, not by UUID magnitude"
9070        );
9071
9072        // Mid-window offset must slice the same globally-ordered sequence,
9073        // not re-derive order per page.
9074        let middle_page = KhiveRuntime::merge_paged_namespace_edges(namespace_prefixes, 1, 2);
9075        let middle_ids: Vec<Uuid> = middle_page.iter().map(|e| Uuid::from(e.id)).collect();
9076        assert_eq!(middle_ids, vec![Uuid::from(b0.id), Uuid::from(a1.id)]);
9077    }
9078
9079    #[tokio::test]
9080    async fn list_edges_filters_by_relation() {
9081        let rt = rt();
9082        let tok = NamespaceToken::local();
9083        let a = rt
9084            .create_entity(&tok, "concept", None, "A", None, None, vec![])
9085            .await
9086            .unwrap();
9087        let b = rt
9088            .create_entity(&tok, "concept", None, "B", None, None, vec![])
9089            .await
9090            .unwrap();
9091        let c = rt
9092            .create_entity(&tok, "concept", None, "C", None, None, vec![])
9093            .await
9094            .unwrap();
9095
9096        rt.link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
9097            .await
9098            .unwrap();
9099        rt.link(&tok, a.id, c.id, EdgeRelation::Enables, 1.0, None)
9100            .await
9101            .unwrap();
9102
9103        let filter = EdgeListFilter {
9104            relations: vec![EdgeRelation::Extends],
9105            ..Default::default()
9106        };
9107        let edges = rt.list_edges(&tok, filter, 100, 0).await.unwrap();
9108        assert_eq!(edges.len(), 1);
9109        assert_eq!(edges[0].relation, EdgeRelation::Extends);
9110    }
9111
9112    #[tokio::test]
9113    async fn list_edges_filters_by_source() {
9114        let rt = rt();
9115        let tok = NamespaceToken::local();
9116        let a = rt
9117            .create_entity(&tok, "concept", None, "A", None, None, vec![])
9118            .await
9119            .unwrap();
9120        let b = rt
9121            .create_entity(&tok, "concept", None, "B", None, None, vec![])
9122            .await
9123            .unwrap();
9124        let c = rt
9125            .create_entity(&tok, "concept", None, "C", None, None, vec![])
9126            .await
9127            .unwrap();
9128        let d = rt
9129            .create_entity(&tok, "concept", None, "D", None, None, vec![])
9130            .await
9131            .unwrap();
9132
9133        rt.link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
9134            .await
9135            .unwrap();
9136        rt.link(&tok, c.id, d.id, EdgeRelation::Extends, 1.0, None)
9137            .await
9138            .unwrap();
9139
9140        let filter = EdgeListFilter {
9141            source_id: Some(a.id),
9142            ..Default::default()
9143        };
9144        let edges = rt.list_edges(&tok, filter, 100, 0).await.unwrap();
9145        assert_eq!(edges.len(), 1);
9146        let src: Uuid = edges[0].source_id;
9147        assert_eq!(src, a.id);
9148    }
9149
9150    /// Regression: `offset` was hard-coded to 0 in `list_edges`, so every
9151    /// page returned the identical first rows. Pages must now tile the full
9152    /// matching set with no gaps or duplicates, and an out-of-range offset
9153    /// must return empty rather than page 1.
9154    #[tokio::test]
9155    async fn list_edges_offset_pages_through_full_set() {
9156        let rt = rt();
9157        let tok = NamespaceToken::local();
9158        let a = rt
9159            .create_entity(&tok, "concept", None, "A", None, None, vec![])
9160            .await
9161            .unwrap();
9162        for i in 0..5 {
9163            let t = rt
9164                .create_entity(&tok, "concept", None, &format!("T{i}"), None, None, vec![])
9165                .await
9166                .unwrap();
9167            rt.link(&tok, a.id, t.id, EdgeRelation::Extends, 1.0, None)
9168                .await
9169                .unwrap();
9170        }
9171
9172        let filter = EdgeListFilter {
9173            source_id: Some(a.id),
9174            relations: vec![EdgeRelation::Extends],
9175            ..Default::default()
9176        };
9177
9178        let page0 = rt.list_edges(&tok, filter.clone(), 2, 0).await.unwrap();
9179        let page1 = rt.list_edges(&tok, filter.clone(), 2, 2).await.unwrap();
9180        let page2 = rt.list_edges(&tok, filter.clone(), 2, 4).await.unwrap();
9181        assert_eq!(page0.len(), 2);
9182        assert_eq!(page1.len(), 2);
9183        assert_eq!(page2.len(), 1);
9184
9185        let ids = |p: &[Edge]| p.iter().map(|e| Uuid::from(e.id)).collect::<Vec<_>>();
9186        assert_ne!(ids(&page0), ids(&page1), "page 2 must differ from page 1");
9187
9188        let mut all_ids: Vec<Uuid> = ids(&page0)
9189            .into_iter()
9190            .chain(ids(&page1))
9191            .chain(ids(&page2))
9192            .collect();
9193        all_ids.sort();
9194        all_ids.dedup();
9195        assert_eq!(all_ids.len(), 5, "pages must tile the full edge set");
9196
9197        let empty = rt.list_edges(&tok, filter.clone(), 2, 100).await.unwrap();
9198        assert!(
9199            empty.is_empty(),
9200            "offset past the end must return empty, not page 1"
9201        );
9202    }
9203
9204    /// `list_edges_after` seeks via a durable insertion sequence instead of
9205    /// paging through OFFSET, so cost does not grow with walk depth.
9206    #[tokio::test]
9207    async fn list_edges_after_keyset_tiles_full_set() {
9208        let rt = rt();
9209        let tok = NamespaceToken::local();
9210        let a = rt
9211            .create_entity(&tok, "concept", None, "A", None, None, vec![])
9212            .await
9213            .unwrap();
9214        for i in 0..5 {
9215            let t = rt
9216                .create_entity(&tok, "concept", None, &format!("K{i}"), None, None, vec![])
9217                .await
9218                .unwrap();
9219            rt.link(&tok, a.id, t.id, EdgeRelation::Extends, 1.0, None)
9220                .await
9221                .unwrap();
9222        }
9223
9224        let filter = EdgeListFilter {
9225            source_id: Some(a.id),
9226            relations: vec![EdgeRelation::Extends],
9227            ..Default::default()
9228        };
9229
9230        let mut seen = Vec::new();
9231        let mut cursor: Option<Uuid> = None;
9232        for _ in 0..20 {
9233            let (page, next) = rt
9234                .list_edges_after(&tok, filter.clone(), cursor, 2)
9235                .await
9236                .unwrap();
9237            if page.is_empty() {
9238                break;
9239            }
9240            seen.extend(page.iter().map(|e| Uuid::from(e.id)));
9241            if next.is_none() {
9242                break;
9243            }
9244            cursor = next;
9245        }
9246        seen.sort();
9247        seen.dedup();
9248        assert_eq!(seen.len(), 5, "keyset walk must tile the full edge set");
9249
9250        // With no intervening writes, repeating the same cursor returns the
9251        // same insertion-sequence page.
9252        let (first_a, next_a) = rt
9253            .list_edges_after(&tok, filter.clone(), None, 2)
9254            .await
9255            .unwrap();
9256        let (first_b, next_b) = rt
9257            .list_edges_after(&tok, filter.clone(), None, 2)
9258            .await
9259            .unwrap();
9260        assert_eq!(
9261            first_a.iter().map(|e| e.id.0).collect::<Vec<_>>(),
9262            first_b.iter().map(|e| e.id.0).collect::<Vec<_>>(),
9263        );
9264        assert_eq!(next_a, next_b);
9265    }
9266
9267    #[tokio::test]
9268    async fn list_entities_after_same_timestamp_lower_uuid_insert_is_not_skipped() {
9269        let rt = rt();
9270        let tok = NamespaceToken::local();
9271        let ids = [
9272            Uuid::parse_str("f0000000-0000-4000-8000-000000000011").unwrap(),
9273            Uuid::parse_str("f0000000-0000-4000-8000-000000000012").unwrap(),
9274            Uuid::parse_str("f0000000-0000-4000-8000-000000000013").unwrap(),
9275        ];
9276        let store = rt.entities(&tok).unwrap();
9277        for (index, id) in ids.into_iter().enumerate() {
9278            let mut entity = Entity::new("local", "concept", format!("Entity{index}"));
9279            entity.id = id;
9280            entity.created_at = 1_000_000;
9281            entity.updated_at = 1_000_000;
9282            store.upsert_entity(entity).await.unwrap();
9283        }
9284
9285        let (first, next) = rt
9286            .list_entities_after(&tok, None, None, &[], None, 2)
9287            .await
9288            .unwrap();
9289        assert_eq!(
9290            first.iter().map(|entity| entity.id).collect::<Vec<_>>(),
9291            ids[..2]
9292        );
9293        let cursor = next.expect("one original entity remains after page one");
9294
9295        let low_id = Uuid::parse_str("00000000-0000-4000-8000-000000000011").unwrap();
9296        assert!(low_id < cursor);
9297        let mut inserted = Entity::new("local", "concept", "InsertedEntity");
9298        inserted.id = low_id;
9299        inserted.created_at = 1_000_000;
9300        inserted.updated_at = 1_000_000;
9301        store.upsert_entity(inserted).await.unwrap();
9302
9303        let (second, final_cursor) = rt
9304            .list_entities_after(&tok, None, None, &[], Some(cursor), 2)
9305            .await
9306            .unwrap();
9307        assert_eq!(
9308            second.iter().map(|entity| entity.id).collect::<Vec<_>>(),
9309            vec![ids[2], low_id]
9310        );
9311        assert_eq!(final_cursor, None);
9312    }
9313
9314    #[tokio::test]
9315    async fn list_notes_after_same_timestamp_lower_uuid_insert_is_not_skipped() {
9316        let rt = rt();
9317        let tok = NamespaceToken::local();
9318        let ids = [
9319            Uuid::parse_str("f0000000-0000-4000-8000-000000000021").unwrap(),
9320            Uuid::parse_str("f0000000-0000-4000-8000-000000000022").unwrap(),
9321            Uuid::parse_str("f0000000-0000-4000-8000-000000000023").unwrap(),
9322        ];
9323        let store = rt.notes(&tok).unwrap();
9324        for (index, id) in ids.into_iter().enumerate() {
9325            let mut note = Note::new("local", "observation", format!("Note {index}"));
9326            note.id = id;
9327            note.created_at = 1_000_000;
9328            note.updated_at = 1_000_000;
9329            store.upsert_note(note).await.unwrap();
9330        }
9331
9332        let (first, next) = rt.list_notes_after(&tok, None, None, 2).await.unwrap();
9333        assert_eq!(
9334            first.iter().map(|note| note.id).collect::<Vec<_>>(),
9335            ids[..2]
9336        );
9337        let cursor = next.expect("one original note remains after page one");
9338
9339        let low_id = Uuid::parse_str("00000000-0000-4000-8000-000000000021").unwrap();
9340        assert!(low_id < cursor);
9341        let mut inserted = Note::new("local", "observation", "Inserted note");
9342        inserted.id = low_id;
9343        inserted.created_at = 1_000_000;
9344        inserted.updated_at = 1_000_000;
9345        store.upsert_note(inserted).await.unwrap();
9346
9347        let (second, final_cursor) = rt
9348            .list_notes_after(&tok, None, Some(cursor), 2)
9349            .await
9350            .unwrap();
9351        assert_eq!(
9352            second.iter().map(|note| note.id).collect::<Vec<_>>(),
9353            vec![ids[2], low_id]
9354        );
9355        assert_eq!(final_cursor, None);
9356    }
9357
9358    #[tokio::test]
9359    async fn list_edges_after_same_timestamp_lower_uuid_insert_is_not_skipped() {
9360        let rt = rt();
9361        let tok = NamespaceToken::local();
9362        let source = rt
9363            .create_entity(&tok, "concept", None, "CursorSource", None, None, vec![])
9364            .await
9365            .unwrap();
9366        let mut targets = Vec::new();
9367        for index in 0..4 {
9368            targets.push(
9369                rt.create_entity(
9370                    &tok,
9371                    "concept",
9372                    None,
9373                    &format!("CursorTarget{index}"),
9374                    None,
9375                    None,
9376                    vec![],
9377                )
9378                .await
9379                .unwrap(),
9380            );
9381        }
9382
9383        let ids = [
9384            Uuid::parse_str("f0000000-0000-4000-8000-000000000001").unwrap(),
9385            Uuid::parse_str("f0000000-0000-4000-8000-000000000002").unwrap(),
9386            Uuid::parse_str("f0000000-0000-4000-8000-000000000003").unwrap(),
9387        ];
9388        let graph = rt.graph(&tok).unwrap();
9389        let created_at = chrono::DateTime::<chrono::Utc>::from_timestamp_micros(1_000_000).unwrap();
9390        for (index, id) in ids.into_iter().enumerate() {
9391            graph
9392                .upsert_edge(Edge {
9393                    id: id.into(),
9394                    namespace: "local".into(),
9395                    source_id: source.id,
9396                    target_id: targets[index].id,
9397                    relation: EdgeRelation::Extends,
9398                    weight: 1.0,
9399                    created_at,
9400                    updated_at: created_at,
9401                    deleted_at: None,
9402                    metadata: None,
9403                    target_backend: None,
9404                })
9405                .await
9406                .unwrap();
9407        }
9408
9409        let filter = EdgeListFilter {
9410            source_id: Some(source.id),
9411            relations: vec![EdgeRelation::Extends],
9412            ..Default::default()
9413        };
9414        let (first, next) = rt
9415            .list_edges_after(&tok, filter.clone(), None, 2)
9416            .await
9417            .unwrap();
9418        assert_eq!(
9419            first.iter().map(|edge| edge.id.0).collect::<Vec<_>>(),
9420            ids[..2]
9421        );
9422        let cursor = next.expect("one original edge remains after page one");
9423
9424        // The later insert has the same wall-clock microsecond and sorts before
9425        // the cursor UUID. Its database-assigned sequence still places it after
9426        // the issued boundary.
9427        let low_id = Uuid::parse_str("00000000-0000-4000-8000-000000000001").unwrap();
9428        assert!(low_id < cursor);
9429        graph
9430            .upsert_edge(Edge {
9431                id: low_id.into(),
9432                namespace: "local".into(),
9433                source_id: source.id,
9434                target_id: targets[3].id,
9435                relation: EdgeRelation::Extends,
9436                weight: 1.0,
9437                created_at,
9438                updated_at: created_at,
9439                deleted_at: None,
9440                metadata: None,
9441                target_backend: None,
9442            })
9443            .await
9444            .unwrap();
9445
9446        let (second, final_cursor) = rt
9447            .list_edges_after(&tok, filter, Some(cursor), 2)
9448            .await
9449            .unwrap();
9450        assert_eq!(
9451            second.iter().map(|edge| edge.id.0).collect::<Vec<_>>(),
9452            vec![ids[2], low_id]
9453        );
9454        assert_eq!(final_cursor, None);
9455    }
9456
9457    #[tokio::test]
9458    async fn list_entity_cursor_survives_soft_delete_and_rejects_hard_delete_or_hidden_scope() {
9459        let rt = rt();
9460        let local = NamespaceToken::local();
9461        for index in 0..3 {
9462            rt.create_entity(
9463                &local,
9464                "concept",
9465                None,
9466                &format!("CursorLifecycle{index}"),
9467                None,
9468                None,
9469                vec![],
9470            )
9471            .await
9472            .unwrap();
9473        }
9474
9475        let (_, next) = rt
9476            .list_entities_after(&local, None, None, &[], None, 2)
9477            .await
9478            .unwrap();
9479        let cursor = next.expect("three entities require a second page");
9480        let store = rt.entities(&local).unwrap();
9481        assert!(store.delete_entity(cursor, DeleteMode::Soft).await.unwrap());
9482
9483        let (remaining, next) = rt
9484            .list_entities_after(&local, None, None, &[], Some(cursor), 2)
9485            .await
9486            .expect("a soft-deleted cursor must retain its sequence boundary");
9487        assert_eq!(remaining.len(), 1);
9488        assert_eq!(next, None);
9489
9490        assert!(store.delete_entity(cursor, DeleteMode::Hard).await.unwrap());
9491        let hard_deleted = rt
9492            .list_entities_after(&local, None, None, &[], Some(cursor), 2)
9493            .await;
9494        assert!(
9495            matches!(hard_deleted, Err(RuntimeError::NotFound(_))),
9496            "a hard-deleted cursor must fail explicitly: {hard_deleted:?}"
9497        );
9498
9499        let hidden_ns = Namespace::parse("cursor-hidden").unwrap();
9500        let hidden_token = NamespaceToken::for_namespace(hidden_ns);
9501        let hidden = rt
9502            .create_entity(
9503                &hidden_token,
9504                "concept",
9505                None,
9506                "HiddenCursor",
9507                None,
9508                None,
9509                vec![],
9510            )
9511            .await
9512            .unwrap();
9513        let out_of_scope = rt
9514            .list_entities_after(&local, None, None, &[], Some(hidden.id), 2)
9515            .await;
9516        assert!(
9517            matches!(out_of_scope, Err(RuntimeError::NotFound(_))),
9518            "an out-of-scope cursor must not reveal or resume from the hidden row: {out_of_scope:?}"
9519        );
9520    }
9521
9522    #[tokio::test]
9523    async fn list_edges_after_single_namespace_exact_final_page_has_no_next_after() {
9524        let rt = rt();
9525        let tok = NamespaceToken::local();
9526        let a = rt
9527            .create_entity(&tok, "concept", None, "SingleCursorA", None, None, vec![])
9528            .await
9529            .unwrap();
9530        for i in 0..4 {
9531            let t = rt
9532                .create_entity(
9533                    &tok,
9534                    "concept",
9535                    None,
9536                    &format!("SingleCursorT{i}"),
9537                    None,
9538                    None,
9539                    vec![],
9540                )
9541                .await
9542                .unwrap();
9543            rt.link(&tok, a.id, t.id, EdgeRelation::Extends, 1.0, None)
9544                .await
9545                .unwrap();
9546        }
9547
9548        let filter = EdgeListFilter {
9549            source_id: Some(a.id),
9550            relations: vec![EdgeRelation::Extends],
9551            ..Default::default()
9552        };
9553
9554        let (page1, next1) = rt
9555            .list_edges_after(&tok, filter.clone(), None, 2)
9556            .await
9557            .unwrap();
9558        assert_eq!(page1.len(), 2);
9559        let cursor = next1.expect("first page must report a cursor when two rows remain");
9560
9561        let (page2, next2) = rt
9562            .list_edges_after(&tok, filter, Some(cursor), 2)
9563            .await
9564            .unwrap();
9565        assert_eq!(page2.len(), 2);
9566        assert_eq!(
9567            next2, None,
9568            "an exact-size final single-namespace page must not report a cursor"
9569        );
9570    }
9571
9572    #[tokio::test]
9573    async fn list_edges_after_multi_namespace_exact_final_page_has_no_next_after() {
9574        let rt = rt();
9575        let ns_a = Namespace::parse("cursor-ns-a").unwrap();
9576        let ns_b = Namespace::parse("cursor-ns-b").unwrap();
9577        let tok_a = NamespaceToken::for_namespace(ns_a.clone());
9578        let tok_b = NamespaceToken::for_namespace(ns_b.clone());
9579        let visible = NamespaceToken::mint_with_visibility(ns_a, vec![ns_b], ActorRef::anonymous());
9580
9581        for (tok, prefix) in [(&tok_a, "A"), (&tok_b, "B")] {
9582            let source = rt
9583                .create_entity(
9584                    tok,
9585                    "concept",
9586                    None,
9587                    &format!("MultiCursor{prefix}Source"),
9588                    None,
9589                    None,
9590                    vec![],
9591                )
9592                .await
9593                .unwrap();
9594            for i in 0..2 {
9595                let target = rt
9596                    .create_entity(
9597                        tok,
9598                        "concept",
9599                        None,
9600                        &format!("MultiCursor{prefix}Target{i}"),
9601                        None,
9602                        None,
9603                        vec![],
9604                    )
9605                    .await
9606                    .unwrap();
9607                rt.link(tok, source.id, target.id, EdgeRelation::Extends, 1.0, None)
9608                    .await
9609                    .unwrap();
9610            }
9611        }
9612
9613        let filter = EdgeListFilter {
9614            relations: vec![EdgeRelation::Extends],
9615            ..Default::default()
9616        };
9617        let (page1, next1) = rt
9618            .list_edges_after(&visible, filter.clone(), None, 2)
9619            .await
9620            .unwrap();
9621        assert_eq!(page1.len(), 2);
9622        let cursor = next1.expect("first merged page must report a cursor when rows remain");
9623
9624        let (page2, next2) = rt
9625            .list_edges_after(&visible, filter, Some(cursor), 2)
9626            .await
9627            .unwrap();
9628        assert_eq!(page2.len(), 2);
9629        assert_eq!(
9630            next2, None,
9631            "an exact-size final multi-namespace page must not report a cursor"
9632        );
9633    }
9634
9635    /// `stats()` should be able to report a per-relation breakdown so
9636    /// auditors know the true population per relation before sampling.
9637    #[tokio::test]
9638    async fn count_edges_by_relation_matches_fixtures() {
9639        let rt = rt();
9640        let tok = NamespaceToken::local();
9641        let a = rt
9642            .create_entity(&tok, "concept", None, "A", None, None, vec![])
9643            .await
9644            .unwrap();
9645        let b = rt
9646            .create_entity(&tok, "concept", None, "B", None, None, vec![])
9647            .await
9648            .unwrap();
9649        let c = rt
9650            .create_entity(&tok, "concept", None, "C", None, None, vec![])
9651            .await
9652            .unwrap();
9653
9654        rt.link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
9655            .await
9656            .unwrap();
9657        rt.link(&tok, a.id, c.id, EdgeRelation::Extends, 1.0, None)
9658            .await
9659            .unwrap();
9660        rt.link(&tok, b.id, c.id, EdgeRelation::Enables, 1.0, None)
9661            .await
9662            .unwrap();
9663
9664        let counts = rt.count_edges_by_relation(&tok).await.unwrap();
9665        assert_eq!(counts.get("extends").copied(), Some(2));
9666        assert_eq!(counts.get("enables").copied(), Some(1));
9667    }
9668
9669    #[tokio::test]
9670    async fn delete_edge_removes_from_storage() {
9671        let rt = rt();
9672        let tok = NamespaceToken::local();
9673        let a = rt
9674            .create_entity(&tok, "concept", None, "A", None, None, vec![])
9675            .await
9676            .unwrap();
9677        let b = rt
9678            .create_entity(&tok, "concept", None, "B", None, None, vec![])
9679            .await
9680            .unwrap();
9681        let edge = rt
9682            .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
9683            .await
9684            .unwrap();
9685        let edge_id: Uuid = edge.id.into();
9686
9687        let deleted = rt.delete_edge(&tok, edge_id, true).await.unwrap();
9688        assert!(deleted);
9689
9690        let fetched = rt.get_edge(&tok, edge_id).await.unwrap();
9691        assert!(fetched.is_none(), "edge should be gone after delete");
9692    }
9693
9694    #[tokio::test]
9695    async fn count_edges_matches_filter() {
9696        let rt = rt();
9697        let tok = NamespaceToken::local();
9698        let a = rt
9699            .create_entity(&tok, "concept", None, "A", None, None, vec![])
9700            .await
9701            .unwrap();
9702        let b = rt
9703            .create_entity(&tok, "concept", None, "B", None, None, vec![])
9704            .await
9705            .unwrap();
9706        let c = rt
9707            .create_entity(&tok, "concept", None, "C", None, None, vec![])
9708            .await
9709            .unwrap();
9710
9711        rt.link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
9712            .await
9713            .unwrap();
9714        rt.link(&tok, a.id, c.id, EdgeRelation::Enables, 1.0, None)
9715            .await
9716            .unwrap();
9717
9718        let all = rt
9719            .count_edges(&tok, EdgeListFilter::default())
9720            .await
9721            .unwrap();
9722        assert_eq!(all, 2);
9723
9724        let just_extends = rt
9725            .count_edges(
9726                &tok,
9727                EdgeListFilter {
9728                    relations: vec![EdgeRelation::Extends],
9729                    ..Default::default()
9730                },
9731            )
9732            .await
9733            .unwrap();
9734        assert_eq!(just_extends, 1);
9735    }
9736
9737    // ---- substrate_exists_in_ns must use get_edge_visible ----
9738
9739    /// An edge owned by a visible (non-primary) namespace must be found by
9740    /// `substrate_exists_in_ns` and therefore usable as a graph root in
9741    /// `neighbors` and `traverse`.
9742    #[tokio::test]
9743    async fn edge_in_visible_namespace_reachable_as_graph_root() {
9744        let rt = rt();
9745        let ns_a = Namespace::parse("vis-edge-a").unwrap();
9746        let ns_b = Namespace::parse("vis-edge-b").unwrap();
9747
9748        // Create two entities and an edge in namespace B.
9749        let tok_b = NamespaceToken::for_namespace(ns_b.clone());
9750        let src = rt
9751            .create_entity(&tok_b, "concept", None, "SrcB", None, None, vec![])
9752            .await
9753            .unwrap();
9754        let tgt = rt
9755            .create_entity(&tok_b, "concept", None, "TgtB", None, None, vec![])
9756            .await
9757            .unwrap();
9758        let edge = rt
9759            .link(&tok_b, src.id, tgt.id, EdgeRelation::Extends, 1.0, None)
9760            .await
9761            .unwrap();
9762
9763        // Namespace A with B in its visible set should be able to get the
9764        // edge and use it as a traverse root.
9765        let tok_a_vis = rt
9766            .authorize_with_visibility(ns_a.clone(), vec![ns_b.clone()])
9767            .unwrap();
9768
9769        // Direct get of the edge must succeed (visible namespace).
9770        let got = rt.get_edge_visible(&tok_a_vis, edge.id.0).await.unwrap();
9771        assert!(
9772            got.is_some(),
9773            "edge in visible namespace must be retrievable via get_edge_visible"
9774        );
9775
9776        // neighbors/traverse use substrate_exists_in_ns which now calls
9777        // get_edge_visible — they must not return empty for a visible-ns edge root.
9778        let neighbors = rt
9779            .neighbors(&tok_a_vis, src.id, Direction::Out, Some(16), None)
9780            .await
9781            .unwrap();
9782        assert!(
9783            neighbors.iter().any(|h| h.node_id == tgt.id),
9784            "neighbors of visible-ns node must include its visible-ns neighbor; got: {neighbors:?}"
9785        );
9786    }
9787
9788    #[tokio::test]
9789    async fn neighbors_accepts_foreign_full_uuid_anchor_but_scopes_returned_edges() {
9790        let rt = rt();
9791        let ns_a = Namespace::parse("neighbor-owner").unwrap();
9792        let ns_b = Namespace::parse("neighbor-caller").unwrap();
9793        let tok_a = NamespaceToken::for_namespace(ns_a.clone());
9794        let tok_b = NamespaceToken::for_namespace(ns_b.clone());
9795
9796        let src = rt
9797            .create_entity(&tok_a, "concept", None, "Source", None, None, vec![])
9798            .await
9799            .unwrap();
9800        let tgt = rt
9801            .create_entity(&tok_a, "concept", None, "Target", None, None, vec![])
9802            .await
9803            .unwrap();
9804        let isolated = rt
9805            .create_entity(&tok_a, "concept", None, "Isolated", None, None, vec![])
9806            .await
9807            .unwrap();
9808        let note = rt
9809            .create_note(&tok_a, "observation", None, "Note", None, None, vec![])
9810            .await
9811            .unwrap();
9812        let caller_target = rt
9813            .create_entity(&tok_b, "concept", None, "Caller target", None, None, vec![])
9814            .await
9815            .unwrap();
9816        let edge = rt
9817            .link(&tok_a, src.id, tgt.id, EdgeRelation::Extends, 1.0, None)
9818            .await
9819            .unwrap();
9820        rt.link(
9821            &tok_b,
9822            src.id,
9823            caller_target.id,
9824            EdgeRelation::Extends,
9825            1.0,
9826            None,
9827        )
9828        .await
9829        .unwrap();
9830
9831        let own_hits = rt
9832            .neighbors(&tok_a, src.id, Direction::Out, None, None)
9833            .await
9834            .unwrap();
9835        assert_eq!(own_hits.len(), 1);
9836        assert_eq!(own_hits[0].node_id, tgt.id);
9837
9838        // The full UUID is a by-ID anchor; only returned edges use the read
9839        // scope. The owner edge is invisible, the caller edge is returned.
9840        assert!(rt.get_entity(&tok_b, src.id).await.is_ok());
9841        assert!(rt.get_edge(&tok_b, edge.id.0).await.unwrap().is_some());
9842        let foreign_hits = rt
9843            .neighbors(&tok_b, src.id, Direction::Out, None, None)
9844            .await
9845            .unwrap();
9846        assert_eq!(foreign_hits.len(), 1);
9847        assert_eq!(foreign_hits[0].node_id, caller_target.id);
9848        for anchor in [note.id, edge.id.0, isolated.id] {
9849            assert!(
9850                rt.neighbors(&tok_b, anchor, Direction::Out, None, None)
9851                    .await
9852                    .unwrap()
9853                    .is_empty(),
9854                "live foreign anchor without a caller-visible edge must be empty"
9855            );
9856        }
9857        let missing = Uuid::new_v4();
9858        assert!(matches!(
9859            rt.neighbors(&tok_b, missing, Direction::Out, None, None)
9860                .await,
9861            Err(RuntimeError::NotFound(message)) if message.contains(&missing.to_string())
9862        ));
9863
9864        let page = rt
9865            .neighbors_with_query_page(
9866                &tok_b,
9867                src.id,
9868                NeighborQuery {
9869                    direction: Direction::Out,
9870                    relations: None,
9871                    limit: Some(1),
9872                    min_weight: None,
9873                },
9874                None,
9875                None,
9876                true,
9877            )
9878            .await;
9879        let page = page.unwrap();
9880        assert_eq!(page.len(), 1);
9881        assert_eq!(page[0].node_id, caller_target.id);
9882
9883        let visible_from_b = rt.authorize_with_visibility(ns_b, vec![ns_a]).unwrap();
9884        let shared_hits = rt
9885            .neighbors(&visible_from_b, src.id, Direction::Out, None, None)
9886            .await
9887            .unwrap();
9888        assert_eq!(shared_hits.len(), 2);
9889        assert!(shared_hits.iter().any(|hit| hit.node_id == tgt.id));
9890        assert!(shared_hits
9891            .iter()
9892            .any(|hit| hit.node_id == caller_target.id));
9893
9894        let isolated_hits = rt
9895            .neighbors(&tok_a, isolated.id, Direction::Out, None, None)
9896            .await
9897            .unwrap();
9898        assert!(isolated_hits.is_empty());
9899    }
9900
9901    // By-ID ops do not enforce namespace isolation. Shared-brain OSS model:
9902    // UUID is globally unique; get/update/delete find the record regardless
9903    // of caller's token namespace.
9904    #[tokio::test]
9905    async fn get_entity_cross_namespace_no_longer_denied() {
9906        let rt = rt();
9907        let ns_a = NamespaceToken::for_namespace(Namespace::parse("ns-a").unwrap());
9908        let ns_b = NamespaceToken::for_namespace(Namespace::parse("ns-b").unwrap());
9909        let entity = rt
9910            .create_entity(&ns_a, "concept", None, "Alpha", None, None, vec![])
9911            .await
9912            .unwrap();
9913
9914        // Same namespace: still works.
9915        let found = rt.get_entity(&ns_a, entity.id).await;
9916        assert!(found.is_ok(), "same-namespace get must succeed");
9917
9918        // Different namespace: now also returns the entity (shared brain).
9919        let cross = rt.get_entity(&ns_b, entity.id).await;
9920        assert!(
9921            cross.is_ok(),
9922            "cross-namespace get must succeed in shared-brain OSS (ADR-007 rule 2)"
9923        );
9924        assert_eq!(cross.unwrap().id, entity.id);
9925    }
9926
9927    #[tokio::test]
9928    async fn delete_entity_cross_namespace_no_longer_denied() {
9929        let rt = rt();
9930        let ns_a = NamespaceToken::for_namespace(Namespace::parse("ns-a").unwrap());
9931        let ns_b = NamespaceToken::for_namespace(Namespace::parse("ns-b").unwrap());
9932        let entity = rt
9933            .create_entity(&ns_a, "concept", None, "Beta", None, None, vec![])
9934            .await
9935            .unwrap();
9936
9937        // Cross-namespace delete now succeeds (shared brain).
9938        let cross_ns_result = rt.delete_entity(&ns_b, entity.id, true).await;
9939        assert!(
9940            cross_ns_result.is_ok(),
9941            "cross-namespace delete must succeed in shared-brain OSS; got {:?}",
9942            cross_ns_result
9943        );
9944        assert!(cross_ns_result.unwrap(), "delete must return true");
9945
9946        // Entity is gone — even from the original namespace.
9947        let gone = rt.get_entity(&ns_a, entity.id).await;
9948        assert!(gone.is_err(), "entity must be gone after delete");
9949    }
9950
9951    // ---- Note annotation tests ----
9952
9953    #[tokio::test]
9954    async fn create_note_indexes_into_fts5() {
9955        let rt = rt();
9956        let tok = NamespaceToken::local();
9957        let note = rt
9958            .create_note(
9959                &tok,
9960                "observation",
9961                None,
9962                "FlashAttention reduces memory by using tiling",
9963                Some(0.8),
9964                None,
9965                vec![],
9966            )
9967            .await
9968            .unwrap();
9969
9970        // FTS5 should have indexed the note content.
9971        let ns = tok.namespace().as_str().to_string();
9972        let hits = rt
9973            .text_for_notes(&tok)
9974            .unwrap()
9975            .search(khive_storage::types::TextSearchRequest {
9976                query: "FlashAttention".to_string(),
9977                mode: khive_storage::types::TextQueryMode::Plain,
9978                filter: Some(khive_storage::types::TextFilter {
9979                    namespaces: vec![ns],
9980                    ..Default::default()
9981                }),
9982                top_k: 10,
9983                snippet_chars: 100,
9984            })
9985            .await
9986            .unwrap();
9987
9988        assert!(
9989            hits.iter().any(|h| h.subject_id == note.id),
9990            "note should be indexed in FTS5 after create"
9991        );
9992    }
9993
9994    /// #916: `@` used to reach SQLite FTS5's bareword parser raw and error
9995    /// (`sanitize_fts5_query` did not strip it), surfacing as
9996    /// `RuntimeError::InvalidInput` per #569's fail-loud policy.
9997    /// `sanitize_fts5_token_group`'s bareword-safety gate now routes it
9998    /// through the quoted-phrase alternative instead, so `search_notes`
9999    /// succeeds and finds the seeded content.
10000    #[tokio::test]
10001    async fn search_notes_with_residual_fts5_char_now_sanitized() {
10002        let rt = rt();
10003        let tok = NamespaceToken::local();
10004        rt.create_note(
10005            &tok,
10006            "observation",
10007            None,
10008            "use foo@bar to chain calls",
10009            Some(0.5),
10010            None,
10011            vec![],
10012        )
10013        .await
10014        .unwrap();
10015
10016        let result = rt
10017            .search_notes(&tok, "foo@bar", None, 10, None, false, &[], None)
10018            .await;
10019
10020        let hits = result.unwrap_or_else(|e| {
10021            panic!("#916 search_notes must not fail on an '@'-bearing query, got: {e:?}")
10022        });
10023        assert!(
10024            !hits.is_empty(),
10025            "#916 '@'-bearing query must still find the seeded 'foo@bar' content via the \
10026             quoted-phrase alternative"
10027        );
10028    }
10029
10030    /// The `search_notes` FTS fail-open arm must only degrade genuine FTS5
10031    /// parser syntax errors. A non-parser `StorageError`: e.g. a pool
10032    /// exhaustion or connection timeout on the text-search backend — is not a
10033    /// bad query and must propagate as `Err`, not be silently swallowed into
10034    /// an empty (falsely "successful") result set. `search_notes`,
10035    /// `hybrid_search`, `hybrid_search_with_strategy`, and
10036    /// `collect_recall_text_hits` all share the same `is_fts5_syntax_error()`
10037    /// gate on `StorageError`, so this case generalizes to all four call sites.
10038    #[tokio::test]
10039    async fn search_notes_propagates_non_parser_fts_error() {
10040        let rt = rt();
10041        let tok = NamespaceToken::local();
10042        rt.create_note(
10043            &tok,
10044            "observation",
10045            None,
10046            "FlashAttention reduces memory by using tiling",
10047            Some(0.8),
10048            None,
10049            vec![],
10050        )
10051        .await
10052        .unwrap();
10053
10054        let ns = tok.namespace().as_str().to_string();
10055        arm_fts_search_fail(&ns);
10056
10057        let result = rt
10058            .search_notes(&tok, "FlashAttention", None, 10, None, false, &[], None)
10059            .await;
10060
10061        assert!(
10062            result.is_err(),
10063            "search_notes must propagate a non-parser FTS StorageError (Timeout) \
10064             as Err, not silently degrade it to an empty result, got: {:?}",
10065            result.ok()
10066        );
10067        assert!(
10068            matches!(
10069                result.unwrap_err(),
10070                RuntimeError::Storage(khive_storage::StorageError::Timeout { .. })
10071            ),
10072            "propagated error must be the injected StorageError::Timeout, unwrapped \
10073             through RuntimeError::Storage"
10074        );
10075    }
10076
10077    #[tokio::test]
10078    async fn create_note_with_properties() {
10079        let rt = rt();
10080        let tok = NamespaceToken::local();
10081        let props = serde_json::json!({"source": "arxiv:2205.14135"});
10082        let note = rt
10083            .create_note(
10084                &tok,
10085                "insight",
10086                None,
10087                "FlashAttention is IO-aware",
10088                Some(0.9),
10089                Some(props.clone()),
10090                vec![],
10091            )
10092            .await
10093            .unwrap();
10094
10095        assert_eq!(note.properties.as_ref().unwrap(), &props);
10096    }
10097
10098    #[tokio::test]
10099    async fn create_note_creates_annotates_edges() {
10100        let rt = rt();
10101        let tok = NamespaceToken::local();
10102        let entity = rt
10103            .create_entity(&tok, "concept", None, "FlashAttention", None, None, vec![])
10104            .await
10105            .unwrap();
10106
10107        let note = rt
10108            .create_note(
10109                &tok,
10110                "observation",
10111                None,
10112                "FlashAttention uses SRAM tiling for memory efficiency",
10113                Some(0.9),
10114                None,
10115                vec![entity.id],
10116            )
10117            .await
10118            .unwrap();
10119
10120        // The note should have an outbound `annotates` edge to the entity.
10121        let out_neighbors = rt
10122            .neighbors(
10123                &tok,
10124                note.id,
10125                Direction::Out,
10126                None,
10127                Some(vec![EdgeRelation::Annotates]),
10128            )
10129            .await
10130            .unwrap();
10131        assert_eq!(out_neighbors.len(), 1);
10132        assert_eq!(out_neighbors[0].node_id, entity.id);
10133        assert_eq!(out_neighbors[0].relation, EdgeRelation::Annotates);
10134
10135        // The entity should have an inbound `annotates` edge from the note.
10136        let in_neighbors = rt
10137            .neighbors(
10138                &tok,
10139                entity.id,
10140                Direction::In,
10141                None,
10142                Some(vec![EdgeRelation::Annotates]),
10143            )
10144            .await
10145            .unwrap();
10146        assert_eq!(in_neighbors.len(), 1);
10147        assert_eq!(in_neighbors[0].node_id, note.id);
10148    }
10149
10150    #[tokio::test]
10151    async fn neighbors_without_relation_filter_returns_all() {
10152        let rt = rt();
10153        let tok = NamespaceToken::local();
10154        let a = rt
10155            .create_entity(&tok, "concept", None, "A", None, None, vec![])
10156            .await
10157            .unwrap();
10158        let b = rt
10159            .create_entity(&tok, "concept", None, "B", None, None, vec![])
10160            .await
10161            .unwrap();
10162        let c = rt
10163            .create_entity(&tok, "concept", None, "C", None, None, vec![])
10164            .await
10165            .unwrap();
10166
10167        rt.link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
10168            .await
10169            .unwrap();
10170        rt.link(&tok, a.id, c.id, EdgeRelation::Enables, 1.0, None)
10171            .await
10172            .unwrap();
10173
10174        let all = rt
10175            .neighbors(&tok, a.id, Direction::Out, None, None)
10176            .await
10177            .unwrap();
10178        assert_eq!(all.len(), 2);
10179    }
10180
10181    #[tokio::test]
10182    async fn neighbors_with_relation_filter_returns_subset() {
10183        let rt = rt();
10184        let tok = NamespaceToken::local();
10185        let a = rt
10186            .create_entity(&tok, "concept", None, "A", None, None, vec![])
10187            .await
10188            .unwrap();
10189        let b = rt
10190            .create_entity(&tok, "concept", None, "B", None, None, vec![])
10191            .await
10192            .unwrap();
10193        let c = rt
10194            .create_entity(&tok, "concept", None, "C", None, None, vec![])
10195            .await
10196            .unwrap();
10197
10198        rt.link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
10199            .await
10200            .unwrap();
10201        rt.link(&tok, a.id, c.id, EdgeRelation::Enables, 1.0, None)
10202            .await
10203            .unwrap();
10204
10205        let filtered = rt
10206            .neighbors(
10207                &tok,
10208                a.id,
10209                Direction::Out,
10210                None,
10211                Some(vec![EdgeRelation::Extends]),
10212            )
10213            .await
10214            .unwrap();
10215        assert_eq!(filtered.len(), 1);
10216        assert_eq!(filtered[0].node_id, b.id);
10217        assert_eq!(filtered[0].relation, EdgeRelation::Extends);
10218    }
10219
10220    /// Self-loop direction parity:
10221    /// `neighbors_with_query_directed`'s post-merge dedup must not collapse a
10222    /// self-loop edge's Out row and In row into one — they share `(node_id,
10223    /// edge_id)` but are opposite directions, matching what a separate `Out`
10224    /// call plus a separate `In` call would return for the same edge. The
10225    /// self-loop edge is inserted directly through the graph store (`link()`
10226    /// rejects source_id == target_id) to exercise the merge/dedup path.
10227    #[tokio::test]
10228    async fn neighbors_with_query_directed_preserves_self_loop_direction_parity() {
10229        let rt = rt();
10230        let tok = NamespaceToken::local();
10231        let centre = rt
10232            .create_entity(&tok, "concept", None, "Centre", None, None, vec![])
10233            .await
10234            .unwrap();
10235
10236        let now = chrono::Utc::now();
10237        rt.graph(&tok)
10238            .unwrap()
10239            .upsert_edge(Edge {
10240                id: LinkId::from(Uuid::new_v4()),
10241                namespace: "local".to_string(),
10242                source_id: centre.id,
10243                target_id: centre.id,
10244                relation: EdgeRelation::Extends,
10245                weight: 0.7,
10246                created_at: now,
10247                updated_at: now,
10248                deleted_at: None,
10249                metadata: None,
10250                target_backend: None,
10251            })
10252            .await
10253            .unwrap();
10254
10255        let directed = rt
10256            .neighbors_with_query_directed(
10257                &tok,
10258                centre.id,
10259                NeighborQuery {
10260                    direction: Direction::Both,
10261                    relations: None,
10262                    limit: None,
10263                    min_weight: None,
10264                },
10265            )
10266            .await
10267            .unwrap();
10268
10269        assert_eq!(
10270            directed.len(),
10271            2,
10272            "a self-loop edge must produce both an Out hit and an In hit, not one collapsed hit"
10273        );
10274        let directions: Vec<Direction> = directed.iter().map(|(_, d)| d.clone()).collect();
10275        assert!(
10276            directions.contains(&Direction::Out),
10277            "self-loop must retain its Out-tagged hit"
10278        );
10279        assert!(
10280            directions.contains(&Direction::In),
10281            "self-loop must retain its In-tagged hit"
10282        );
10283    }
10284
10285    #[tokio::test]
10286    async fn search_notes_returns_relevant_note() {
10287        let rt = rt();
10288        let tok = NamespaceToken::local();
10289        rt.create_note(
10290            &tok,
10291            "observation",
10292            None,
10293            "GQA reduces KV cache memory for large models",
10294            Some(0.8),
10295            None,
10296            vec![],
10297        )
10298        .await
10299        .unwrap();
10300
10301        let results = rt
10302            .search_notes(&tok, "GQA KV cache", None, 10, None, false, &[], None)
10303            .await
10304            .unwrap();
10305
10306        assert!(!results.is_empty(), "search should return the indexed note");
10307        let hit = &results[0];
10308        assert!(
10309            hit.title.is_some(),
10310            "note hit title should be populated (falls back to content)"
10311        );
10312        assert!(
10313            hit.snippet.is_some(),
10314            "note hit snippet should be populated"
10315        );
10316    }
10317
10318    #[tokio::test]
10319    async fn search_notes_excludes_soft_deleted() {
10320        let rt = rt();
10321        let tok = NamespaceToken::local();
10322        let note = rt
10323            .create_note(
10324                &tok,
10325                "observation",
10326                None,
10327                "RoPE positional encoding rotary embeddings",
10328                Some(0.7),
10329                None,
10330                vec![],
10331            )
10332            .await
10333            .unwrap();
10334
10335        // Soft-delete the note.
10336        rt.notes(&tok)
10337            .unwrap()
10338            .delete_note(note.id, DeleteMode::Soft)
10339            .await
10340            .unwrap();
10341
10342        let results = rt
10343            .search_notes(
10344                &tok,
10345                "RoPE rotary positional",
10346                None,
10347                10,
10348                None,
10349                false,
10350                &[],
10351                None,
10352            )
10353            .await
10354            .unwrap();
10355
10356        assert!(
10357            results.iter().all(|h| h.note_id != note.id),
10358            "soft-deleted note should be excluded from search"
10359        );
10360    }
10361
10362    // ---- predicate pushdown before truncation (note branch) ----
10363
10364    /// Regression: notes store tags inside `properties["tags"]`: there is no
10365    /// separate tags column. Without pushdown, the tag filter is applied after
10366    /// `hits.truncate(limit)`, so a tag-matching note ranked beyond `limit` in
10367    /// the raw RRF fusion is silently dropped.
10368    ///
10369    /// Scenario: `limit=1`, tags_any=["note-target-tag"]. Two notes are inserted:
10370    ///   - decoy: high FTS rank (repeats query terms), NO target tag.
10371    ///   - target: lower FTS rank, HAS "note-target-tag" in `properties["tags"]`.
10372    ///
10373    /// Without pushdown: decoy occupies the slot, target is dropped.
10374    /// With pushdown: decoy is excluded in the alive-note loop, target survives, returned.
10375    #[tokio::test]
10376    async fn search_notes_tag_filter_pushed_before_truncation() {
10377        let rt = rt();
10378        let tok = NamespaceToken::local();
10379
10380        // Decoy note: repeats query tokens → higher FTS rank. No target tag.
10381        rt.create_note(
10382            &tok,
10383            "observation",
10384            None,
10385            "kappa lambda mu note decoy kappa lambda mu note decoy kappa lambda mu",
10386            Some(0.5),
10387            Some(serde_json::json!({"tags": ["other-note-tag"]})),
10388            vec![],
10389        )
10390        .await
10391        .unwrap();
10392
10393        // Target note: fewer query tokens → lower FTS rank. Has the target tag.
10394        let target = rt
10395            .create_note(
10396                &tok,
10397                "observation",
10398                None,
10399                "kappa lambda mu note target",
10400                Some(0.5),
10401                Some(serde_json::json!({"tags": ["note-target-tag"]})),
10402                vec![],
10403            )
10404            .await
10405            .unwrap();
10406
10407        // With limit=1 and tags_any, the fix must return the target note despite the
10408        // decoy ranking higher in raw FTS.
10409        let hits = rt
10410            .search_notes(
10411                &tok,
10412                "kappa lambda mu note",
10413                None,
10414                1,
10415                None,
10416                false,
10417                &["note-target-tag".to_string()],
10418                None,
10419            )
10420            .await
10421            .unwrap();
10422
10423        assert_eq!(
10424            hits.len(),
10425            1,
10426            "exactly one hit expected (tag-matching note)"
10427        );
10428        assert_eq!(
10429            hits[0].note_id, target.id,
10430            "tag-filtered note must be returned even when ranked below limit in raw fusion"
10431        );
10432    }
10433
10434    /// Regression: the text arm fetched the top `candidates` rows across EVERY note kind
10435    /// and applied `note_kind` post-fetch. On a store where many short rows of another
10436    /// kind carry the query literal, they fill the window under BM25's length
10437    /// normalisation and the caller sees one or two task hits while the store holds
10438    /// every task carrying the literal.
10439    ///
10440    /// Scenario: `limit=8`, `note_kind="task"`. Forty short observation decoys carry
10441    /// the literal; six task notes carry it inside a longer description. All six tasks
10442    /// must come back.
10443    #[tokio::test]
10444    async fn search_notes_kind_filter_pushed_into_text_arm() {
10445        let rt = rt();
10446        let tok = NamespaceToken::local();
10447
10448        for i in 0..40 {
10449            rt.create_note(
10450                &tok,
10451                "observation",
10452                None,
10453                &format!("ref needle-93 decoy {i}"),
10454                Some(0.5),
10455                None,
10456                vec![],
10457            )
10458            .await
10459            .unwrap();
10460        }
10461
10462        let mut targets = Vec::new();
10463        for i in 0..6 {
10464            let target = rt
10465                .create_note(
10466                    &tok,
10467                    "task",
10468                    Some(&format!("task {i} tracking the review")),
10469                    &format!(
10470                        "Long description number {i}: the work item needle-93 waits on the \
10471                         build, the test report, the release, and the follow-up sweep of every \
10472                         row that cited it; none of that shortens the row."
10473                    ),
10474                    Some(0.5),
10475                    None,
10476                    vec![],
10477                )
10478                .await
10479                .unwrap();
10480            targets.push(target.id);
10481        }
10482
10483        let hits = rt
10484            .search_notes(&tok, "needle-93", None, 8, Some("task"), false, &[], None)
10485            .await
10486            .unwrap();
10487
10488        let mut got: Vec<Uuid> = hits.iter().map(|h| h.note_id).collect();
10489        got.sort();
10490        targets.sort();
10491        assert_eq!(
10492            got,
10493            targets,
10494            "every task carrying the literal must be returned when the caller asks for tasks; \
10495             got {} hit(s)",
10496            hits.len()
10497        );
10498    }
10499
10500    /// Regression: without pushdown, the properties filter is applied after truncation; a matching
10501    /// note ranked beyond `limit` is silently dropped.
10502    ///
10503    /// Scenario: `limit=1`, properties_filter={{"source": "target"}}. Two notes:
10504    ///   - decoy: high FTS rank, properties {{"source": "other"}}.
10505    ///   - target: lower FTS rank, properties {{"source": "target"}}.
10506    #[tokio::test]
10507    async fn search_notes_props_filter_pushed_before_truncation() {
10508        let rt = rt();
10509        let tok = NamespaceToken::local();
10510
10511        rt.create_note(
10512            &tok,
10513            "observation",
10514            None,
10515            "nu xi omicron note decoy nu xi omicron note decoy nu xi omicron",
10516            Some(0.5),
10517            Some(serde_json::json!({"source": "other"})),
10518            vec![],
10519        )
10520        .await
10521        .unwrap();
10522
10523        let target = rt
10524            .create_note(
10525                &tok,
10526                "observation",
10527                None,
10528                "nu xi omicron note target",
10529                Some(0.5),
10530                Some(serde_json::json!({"source": "target"})),
10531                vec![],
10532            )
10533            .await
10534            .unwrap();
10535
10536        let filter = serde_json::json!({"source": "target"});
10537        let hits = rt
10538            .search_notes(
10539                &tok,
10540                "nu xi omicron note",
10541                None,
10542                1,
10543                None,
10544                false,
10545                &[],
10546                Some(&filter),
10547            )
10548            .await
10549            .unwrap();
10550
10551        assert_eq!(
10552            hits.len(),
10553            1,
10554            "exactly one hit expected (properties-matching note)"
10555        );
10556        assert_eq!(
10557            hits[0].note_id, target.id,
10558            "properties-filtered note must be returned even when ranked below limit"
10559        );
10560    }
10561
10562    #[tokio::test]
10563    async fn resolve_returns_entity() {
10564        let rt = rt();
10565        let tok = NamespaceToken::local();
10566        let entity = rt
10567            .create_entity(&tok, "concept", None, "LoRA", None, None, vec![])
10568            .await
10569            .unwrap();
10570
10571        let resolved = rt.resolve(&tok, entity.id).await.unwrap();
10572        match resolved {
10573            Some(Resolved::Entity(e)) => assert_eq!(e.id, entity.id),
10574            other => panic!("expected Resolved::Entity, got {:?}", other),
10575        }
10576    }
10577
10578    #[tokio::test]
10579    async fn resolve_returns_note() {
10580        let rt = rt();
10581        let tok = NamespaceToken::local();
10582        let note = rt
10583            .create_note(
10584                &tok,
10585                "observation",
10586                None,
10587                "LoRA fine-tunes LLMs with low-rank adapters",
10588                Some(0.85),
10589                None,
10590                vec![],
10591            )
10592            .await
10593            .unwrap();
10594
10595        let resolved = rt.resolve(&tok, note.id).await.unwrap();
10596        match resolved {
10597            Some(Resolved::Note(n)) => assert_eq!(n.id, note.id),
10598            other => panic!("expected Resolved::Note, got {:?}", other),
10599        }
10600    }
10601
10602    #[tokio::test]
10603    async fn resolve_returns_none_for_unknown_uuid() {
10604        let rt = rt();
10605        let tok = NamespaceToken::local();
10606        let unknown = Uuid::new_v4();
10607        let resolved = rt.resolve(&tok, unknown).await.unwrap();
10608        assert!(resolved.is_none(), "unknown UUID should resolve to None");
10609    }
10610
10611    #[tokio::test]
10612    async fn resolve_prefix_finds_entity_in_own_namespace() {
10613        let rt = rt();
10614        let tok = NamespaceToken::local();
10615        let entity = rt
10616            .create_entity(&tok, "concept", None, "PrefixTest", None, None, vec![])
10617            .await
10618            .unwrap();
10619        let prefix = &entity.id.to_string()[..8];
10620
10621        let resolved = rt.resolve_prefix(&tok, prefix).await.unwrap();
10622        assert_eq!(resolved, Some(entity.id));
10623    }
10624
10625    #[test]
10626    fn hex_prefix_to_uuid_pattern_inserts_hyphens_at_canonical_boundaries() {
10627        let full = "aabbccdd112240008000000000000ab1";
10628        let cases: &[(usize, &str)] = &[
10629            (1, "a"),
10630            (7, "aabbccd"),
10631            (8, "aabbccdd"),
10632            (9, "aabbccdd-1"),
10633            (12, "aabbccdd-1122"),
10634            (13, "aabbccdd-1122-4"),
10635            (16, "aabbccdd-1122-4000"),
10636            (18, "aabbccdd-1122-4000-80"),
10637            (20, "aabbccdd-1122-4000-8000"),
10638            (23, "aabbccdd-1122-4000-8000-000"),
10639            (24, "aabbccdd-1122-4000-8000-0000"),
10640            (28, "aabbccdd-1122-4000-8000-00000000"),
10641            (31, "aabbccdd-1122-4000-8000-000000000ab"),
10642        ];
10643        for (len, expected) in cases {
10644            let input = &full[..*len];
10645            assert_eq!(
10646                hex_prefix_to_uuid_pattern(input),
10647                *expected,
10648                "len={len} input={input:?}"
10649            );
10650        }
10651    }
10652
10653    #[test]
10654    fn hex_prefix_to_uuid_pattern_full_32_char_matches_canonical_uuid() {
10655        let compact = "aabbccdd112240008000000000000ab1";
10656        let compact32 = &compact[..32];
10657        assert_eq!(
10658            hex_prefix_to_uuid_pattern(compact32),
10659            "aabbccdd-1122-4000-8000-000000000ab1"
10660        );
10661    }
10662
10663    /// Input longer than 32 hex chars is NOT truncated: the extra chars land
10664    /// past the canonical 12-char final
10665    /// segment with no further hyphen, so the pattern can never match a real
10666    /// (36-char) stored `id`, instead of silently truncating down to a
10667    /// pattern that matches the valid 32-char UUID.
10668    #[test]
10669    fn hex_prefix_to_uuid_pattern_overlong_input_is_not_truncated() {
10670        let compact32 = "aabbccdd112240008000000000000ab1";
10671        let overlong = format!("{compact32}extrahex");
10672        let pattern = hex_prefix_to_uuid_pattern(&overlong);
10673        assert_eq!(
10674            pattern, "aabbccdd-1122-4000-8000-000000000ab1extrahex",
10675            "overlong input must keep its extra chars, not truncate to the valid UUID"
10676        );
10677        assert_ne!(
10678            pattern, "aabbccdd-1122-4000-8000-000000000ab1",
10679            "overlong pattern must not collapse to the canonical 36-char UUID form"
10680        );
10681    }
10682
10683    #[test]
10684    fn hex_prefix_to_uuid_pattern_passes_through_hyphenated_input() {
10685        let hyphenated = "aabbccdd-1122-4000-8000-000000000ab1";
10686        assert_eq!(hex_prefix_to_uuid_pattern(hyphenated), hyphenated);
10687
10688        let partial = "aabbccdd-11";
10689        assert_eq!(hex_prefix_to_uuid_pattern(partial), partial);
10690    }
10691
10692    #[test]
10693    fn uuid_prefix_bounds_are_canonical_lowercase_half_open_ranges() {
10694        assert_eq!(
10695            super::uuid_prefix_bounds("0ABCDEFF"),
10696            Some(("0abcdeff".into(), "0abcdf".into()))
10697        );
10698        assert_eq!(
10699            super::uuid_prefix_bounds("aabbccdd1"),
10700            Some(("aabbccdd-1".into(), "aabbccdd-2".into()))
10701        );
10702        assert_eq!(
10703            super::uuid_prefix_bounds("AABBCCDD-19FF"),
10704            Some(("aabbccdd-19ff".into(), "aabbccdd-1a".into()))
10705        );
10706        assert_eq!(
10707            super::uuid_prefix_bounds("ffffffff"),
10708            Some(("ffffffff".into(), "g".into()))
10709        );
10710    }
10711
10712    #[test]
10713    fn uuid_prefix_bounds_reject_malformed_or_overlong_input() {
10714        for invalid in [
10715            "",
10716            "aabbccdd_",
10717            "aabbccdd1-",
10718            "aabbccdd-11111",
10719            "aabbccdd--",
10720            "aabbccdd112240008000000000000ab1f",
10721        ] {
10722            assert_eq!(
10723                super::uuid_prefix_bounds(invalid),
10724                None,
10725                "invalid prefix {invalid:?} must fail closed"
10726            );
10727        }
10728    }
10729
10730    #[tokio::test]
10731    async fn resolve_prefix_compact_9_to_31_char_matches() {
10732        let rt = rt();
10733        let tok = NamespaceToken::local();
10734        let entity = rt
10735            .create_entity(
10736                &tok,
10737                "concept",
10738                None,
10739                "CompactPrefixTest",
10740                None,
10741                None,
10742                vec![],
10743            )
10744            .await
10745            .unwrap();
10746        let compact = entity.id.simple().to_string();
10747
10748        for len in [9, 12, 16, 20, 24, 28, 31] {
10749            let prefix = &compact[..len];
10750            let resolved = rt.resolve_prefix(&tok, prefix).await.unwrap();
10751            assert_eq!(
10752                resolved,
10753                Some(entity.id),
10754                "compact prefix of len {len} should resolve"
10755            );
10756        }
10757    }
10758
10759    #[tokio::test]
10760    async fn resolve_prefix_compact_full_32_char_matches() {
10761        let rt = rt();
10762        let tok = NamespaceToken::local();
10763        let entity = rt
10764            .create_entity(&tok, "concept", None, "Full32Test", None, None, vec![])
10765            .await
10766            .unwrap();
10767        let compact = entity.id.simple().to_string();
10768        assert_eq!(compact.len(), 32);
10769
10770        let resolved = rt.resolve_prefix(&tok, &compact).await.unwrap();
10771        assert_eq!(resolved, Some(entity.id));
10772    }
10773
10774    /// A valid 32-char compact id with extra trailing hex chars appended must
10775    /// fail to resolve, not silently resolve to the valid entity via truncation.
10776    #[tokio::test]
10777    async fn resolve_prefix_rejects_overlong_all_hex_input() {
10778        let rt = rt();
10779        let tok = NamespaceToken::local();
10780        let entity = rt
10781            .create_entity(&tok, "concept", None, "OverlongTest", None, None, vec![])
10782            .await
10783            .unwrap();
10784        let compact = entity.id.simple().to_string();
10785        assert_eq!(compact.len(), 32);
10786
10787        let overlong = format!("{compact}ab");
10788        let resolved = rt.resolve_prefix(&tok, &overlong).await.unwrap();
10789        assert_eq!(
10790            resolved, None,
10791            "a 32-char id plus extra hex chars must not resolve to the valid entity"
10792        );
10793    }
10794
10795    /// The `resolve_prefix*` boundary rejects
10796    /// non-hex/non-hyphen input (e.g. LIKE wildcards `%`/`_`) instead of
10797    /// letting it reach the bound `LIKE` pattern unfiltered — covers callers
10798    /// (like khive-pack-git/src/ingest.rs) that resolve raw input without
10799    /// their own all-hex gate.
10800    #[tokio::test]
10801    async fn resolve_prefix_rejects_like_wildcard_input() {
10802        let rt = rt();
10803        let tok = NamespaceToken::local();
10804        let entity = rt
10805            .create_entity(&tok, "concept", None, "WildcardTest", None, None, vec![])
10806            .await
10807            .unwrap();
10808        let compact = entity.id.simple().to_string();
10809        // A caller that forgot to hex-gate might pass a `%`-bearing string
10810        // straight through, hoping to broaden a scan; the resolver boundary
10811        // must reject it instead of running it as a wildcard LIKE.
10812        let wildcard_prefix = format!("{}%", &compact[..8]);
10813
10814        let resolved = rt.resolve_prefix(&tok, &wildcard_prefix).await.unwrap();
10815        assert_eq!(
10816            resolved, None,
10817            "prefix containing a LIKE wildcard must be rejected, not resolved"
10818        );
10819    }
10820
10821    #[tokio::test]
10822    async fn resolve_prefix_boundary_at_hyphen_positions() {
10823        let rt = rt();
10824        let tok = NamespaceToken::local();
10825        let entity = rt
10826            .create_entity(&tok, "concept", None, "BoundaryTest", None, None, vec![])
10827            .await
10828            .unwrap();
10829        let compact = entity.id.simple().to_string();
10830
10831        for len in [8, 12, 16, 20, 24] {
10832            let prefix = &compact[..len];
10833            let resolved = rt.resolve_prefix(&tok, prefix).await.unwrap();
10834            assert_eq!(
10835                resolved,
10836                Some(entity.id),
10837                "boundary prefix of len {len} should resolve"
10838            );
10839        }
10840    }
10841
10842    #[tokio::test]
10843    async fn resolve_prefix_range_preserves_boundaries_ambiguity_and_not_found() {
10844        use khive_storage::entity::Entity;
10845
10846        let rt = rt();
10847        let tok = NamespaceToken::local();
10848        let ids = [
10849            "0abcdefe-ffff-4fff-8fff-ffffffffffff",
10850            "0abcdeff-0000-4000-8000-000000000001",
10851            "0abcdeff-1000-4000-8000-000000000002",
10852            "0abcdf00-0000-4000-8000-000000000003",
10853        ];
10854        let store = rt.entities(&tok).unwrap();
10855        for (index, id) in ids.into_iter().enumerate() {
10856            let mut entity = Entity::new("local", "concept", format!("RangeControl{index}"));
10857            entity.id = Uuid::parse_str(id).unwrap();
10858            store.upsert_entity(entity).await.unwrap();
10859        }
10860
10861        let unique = rt.resolve_prefix(&tok, "0abcdeff0").await.unwrap();
10862        assert_eq!(unique, Some(Uuid::parse_str(ids[1]).unwrap()));
10863
10864        let ambiguous = rt.resolve_prefix(&tok, "0abcdeff").await.unwrap_err();
10865        assert!(
10866            matches!(
10867                ambiguous,
10868                RuntimeError::AmbiguousPrefix { ref matches, .. } if matches.len() == 2
10869            ),
10870            "the two in-range UUIDs must remain ambiguous: {ambiguous:?}"
10871        );
10872
10873        let missing = rt.resolve_prefix(&tok, "0abcdeff2").await.unwrap();
10874        assert_eq!(missing, None, "adjacent UUIDs must not become prefix hits");
10875    }
10876
10877    #[tokio::test]
10878    async fn resolve_prefix_ambiguous_still_detected_after_normalization() {
10879        use khive_storage::entity::Entity;
10880
10881        let rt = rt();
10882        let tok = NamespaceToken::local();
10883        let id_a = Uuid::parse_str("aabbccdd-1111-4000-8000-000000000001").unwrap();
10884        let id_b = Uuid::parse_str("aabbccdd-1111-4000-8000-000000000002").unwrap();
10885
10886        let mut entity_a = Entity::new("local", "concept", "AmbigCompactA");
10887        entity_a.id = id_a;
10888        let mut entity_b = Entity::new("local", "concept", "AmbigCompactB");
10889        entity_b.id = id_b;
10890
10891        let store = rt.entities(&tok).unwrap();
10892        store.upsert_entity(entity_a).await.unwrap();
10893        store.upsert_entity(entity_b).await.unwrap();
10894
10895        // Shared 20-char compact prefix (past the first hyphen boundary).
10896        let shared_compact = &id_a.simple().to_string()[..20];
10897        let err = rt.resolve_prefix(&tok, shared_compact).await.unwrap_err();
10898        assert!(
10899            matches!(
10900                err,
10901                RuntimeError::AmbiguousPrefix { ref matches, .. } if matches.len() == 2
10902            ),
10903            "shared compact prefix must still return AmbiguousPrefix; got {err:?}"
10904        );
10905    }
10906
10907    #[tokio::test]
10908    async fn resolve_prefix_invisible_across_namespaces() {
10909        let rt = rt();
10910        let ns_a = NamespaceToken::for_namespace(Namespace::parse("ns-a").unwrap());
10911        let ns_b = NamespaceToken::for_namespace(Namespace::parse("ns-b").unwrap());
10912        let entity = rt
10913            .create_entity(&ns_a, "concept", None, "Invisible", None, None, vec![])
10914            .await
10915            .unwrap();
10916        let prefix = &entity.id.to_string()[..8];
10917
10918        // From ns_b, the entity in ns_a should not be visible.
10919        let resolved = rt.resolve_prefix(&ns_b, prefix).await.unwrap();
10920        assert_eq!(resolved, None);
10921    }
10922
10923    #[tokio::test]
10924    async fn resolve_prefix_ambiguous_same_namespace() {
10925        use khive_storage::entity::Entity;
10926
10927        let rt = rt();
10928        let tok = NamespaceToken::local();
10929        // Two entities with UUIDs sharing the same 8-char prefix "aabbccdd".
10930        let id_a = Uuid::parse_str("aabbccdd-1111-4000-8000-000000000001").unwrap();
10931        let id_b = Uuid::parse_str("aabbccdd-2222-4000-8000-000000000002").unwrap();
10932
10933        let mut entity_a = Entity::new("local", "concept", "AmbigA");
10934        entity_a.id = id_a;
10935        let mut entity_b = Entity::new("local", "concept", "AmbigB");
10936        entity_b.id = id_b;
10937
10938        let store = rt.entities(&tok).unwrap();
10939        store.upsert_entity(entity_a).await.unwrap();
10940        store.upsert_entity(entity_b).await.unwrap();
10941
10942        let err = rt.resolve_prefix(&tok, "aabbccdd").await.unwrap_err();
10943        assert!(
10944            matches!(
10945                err,
10946                RuntimeError::AmbiguousPrefix { ref prefix, ref matches }
10947                    if prefix == "aabbccdd" && matches.len() == 2
10948            ),
10949            "shared 8-char prefix must return AmbiguousPrefix; got {err:?}"
10950        );
10951    }
10952
10953    /// A single UUID legitimately present in TWO scanned tables (entities and
10954    /// notes here) must resolve cleanly to that one UUID, not a false
10955    /// `AmbiguousPrefix` naming the same UUID twice: without cross-table
10956    /// dedup, `matches.len()` becomes 2 for a single record.
10957    #[tokio::test]
10958    async fn resolve_prefix_cross_table_duplicate_uuid_resolves_cleanly() {
10959        use khive_storage::entity::Entity;
10960
10961        let rt = rt();
10962        let tok = NamespaceToken::local();
10963        let shared_id = Uuid::parse_str("ccddeeff-1111-4000-8000-000000000001").unwrap();
10964
10965        let mut entity = Entity::new("local", "concept", "Nvk749Entity");
10966        entity.id = shared_id;
10967        rt.entities(&tok)
10968            .unwrap()
10969            .upsert_entity(entity)
10970            .await
10971            .unwrap();
10972
10973        let mut note = Note::new("local", "observation", "nvk749 note with the same id");
10974        note.id = shared_id;
10975        rt.notes(&tok).unwrap().upsert_note(note).await.unwrap();
10976
10977        let resolved = rt
10978            .resolve_prefix(&tok, "ccddeeff")
10979            .await
10980            .expect("#749: a UUID present in two tables must not be reported as ambiguous");
10981        assert_eq!(
10982            resolved,
10983            Some(shared_id),
10984            "#749: cross-table duplicate must resolve to the single shared UUID"
10985        );
10986    }
10987
10988    /// The early-exit inside the per-table scan loop (`if matches.len()
10989    /// > 1 { break }`) must also operate on DEDUPED state — otherwise a
10990    /// cross-table duplicate could still short-circuit the scan before a
10991    /// later table contributes the SAME UUID again, which would have masked
10992    /// the bug rather than exercising it. This drives the duplicate through
10993    /// the earliest two tables scanned (entities, notes) so the early-exit
10994    /// path is the one under test, not a post-loop dedup applied too late.
10995    #[tokio::test]
10996    async fn resolve_prefix_early_exit_uses_deduped_match_count() {
10997        use khive_storage::entity::Entity;
10998
10999        let rt = rt();
11000        let tok = NamespaceToken::local();
11001        let shared_id = Uuid::parse_str("ddeeff11-2222-4000-8000-000000000002").unwrap();
11002
11003        // entities and notes are the first two tables scanned inside
11004        // resolve_prefix_inner — the same UUID in both must not trip the
11005        // mid-scan `matches.len() > 1` break as if two distinct UUIDs had
11006        // been found.
11007        let mut entity = Entity::new("local", "concept", "Nvk749bEntity");
11008        entity.id = shared_id;
11009        rt.entities(&tok)
11010            .unwrap()
11011            .upsert_entity(entity)
11012            .await
11013            .unwrap();
11014
11015        let mut note = Note::new("local", "observation", "nvk749b note with the same id");
11016        note.id = shared_id;
11017        rt.notes(&tok).unwrap().upsert_note(note).await.unwrap();
11018
11019        let resolved = rt
11020            .resolve_prefix(&tok, "ddeeff11")
11021            .await
11022            .expect("#749: deduped early-exit must not falsely report ambiguity");
11023        assert_eq!(resolved, Some(shared_id));
11024    }
11025
11026    // ---- Event resolution tests ----
11027    //
11028    // resolve_prefix and handle_get already include events; these tests are
11029    // regression coverage confirming event UUIDs are resolvable and that get()
11030    // returns kind="event".
11031
11032    #[tokio::test]
11033    async fn resolve_finds_event_by_full_uuid() {
11034        use khive_storage::Event;
11035        use khive_types::{EventKind, SubstrateKind};
11036
11037        let rt = rt();
11038        let tok = NamespaceToken::local();
11039        let ns = tok.namespace().as_str();
11040        let event = Event::new(
11041            ns,
11042            "test_verb",
11043            EventKind::Audit,
11044            SubstrateKind::Entity,
11045            "actor",
11046        );
11047        let event_id = event.id;
11048        rt.events(&tok).unwrap().append_event(event).await.unwrap();
11049
11050        let resolved = rt.resolve(&tok, event_id).await.unwrap();
11051        assert!(
11052            matches!(resolved, Some(Resolved::Event(_))),
11053            "event UUID must resolve to Resolved::Event, got {resolved:?}"
11054        );
11055    }
11056
11057    #[tokio::test]
11058    async fn resolve_prefix_finds_event() {
11059        use khive_storage::Event;
11060        use khive_types::{EventKind, SubstrateKind};
11061
11062        let rt = rt();
11063        let tok = NamespaceToken::local();
11064        let ns = tok.namespace().as_str();
11065        let event = Event::new(
11066            ns,
11067            "test_verb",
11068            EventKind::Audit,
11069            SubstrateKind::Entity,
11070            "actor",
11071        );
11072        let event_id = event.id;
11073        rt.events(&tok).unwrap().append_event(event).await.unwrap();
11074
11075        let prefix = &event_id.to_string()[..8];
11076        let resolved = rt.resolve_prefix(&tok, prefix).await.unwrap();
11077        assert_eq!(
11078            resolved,
11079            Some(event_id),
11080            "resolve_prefix must return event UUID for 8-char prefix"
11081        );
11082    }
11083
11084    // ---- Referential integrity tests ----
11085
11086    #[tokio::test]
11087    async fn link_phantom_source_returns_not_found() {
11088        let rt = rt();
11089        let tok = NamespaceToken::local();
11090        let b = rt
11091            .create_entity(&tok, "concept", None, "B", None, None, vec![])
11092            .await
11093            .unwrap();
11094        let phantom = Uuid::new_v4();
11095
11096        let result = rt
11097            .link(&tok, phantom, b.id, EdgeRelation::Extends, 1.0, None)
11098            .await;
11099        match result {
11100            Err(RuntimeError::NotFound(msg)) => {
11101                assert!(
11102                    msg.contains("source"),
11103                    "error message must name 'source': {msg}"
11104                );
11105            }
11106            other => panic!("expected NotFound for phantom source, got {other:?}"),
11107        }
11108    }
11109
11110    #[tokio::test]
11111    async fn link_phantom_target_returns_not_found() {
11112        let rt = rt();
11113        let tok = NamespaceToken::local();
11114        let a = rt
11115            .create_entity(&tok, "concept", None, "A", None, None, vec![])
11116            .await
11117            .unwrap();
11118        let phantom = Uuid::new_v4();
11119
11120        let result = rt
11121            .link(&tok, a.id, phantom, EdgeRelation::Extends, 1.0, None)
11122            .await;
11123        match result {
11124            Err(RuntimeError::NotFound(msg)) => {
11125                assert!(
11126                    msg.contains("target"),
11127                    "error message must name 'target': {msg}"
11128                );
11129            }
11130            other => panic!("expected NotFound for phantom target, got {other:?}"),
11131        }
11132    }
11133
11134    #[tokio::test]
11135    async fn link_real_entities_succeeds() {
11136        let rt = rt();
11137        let tok = NamespaceToken::local();
11138        let a = rt
11139            .create_entity(&tok, "concept", None, "A", None, None, vec![])
11140            .await
11141            .unwrap();
11142        let b = rt
11143            .create_entity(&tok, "concept", None, "B", None, None, vec![])
11144            .await
11145            .unwrap();
11146
11147        let edge = rt
11148            .link(&tok, a.id, b.id, EdgeRelation::Extends, 0.8, None)
11149            .await
11150            .unwrap();
11151        assert_eq!(edge.source_id, a.id);
11152        assert_eq!(edge.target_id, b.id);
11153        assert_eq!(edge.relation, EdgeRelation::Extends);
11154    }
11155
11156    // ---- commit-time endpoint guard vs concurrent hard-delete ----
11157
11158    /// Deterministic form of the regression, exercised directly at the
11159    /// write step `link` performs after prepare-time validation: build the
11160    /// exact `Edge` `link` would build, delete the target the way a
11161    /// concurrent racer would, and confirm the guarded write refuses it.
11162    #[tokio::test]
11163    async fn link_write_time_guard_blocks_dangling_edge_after_target_vanishes() {
11164        let rt = rt();
11165        let tok = NamespaceToken::local();
11166        let a = rt
11167            .create_entity(&tok, "concept", None, "A", None, None, vec![])
11168            .await
11169            .unwrap();
11170        let x = rt
11171            .create_entity(&tok, "concept", None, "X", None, None, vec![])
11172            .await
11173            .unwrap();
11174
11175        rt.validate_edge_relation_endpoints(&tok, a.id, x.id, EdgeRelation::Extends)
11176            .await
11177            .expect("prepare-time validation must pass while X is live");
11178
11179        assert!(rt.delete_entity(&tok, x.id, true).await.unwrap());
11180
11181        let now = chrono::Utc::now();
11182        let edge = Edge {
11183            id: LinkId::from(Uuid::new_v4()),
11184            namespace: tok.namespace().as_str().to_string(),
11185            source_id: a.id,
11186            target_id: x.id,
11187            relation: EdgeRelation::Extends,
11188            weight: 1.0,
11189            created_at: now,
11190            updated_at: now,
11191            deleted_at: None,
11192            metadata: None,
11193            target_backend: None,
11194        };
11195        let outcome = rt
11196            .graph(&tok)
11197            .unwrap()
11198            .upsert_edge_guarded(edge)
11199            .await
11200            .unwrap();
11201        match outcome {
11202            khive_storage::GuardedWriteOutcome::Refused(missing) => {
11203                assert!(missing.target, "target must be reported missing");
11204            }
11205            other => panic!(
11206                "guarded write must refuse an edge whose target vanished before commit, got {other:?}"
11207            ),
11208        }
11209
11210        let edges = rt
11211            .list_edges(
11212                &tok,
11213                crate::curation::EdgeListFilter {
11214                    source_id: Some(a.id),
11215                    target_id: Some(x.id),
11216                    relations: vec![EdgeRelation::Extends],
11217                    ..Default::default()
11218                },
11219                10,
11220                0,
11221            )
11222            .await
11223            .unwrap();
11224        assert!(
11225            edges.is_empty(),
11226            "no dangling edge may be persisted after the guarded write refused it"
11227        );
11228    }
11229
11230    #[tokio::test]
11231    async fn link_many_writes_nothing_when_one_target_vanishes_before_write() {
11232        let rt = rt();
11233        let tok = NamespaceToken::local();
11234        let a = rt
11235            .create_entity(&tok, "concept", None, "A", None, None, vec![])
11236            .await
11237            .unwrap();
11238        let b = rt
11239            .create_entity(&tok, "concept", None, "B", None, None, vec![])
11240            .await
11241            .unwrap();
11242        let x = rt
11243            .create_entity(&tok, "concept", None, "X", None, None, vec![])
11244            .await
11245            .unwrap();
11246
11247        let specs = vec![
11248            LinkSpec {
11249                namespace: None,
11250                source_id: a.id,
11251                target_id: x.id,
11252                relation: EdgeRelation::Extends,
11253                weight: 1.0,
11254                metadata: None,
11255                resurrect: false,
11256            },
11257            LinkSpec {
11258                namespace: None,
11259                source_id: a.id,
11260                target_id: b.id,
11261                relation: EdgeRelation::Extends,
11262                weight: 1.0,
11263                metadata: None,
11264                resurrect: false,
11265            },
11266        ];
11267
11268        // Both specs validate fine at build_edge time (X and B both live).
11269        let mut edges = Vec::with_capacity(specs.len());
11270        for spec in &specs {
11271            edges.push(rt.build_edge(&tok, spec).await.unwrap());
11272        }
11273
11274        // X vanishes before the batched write — mirrors a concurrent
11275        // hard-delete landing between per-spec validation and link_many's
11276        // single guarded batch write.
11277        assert!(rt.delete_entity(&tok, x.id, true).await.unwrap());
11278
11279        let outcome = rt
11280            .graph(&tok)
11281            .unwrap()
11282            .upsert_edges_guarded(edges)
11283            .await
11284            .unwrap();
11285        assert_eq!(
11286            outcome.summary.affected, 0,
11287            "no edge from the batch may be persisted when any endpoint vanished"
11288        );
11289        assert!(
11290            outcome.refused.is_some(),
11291            "refused batch entry must be reported"
11292        );
11293
11294        let edges = rt
11295            .list_edges(
11296                &tok,
11297                crate::curation::EdgeListFilter {
11298                    source_id: Some(a.id),
11299                    relations: vec![EdgeRelation::Extends],
11300                    ..Default::default()
11301                },
11302                10,
11303                0,
11304            )
11305            .await
11306            .unwrap();
11307        assert!(
11308            edges.is_empty(),
11309            "link_many's guarded batch must be all-or-nothing: the live A-B edge \
11310             must not have been persisted alongside the doomed A-X edge"
11311        );
11312    }
11313
11314    #[tokio::test]
11315    async fn link_many_reverse_symmetric_refusal_reports_canonical_missing_endpoint() {
11316        for relation in [EdgeRelation::CompetesWith, EdgeRelation::ComposedWith] {
11317            for delete_source in [true, false] {
11318                let rt = rt();
11319                let tok = NamespaceToken::local();
11320                let a = rt
11321                    .create_entity(&tok, "concept", None, "A", None, None, vec![])
11322                    .await
11323                    .unwrap();
11324                let b = rt
11325                    .create_entity(&tok, "concept", None, "B", None, None, vec![])
11326                    .await
11327                    .unwrap();
11328                let (source_id, target_id) = (a.id.min(b.id), a.id.max(b.id));
11329                let spec = LinkSpec {
11330                    namespace: None,
11331                    source_id: target_id,
11332                    target_id: source_id,
11333                    relation,
11334                    weight: 1.0,
11335                    metadata: None,
11336                    resurrect: false,
11337                };
11338                assert!(spec.source_id > spec.target_id);
11339                let edge = rt.build_edge(&tok, &spec).await.unwrap();
11340                assert_eq!((edge.source_id, edge.target_id), (source_id, target_id));
11341
11342                let deleted_id = if delete_source { source_id } else { target_id };
11343                assert!(rt.delete_entity(&tok, deleted_id, true).await.unwrap());
11344                let outcome = rt
11345                    .graph(&tok)
11346                    .unwrap()
11347                    .upsert_edges_guarded_observed(vec![EdgeUpsertRequest {
11348                        edge,
11349                        resurrect: false,
11350                    }])
11351                    .await
11352                    .unwrap();
11353                assert!(outcome.rows.is_empty());
11354                let refusal = outcome.refusal.expect("the deleted endpoint must refuse");
11355                let EdgeUpsertRefusal::MissingEndpoints(missing) = refusal.reason else {
11356                    panic!("expected a missing-endpoint refusal");
11357                };
11358                assert_eq!(missing.source, delete_source);
11359                assert_eq!(missing.target, !delete_source);
11360
11361                let failure = guarded_link_batch_failure(&spec, refusal.entry_index, missing);
11362                assert_eq!(failure.entry_index, Some(0));
11363                assert_eq!(failure.missing_source, delete_source.then_some(deleted_id));
11364                assert_eq!(
11365                    failure.missing_target,
11366                    (!delete_source).then_some(deleted_id)
11367                );
11368                assert!(rt
11369                    .list_edges(&tok, EdgeListFilter::default(), 10, 0)
11370                    .await
11371                    .unwrap()
11372                    .is_empty());
11373            }
11374        }
11375    }
11376
11377    // ---- hard-delete row + incident-edge purge is ONE transaction ----
11378    //
11379    // Six tests below cover both orderings (write-then-delete, and a
11380    // concurrent write raced against delete via `tokio::join!`) across all
11381    // three hard-delete paths that cascade-purge incident edges: entity,
11382    // note, and edge-as-node. No sleeps — the "concurrent" tests assert an
11383    // invariant that must hold for EITHER interleaving the async scheduler
11384    // picks, rather than forcing one specific interleaving, so they are
11385    // deterministic (never flaky) without a barrier.
11386
11387    fn raw_edge(source_id: Uuid, target_id: Uuid, ns: &str) -> Edge {
11388        let now = chrono::Utc::now();
11389        Edge {
11390            id: LinkId::from(Uuid::new_v4()),
11391            namespace: ns.to_string(),
11392            source_id,
11393            target_id,
11394            relation: EdgeRelation::Extends,
11395            weight: 1.0,
11396            created_at: now,
11397            updated_at: now,
11398            deleted_at: None,
11399            metadata: None,
11400            target_backend: None,
11401        }
11402    }
11403
11404    async fn assert_no_edges_touch(rt: &KhiveRuntime, tok: &NamespaceToken, node_id: Uuid) {
11405        let as_source = rt
11406            .list_edges(
11407                tok,
11408                crate::curation::EdgeListFilter {
11409                    source_id: Some(node_id),
11410                    ..Default::default()
11411                },
11412                10,
11413                0,
11414            )
11415            .await
11416            .unwrap();
11417        let as_target = rt
11418            .list_edges(
11419                tok,
11420                crate::curation::EdgeListFilter {
11421                    target_id: Some(node_id),
11422                    ..Default::default()
11423                },
11424                10,
11425                0,
11426            )
11427            .await
11428            .unwrap();
11429        assert!(
11430            as_source.is_empty() && as_target.is_empty(),
11431            "no edge may reference hard-deleted node {node_id}: source-side={as_source:?} \
11432             target-side={as_target:?}"
11433        );
11434    }
11435
11436    #[tokio::test]
11437    async fn hard_delete_entity_purges_edge_written_before_delete() {
11438        let rt = rt();
11439        let tok = NamespaceToken::local();
11440        let a = rt
11441            .create_entity(&tok, "concept", None, "A", None, None, vec![])
11442            .await
11443            .unwrap();
11444        let x = rt
11445            .create_entity(&tok, "concept", None, "X", None, None, vec![])
11446            .await
11447            .unwrap();
11448
11449        let edge = raw_edge(a.id, x.id, tok.namespace().as_str());
11450        assert_eq!(
11451            rt.graph(&tok)
11452                .unwrap()
11453                .upsert_edge_guarded(edge)
11454                .await
11455                .unwrap(),
11456            khive_storage::GuardedWriteOutcome::Written
11457        );
11458
11459        assert!(rt.delete_entity(&tok, x.id, true).await.unwrap());
11460        assert_no_edges_touch(&rt, &tok, x.id).await;
11461    }
11462
11463    #[tokio::test]
11464    async fn hard_delete_entity_concurrent_with_guarded_write_never_leaves_dangling_edge() {
11465        let rt = std::sync::Arc::new(rt());
11466        let tok = NamespaceToken::local();
11467        let a = rt
11468            .create_entity(&tok, "concept", None, "A", None, None, vec![])
11469            .await
11470            .unwrap();
11471        let x = rt
11472            .create_entity(&tok, "concept", None, "X", None, None, vec![])
11473            .await
11474            .unwrap();
11475
11476        let delete_rt = std::sync::Arc::clone(&rt);
11477        let delete_tok = tok.clone();
11478        let delete_task =
11479            tokio::spawn(async move { delete_rt.delete_entity(&delete_tok, x.id, true).await });
11480
11481        let write_rt = std::sync::Arc::clone(&rt);
11482        let write_tok = tok.clone();
11483        let ns = tok.namespace().as_str().to_string();
11484        let write_task = tokio::spawn(async move {
11485            let edge = raw_edge(a.id, x.id, &ns);
11486            write_rt
11487                .graph(&write_tok)
11488                .unwrap()
11489                .upsert_edge_guarded(edge)
11490                .await
11491        });
11492
11493        let (deleted, _written) = tokio::join!(delete_task, write_task);
11494        deleted.unwrap().unwrap();
11495        assert_no_edges_touch(&rt, &tok, x.id).await;
11496    }
11497
11498    #[tokio::test]
11499    async fn hard_delete_note_purges_edge_written_before_delete() {
11500        let rt = rt();
11501        let tok = NamespaceToken::local();
11502        let a = rt
11503            .create_entity(&tok, "concept", None, "A", None, None, vec![])
11504            .await
11505            .unwrap();
11506        let n = rt
11507            .create_note(
11508                &tok,
11509                "observation",
11510                None,
11511                "note content",
11512                None,
11513                None,
11514                vec![],
11515            )
11516            .await
11517            .unwrap();
11518
11519        let edge = raw_edge(a.id, n.id, tok.namespace().as_str());
11520        assert_eq!(
11521            rt.graph(&tok)
11522                .unwrap()
11523                .upsert_edge_guarded(edge)
11524                .await
11525                .unwrap(),
11526            khive_storage::GuardedWriteOutcome::Written
11527        );
11528
11529        assert!(rt.delete_note(&tok, n.id, true).await.unwrap());
11530        assert_no_edges_touch(&rt, &tok, n.id).await;
11531    }
11532
11533    #[tokio::test]
11534    async fn hard_delete_note_concurrent_with_guarded_write_never_leaves_dangling_edge() {
11535        let rt = std::sync::Arc::new(rt());
11536        let tok = NamespaceToken::local();
11537        let a = rt
11538            .create_entity(&tok, "concept", None, "A", None, None, vec![])
11539            .await
11540            .unwrap();
11541        let n = rt
11542            .create_note(
11543                &tok,
11544                "observation",
11545                None,
11546                "note content",
11547                None,
11548                None,
11549                vec![],
11550            )
11551            .await
11552            .unwrap();
11553
11554        let delete_rt = std::sync::Arc::clone(&rt);
11555        let delete_tok = tok.clone();
11556        let note_id = n.id;
11557        let delete_task =
11558            tokio::spawn(async move { delete_rt.delete_note(&delete_tok, note_id, true).await });
11559
11560        let write_rt = std::sync::Arc::clone(&rt);
11561        let write_tok = tok.clone();
11562        let ns = tok.namespace().as_str().to_string();
11563        let write_task = tokio::spawn(async move {
11564            let edge = raw_edge(a.id, note_id, &ns);
11565            write_rt
11566                .graph(&write_tok)
11567                .unwrap()
11568                .upsert_edge_guarded(edge)
11569                .await
11570        });
11571
11572        let (deleted, _written) = tokio::join!(delete_task, write_task);
11573        deleted.unwrap().unwrap();
11574        assert_no_edges_touch(&rt, &tok, note_id).await;
11575    }
11576
11577    #[tokio::test]
11578    async fn hard_delete_edge_endpoint_purges_annotating_edge_written_before_delete() {
11579        let rt = rt();
11580        let tok = NamespaceToken::local();
11581        let a = rt
11582            .create_entity(&tok, "concept", None, "A", None, None, vec![])
11583            .await
11584            .unwrap();
11585        let b = rt
11586            .create_entity(&tok, "concept", None, "B", None, None, vec![])
11587            .await
11588            .unwrap();
11589        let n = rt
11590            .create_note(
11591                &tok,
11592                "observation",
11593                None,
11594                "note content",
11595                None,
11596                None,
11597                vec![],
11598            )
11599            .await
11600            .unwrap();
11601        let base_edge = rt
11602            .link(&tok, a.id, b.id, EdgeRelation::Extends, 0.8, None)
11603            .await
11604            .unwrap();
11605        let base_edge_id = Uuid::from(base_edge.id);
11606
11607        // An edge whose TARGET is another edge — the "edge-as-node" case
11608        // `delete_edge`'s cascade must sweep.
11609        let annotating = raw_edge(n.id, base_edge_id, tok.namespace().as_str());
11610        assert_eq!(
11611            rt.graph(&tok)
11612                .unwrap()
11613                .upsert_edge_guarded(annotating)
11614                .await
11615                .unwrap(),
11616            khive_storage::GuardedWriteOutcome::Written
11617        );
11618
11619        assert!(rt.delete_edge(&tok, base_edge_id, true).await.unwrap());
11620        assert_no_edges_touch(&rt, &tok, base_edge_id).await;
11621    }
11622
11623    #[tokio::test]
11624    async fn hard_delete_edge_endpoint_concurrent_with_guarded_write_never_leaves_dangling_edge() {
11625        let rt = std::sync::Arc::new(rt());
11626        let tok = NamespaceToken::local();
11627        let a = rt
11628            .create_entity(&tok, "concept", None, "A", None, None, vec![])
11629            .await
11630            .unwrap();
11631        let b = rt
11632            .create_entity(&tok, "concept", None, "B", None, None, vec![])
11633            .await
11634            .unwrap();
11635        let n = rt
11636            .create_note(
11637                &tok,
11638                "observation",
11639                None,
11640                "note content",
11641                None,
11642                None,
11643                vec![],
11644            )
11645            .await
11646            .unwrap();
11647        let base_edge = rt
11648            .link(&tok, a.id, b.id, EdgeRelation::Extends, 0.8, None)
11649            .await
11650            .unwrap();
11651        let base_edge_id = Uuid::from(base_edge.id);
11652
11653        let delete_rt = std::sync::Arc::clone(&rt);
11654        let delete_tok = tok.clone();
11655        let delete_task =
11656            tokio::spawn(
11657                async move { delete_rt.delete_edge(&delete_tok, base_edge_id, true).await },
11658            );
11659
11660        let write_rt = std::sync::Arc::clone(&rt);
11661        let write_tok = tok.clone();
11662        let ns = tok.namespace().as_str().to_string();
11663        let write_task = tokio::spawn(async move {
11664            let edge = raw_edge(n.id, base_edge_id, &ns);
11665            write_rt
11666                .graph(&write_tok)
11667                .unwrap()
11668                .upsert_edge_guarded(edge)
11669                .await
11670        });
11671
11672        let (deleted, _written) = tokio::join!(delete_task, write_task);
11673        deleted.unwrap().unwrap();
11674        assert_no_edges_touch(&rt, &tok, base_edge_id).await;
11675    }
11676
11677    // ---- file-backed, both write-queue configs ----
11678    // The six tests above race delete against the guarded write via `tokio::join!` with
11679    // no ordering control, so the scheduler could run them fully sequentially without
11680    // exercising real interleaving, and neither the file-backed storage path nor
11681    // `write_queue_enabled: Some(true)` (`KHIVE_WRITE_QUEUE=1`) is covered. The four
11682    // tests below force the guarded write to land on one specific side of the delete via
11683    // plain `.await` sequencing instead of an uncontrolled race.
11684
11685    fn file_backed_runtime(
11686        dir: &tempfile::TempDir,
11687        name: &str,
11688        write_queue_enabled: bool,
11689    ) -> KhiveRuntime {
11690        let path = dir.path().join(name);
11691        if write_queue_enabled {
11692            std::env::set_var("KHIVE_WRITE_QUEUE", "1");
11693        } else {
11694            std::env::remove_var("KHIVE_WRITE_QUEUE");
11695        }
11696        let rt = KhiveRuntime::new_for_test(crate::config::RuntimeConfig {
11697            db_path: Some(path),
11698            packs: vec!["kg".to_string()],
11699            brain_profile: None,
11700            actor_id: None,
11701            ..crate::config::RuntimeConfig::no_embeddings()
11702        })
11703        .unwrap();
11704        std::env::remove_var("KHIVE_WRITE_QUEUE");
11705        rt
11706    }
11707
11708    async fn assert_guarded_write_committed_before_delete_is_swept(rt: &KhiveRuntime) {
11709        let tok = NamespaceToken::local();
11710        let a = rt
11711            .create_entity(&tok, "concept", None, "A", None, None, vec![])
11712            .await
11713            .unwrap();
11714        let x = rt
11715            .create_entity(&tok, "concept", None, "X", None, None, vec![])
11716            .await
11717            .unwrap();
11718
11719        // Write lands fully committed while X is still live — squarely
11720        // inside the window before the delete's cascade runs.
11721        let edge = raw_edge(a.id, x.id, tok.namespace().as_str());
11722        assert_eq!(
11723            rt.graph(&tok)
11724                .unwrap()
11725                .upsert_edge_guarded(edge)
11726                .await
11727                .unwrap(),
11728            khive_storage::GuardedWriteOutcome::Written,
11729            "write must succeed while both endpoints are still live"
11730        );
11731
11732        assert!(rt.delete_entity(&tok, x.id, true).await.unwrap());
11733        assert_no_edges_touch(rt, &tok, x.id).await;
11734    }
11735
11736    async fn assert_guarded_write_attempted_after_delete_is_refused(rt: &KhiveRuntime) {
11737        let tok = NamespaceToken::local();
11738        let a = rt
11739            .create_entity(&tok, "concept", None, "A", None, None, vec![])
11740            .await
11741            .unwrap();
11742        let x = rt
11743            .create_entity(&tok, "concept", None, "X", None, None, vec![])
11744            .await
11745            .unwrap();
11746
11747        // The delete's transaction has fully committed before the guarded
11748        // write is even attempted — squarely after the window has closed.
11749        assert!(rt.delete_entity(&tok, x.id, true).await.unwrap());
11750
11751        let edge = raw_edge(a.id, x.id, tok.namespace().as_str());
11752        let outcome = rt
11753            .graph(&tok)
11754            .unwrap()
11755            .upsert_edge_guarded(edge)
11756            .await
11757            .unwrap();
11758        match outcome {
11759            khive_storage::GuardedWriteOutcome::Refused(missing) => {
11760                assert!(
11761                    missing.target,
11762                    "target must be reported missing once the delete has committed"
11763                );
11764                assert!(!missing.source, "source was never deleted");
11765            }
11766            other => panic!(
11767                "guarded write attempted after the delete committed must be refused, got {other:?}"
11768            ),
11769        }
11770        assert_no_edges_touch(rt, &tok, x.id).await;
11771    }
11772
11773    #[tokio::test]
11774    #[serial_test::serial(khive_write_queue_env)]
11775    async fn guarded_write_before_delete_swept_file_backed_write_queue_off() {
11776        let dir = tempfile::tempdir().unwrap();
11777        let rt = file_backed_runtime(&dir, "guard_before_off.db", false);
11778        assert_guarded_write_committed_before_delete_is_swept(&rt).await;
11779    }
11780
11781    #[tokio::test]
11782    #[serial_test::serial(khive_write_queue_env)]
11783    async fn guarded_write_after_delete_refused_file_backed_write_queue_off() {
11784        let dir = tempfile::tempdir().unwrap();
11785        let rt = file_backed_runtime(&dir, "guard_after_off.db", false);
11786        assert_guarded_write_attempted_after_delete_is_refused(&rt).await;
11787    }
11788
11789    #[tokio::test]
11790    #[serial_test::serial(khive_write_queue_env)]
11791    async fn guarded_write_before_delete_swept_file_backed_write_queue_on() {
11792        let dir = tempfile::tempdir().unwrap();
11793        let rt = file_backed_runtime(&dir, "guard_before_on.db", true);
11794        assert_guarded_write_committed_before_delete_is_swept(&rt).await;
11795    }
11796
11797    #[tokio::test]
11798    #[serial_test::serial(khive_write_queue_env)]
11799    async fn guarded_write_after_delete_refused_file_backed_write_queue_on() {
11800        let dir = tempfile::tempdir().unwrap();
11801        let rt = file_backed_runtime(&dir, "guard_after_on.db", true);
11802        assert_guarded_write_attempted_after_delete_is_refused(&rt).await;
11803    }
11804
11805    #[tokio::test]
11806    async fn create_note_annotates_phantom_returns_not_found() {
11807        let rt = rt();
11808        let tok = NamespaceToken::local();
11809        let phantom = Uuid::new_v4();
11810
11811        let result = rt
11812            .create_note(
11813                &tok,
11814                "observation",
11815                None,
11816                "some content",
11817                Some(0.5),
11818                None,
11819                vec![phantom],
11820            )
11821            .await;
11822        assert!(
11823            matches!(result, Err(RuntimeError::NotFound(_))),
11824            "annotates with phantom uuid must return NotFound, got {result:?}"
11825        );
11826    }
11827
11828    #[tokio::test]
11829    async fn create_note_annotates_real_entity_succeeds() {
11830        let rt = rt();
11831        let tok = NamespaceToken::local();
11832        let entity = rt
11833            .create_entity(&tok, "concept", None, "RealTarget", None, None, vec![])
11834            .await
11835            .unwrap();
11836
11837        let note = rt
11838            .create_note(
11839                &tok,
11840                "observation",
11841                None,
11842                "content",
11843                Some(0.5),
11844                None,
11845                vec![entity.id],
11846            )
11847            .await
11848            .unwrap();
11849
11850        let neighbors = rt
11851            .neighbors(
11852                &tok,
11853                note.id,
11854                Direction::Out,
11855                None,
11856                Some(vec![EdgeRelation::Annotates]),
11857            )
11858            .await
11859            .unwrap();
11860        assert_eq!(neighbors.len(), 1);
11861        assert_eq!(neighbors[0].node_id, entity.id);
11862    }
11863
11864    // Atomicity: multi-target annotates golden path — all edges created, note present.
11865    #[tokio::test]
11866    async fn create_note_multi_annotates_creates_all_edges() {
11867        let rt = rt();
11868        let tok = NamespaceToken::local();
11869        let t1 = rt
11870            .create_entity(&tok, "concept", None, "Target1", None, None, vec![])
11871            .await
11872            .unwrap();
11873        let t2 = rt
11874            .create_entity(&tok, "concept", None, "Target2", None, None, vec![])
11875            .await
11876            .unwrap();
11877
11878        let note = rt
11879            .create_note(
11880                &tok,
11881                "observation",
11882                None,
11883                "content",
11884                Some(0.5),
11885                None,
11886                vec![t1.id, t2.id],
11887            )
11888            .await
11889            .unwrap();
11890
11891        let neighbors = rt
11892            .neighbors(
11893                &tok,
11894                note.id,
11895                Direction::Out,
11896                None,
11897                Some(vec![EdgeRelation::Annotates]),
11898            )
11899            .await
11900            .unwrap();
11901        assert_eq!(
11902            neighbors.len(),
11903            2,
11904            "multi-annotates note must have exactly 2 outbound annotates edges"
11905        );
11906        let target_ids: Vec<Uuid> = neighbors.iter().map(|n| n.node_id).collect();
11907        assert!(target_ids.contains(&t1.id));
11908        assert!(target_ids.contains(&t2.id));
11909    }
11910
11911    /// The atomic-apply post-commit renderer for a
11912    /// symmetric-edge update resolves the surviving row's store by the CALLER's
11913    /// token but must filter by the record's OWN namespace, passed explicitly —
11914    /// never by re-deriving it from whichever token happened to select the store
11915    /// (`self.graph(token)` scopes by `token.namespace()`, and by-ID edge updates
11916    /// are namespace-agnostic, so a caller in one namespace can legitimately
11917    /// commit an update against an edge recorded in another). This proves the
11918    /// `namespace` parameter — not the `token` argument — decides which row the
11919    /// natural-key lookup finds, at the level that is otherwise an
11920    /// acceptable substitute for a full cross-namespace atomic-apply test.
11921    #[tokio::test]
11922    async fn get_edge_by_natural_key_including_deleted_honors_explicit_namespace_not_token() {
11923        let rt = rt();
11924        let ns_a = NamespaceToken::for_namespace(Namespace::parse("ns-a").unwrap());
11925        let ns_b = NamespaceToken::for_namespace(Namespace::parse("ns-b").unwrap());
11926
11927        let a = rt
11928            .create_entity(&ns_b, "concept", None, "A", None, None, vec![])
11929            .await
11930            .unwrap();
11931        let b = rt
11932            .create_entity(&ns_b, "concept", None, "B", None, None, vec![])
11933            .await
11934            .unwrap();
11935        let edge = rt
11936            .link(&ns_b, a.id, b.id, EdgeRelation::CompetesWith, 1.0, None)
11937            .await
11938            .unwrap();
11939        let (canon_src, canon_tgt) =
11940            canonical_edge_endpoints(EdgeRelation::CompetesWith, a.id, b.id);
11941
11942        // Caller token is ns-a (an unrelated namespace); passing "ns-b" explicitly
11943        // must still find the edge recorded there.
11944        let found = rt
11945            .get_edge_by_natural_key_including_deleted(
11946                &ns_a,
11947                "ns-b",
11948                canon_src,
11949                canon_tgt,
11950                EdgeRelation::CompetesWith,
11951            )
11952            .await
11953            .unwrap();
11954        assert_eq!(
11955            found.map(|e| Uuid::from(e.id)),
11956            Some(Uuid::from(edge.id)),
11957            "must find the edge by its own namespace regardless of the caller's token"
11958        );
11959
11960        // Passing the caller token's OWN namespace ("ns-a") as the explicit filter
11961        // must NOT find it — proves the lookup is keyed on the `namespace` argument,
11962        // not silently re-scoped to whatever namespace the token carries.
11963        let not_found = rt
11964            .get_edge_by_natural_key_including_deleted(
11965                &ns_a,
11966                "ns-a",
11967                canon_src,
11968                canon_tgt,
11969                EdgeRelation::CompetesWith,
11970            )
11971            .await
11972            .unwrap();
11973        assert!(
11974            not_found.is_none(),
11975            "must not find an edge recorded in a different namespace than the one queried"
11976        );
11977    }
11978
11979    /// `link` endpoint existence is a by-ID check and therefore namespace-agnostic:
11980    /// a target living in a different namespace than the caller must still
11981    /// resolve, exactly as `get()` would.
11982    #[tokio::test]
11983    async fn link_target_in_different_namespace_succeeds() {
11984        let rt = rt();
11985        let ns_a = NamespaceToken::for_namespace(Namespace::parse("ns-a").unwrap());
11986        let ns_b = NamespaceToken::for_namespace(Namespace::parse("ns-b").unwrap());
11987        let a = rt
11988            .create_entity(&ns_a, "concept", None, "A", None, None, vec![])
11989            .await
11990            .unwrap();
11991        let b = rt
11992            .create_entity(&ns_b, "concept", None, "B", None, None, vec![])
11993            .await
11994            .unwrap();
11995
11996        // Linking from ns-a: target b lives in ns-b — by-ID resolution finds it anyway.
11997        let result = rt
11998            .link(&ns_a, a.id, b.id, EdgeRelation::Extends, 1.0, None)
11999            .await;
12000        assert!(
12001            result.is_ok(),
12002            "target in a different namespace than the caller must resolve (#631), got {result:?}"
12003        );
12004    }
12005
12006    #[tokio::test]
12007    async fn link_phantom_self_loop_returns_invalid_input() {
12008        let rt = rt();
12009        let tok = NamespaceToken::local();
12010        let phantom = Uuid::new_v4();
12011
12012        let result = rt
12013            .link(&tok, phantom, phantom, EdgeRelation::Extends, 1.0, None)
12014            .await;
12015        match result {
12016            Err(RuntimeError::InvalidInput(msg)) => {
12017                assert!(
12018                    msg.contains("self-loop"),
12019                    "self-loop must be rejected with self-loop message: {msg}"
12020                );
12021            }
12022            other => panic!("expected InvalidInput for self-loop, got {other:?}"),
12023        }
12024    }
12025
12026    // ---- edge target coverage + atomicity ----
12027
12028    #[tokio::test]
12029    async fn link_note_to_edge_annotates_succeeds() {
12030        let rt = rt();
12031        let tok = NamespaceToken::local();
12032        let a = rt
12033            .create_entity(&tok, "concept", None, "A", None, None, vec![])
12034            .await
12035            .unwrap();
12036        let b = rt
12037            .create_entity(&tok, "concept", None, "B", None, None, vec![])
12038            .await
12039            .unwrap();
12040        // Create a real edge between a and b, capture its UUID.
12041        let edge = rt
12042            .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
12043            .await
12044            .unwrap();
12045        let edge_uuid: Uuid = edge.id.into();
12046
12047        // Create a note and annotate the edge itself (edge is a valid substrate target for annotates).
12048        let note = rt
12049            .create_note(
12050                &tok,
12051                "observation",
12052                None,
12053                "edge note",
12054                Some(0.5),
12055                None,
12056                vec![],
12057            )
12058            .await
12059            .unwrap();
12060
12061        let result = rt
12062            .link(&tok, note.id, edge_uuid, EdgeRelation::Annotates, 1.0, None)
12063            .await;
12064        assert!(
12065            result.is_ok(),
12066            "note→edge Annotates must succeed, got {result:?}"
12067        );
12068    }
12069    /// #803: `neighbors(edge_id, direction=In, relations=[Annotates])` must
12070    /// find the annotating note — the storage-layer `graph_edges` query
12071    /// filters on `target_id = node_id` with no substrate-type check, so an
12072    /// edge id works as a neighbor-query node the same as an entity or note
12073    /// id. This is the runtime capability `get(edge_id)`'s new `annotations`
12074    /// field (khive-pack-kg) builds on.
12075    #[tokio::test]
12076    async fn neighbors_edge_id_finds_annotating_note() {
12077        let rt = rt();
12078        let tok = NamespaceToken::local();
12079        let a = rt
12080            .create_entity(&tok, "concept", None, "A", None, None, vec![])
12081            .await
12082            .unwrap();
12083        let b = rt
12084            .create_entity(&tok, "concept", None, "B", None, None, vec![])
12085            .await
12086            .unwrap();
12087        let edge = rt
12088            .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
12089            .await
12090            .unwrap();
12091        let edge_uuid: Uuid = edge.id.into();
12092
12093        let note = rt
12094            .create_note(
12095                &tok,
12096                "observation",
12097                None,
12098                "edge note",
12099                Some(0.5),
12100                None,
12101                vec![edge_uuid],
12102            )
12103            .await
12104            .unwrap();
12105
12106        let neighbors = rt
12107            .neighbors(
12108                &tok,
12109                edge_uuid,
12110                Direction::In,
12111                None,
12112                Some(vec![EdgeRelation::Annotates]),
12113            )
12114            .await
12115            .unwrap();
12116        assert_eq!(neighbors.len(), 1, "expected annotating note to show up");
12117        assert_eq!(neighbors[0].node_id, note.id);
12118    }
12119
12120    #[tokio::test]
12121    async fn create_note_annotates_real_edge_succeeds() {
12122        let rt = rt();
12123        let tok = NamespaceToken::local();
12124        let a = rt
12125            .create_entity(&tok, "concept", None, "A", None, None, vec![])
12126            .await
12127            .unwrap();
12128        let b = rt
12129            .create_entity(&tok, "concept", None, "B", None, None, vec![])
12130            .await
12131            .unwrap();
12132        let edge = rt
12133            .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
12134            .await
12135            .unwrap();
12136        let edge_uuid: Uuid = edge.id.into();
12137
12138        let note = rt
12139            .create_note(
12140                &tok,
12141                "observation",
12142                None,
12143                "annotating an edge",
12144                Some(0.5),
12145                None,
12146                vec![edge_uuid],
12147            )
12148            .await
12149            .unwrap();
12150
12151        let neighbors = rt
12152            .neighbors(
12153                &tok,
12154                note.id,
12155                Direction::Out,
12156                None,
12157                Some(vec![EdgeRelation::Annotates]),
12158            )
12159            .await
12160            .unwrap();
12161        assert_eq!(neighbors.len(), 1);
12162        assert_eq!(neighbors[0].node_id, edge_uuid);
12163    }
12164
12165    #[tokio::test]
12166    async fn create_note_annotates_phantom_is_atomic_no_note_persisted() {
12167        let rt = rt();
12168        let tok = NamespaceToken::local();
12169        let phantom = Uuid::new_v4();
12170
12171        let before_count = rt.list_notes(&tok, None, 1000, 0).await.unwrap().len();
12172
12173        let result = rt
12174            .create_note(
12175                &tok,
12176                "observation",
12177                None,
12178                "should not persist",
12179                Some(0.5),
12180                None,
12181                vec![phantom],
12182            )
12183            .await;
12184        assert!(
12185            matches!(result, Err(RuntimeError::NotFound(_))),
12186            "phantom annotates target must return NotFound, got {result:?}"
12187        );
12188
12189        // Atomicity: the note row must NOT have been written.
12190        let after_count = rt.list_notes(&tok, None, 1000, 0).await.unwrap().len();
12191        assert_eq!(
12192            before_count, after_count,
12193            "failed create_note must not persist any note row (atomicity)"
12194        );
12195
12196        // FTS must not contain the content either.
12197        let search_hits = rt
12198            .search_notes(&tok, "should not persist", None, 10, None, false, &[], None)
12199            .await
12200            .unwrap();
12201        assert!(
12202            search_hits.is_empty(),
12203            "failed create_note must not index into FTS (atomicity)"
12204        );
12205        // Vector-store row: only written when an embedding model is configured; the rt()
12206        // harness has none, so no vector assertion is needed here.
12207    }
12208
12209    // ---- relation-aware endpoint contract ----
12210
12211    // Test #2: entity→entity with non-annotates rejects an edge UUID as target.
12212    #[tokio::test]
12213    async fn link_entity_to_edge_uuid_non_annotates_returns_invalid_input() {
12214        let rt = rt();
12215        let tok = NamespaceToken::local();
12216        let a = rt
12217            .create_entity(&tok, "concept", None, "A", None, None, vec![])
12218            .await
12219            .unwrap();
12220        let b = rt
12221            .create_entity(&tok, "concept", None, "B", None, None, vec![])
12222            .await
12223            .unwrap();
12224        // Create a real edge; capture its UUID as the bad target.
12225        let edge = rt
12226            .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
12227            .await
12228            .unwrap();
12229        let edge_uuid: Uuid = edge.id.into();
12230
12231        let result = rt
12232            .link(&tok, a.id, edge_uuid, EdgeRelation::Extends, 1.0, None)
12233            .await;
12234        match result {
12235            Err(RuntimeError::InvalidInput(msg)) => {
12236                assert!(
12237                    msg.contains("target"),
12238                    "error message must name 'target': {msg}"
12239                );
12240            }
12241            other => {
12242                panic!("expected InvalidInput for edge-uuid target with Extends, got {other:?}")
12243            }
12244        }
12245    }
12246
12247    // Test #3: non-annotates rejects a note UUID as source.
12248    #[tokio::test]
12249    async fn link_note_as_source_non_annotates_returns_invalid_input() {
12250        let rt = rt();
12251        let tok = NamespaceToken::local();
12252        let note = rt
12253            .create_note(&tok, "observation", None, "a note", Some(0.5), None, vec![])
12254            .await
12255            .unwrap();
12256        let entity = rt
12257            .create_entity(&tok, "concept", None, "E", None, None, vec![])
12258            .await
12259            .unwrap();
12260
12261        let result = rt
12262            .link(&tok, note.id, entity.id, EdgeRelation::DependsOn, 1.0, None)
12263            .await;
12264        match result {
12265            Err(RuntimeError::InvalidInput(msg)) => {
12266                assert!(
12267                    msg.contains("source"),
12268                    "error message must name 'source': {msg}"
12269                );
12270            }
12271            other => panic!("expected InvalidInput for note source with DependsOn, got {other:?}"),
12272        }
12273    }
12274
12275    // Test #4: annotates rejects entity as source (source must be a note).
12276    #[tokio::test]
12277    async fn link_entity_as_annotates_source_returns_invalid_input() {
12278        let rt = rt();
12279        let tok = NamespaceToken::local();
12280        let a = rt
12281            .create_entity(&tok, "concept", None, "A", None, None, vec![])
12282            .await
12283            .unwrap();
12284        let b = rt
12285            .create_entity(&tok, "concept", None, "B", None, None, vec![])
12286            .await
12287            .unwrap();
12288
12289        let result = rt
12290            .link(&tok, a.id, b.id, EdgeRelation::Annotates, 1.0, None)
12291            .await;
12292        match result {
12293            Err(RuntimeError::InvalidInput(msg)) => {
12294                assert!(
12295                    msg.contains("source") && msg.contains("note"),
12296                    "error must say source must be a note: {msg}"
12297                );
12298            }
12299            other => {
12300                panic!("expected InvalidInput for entity source with Annotates, got {other:?}")
12301            }
12302        }
12303    }
12304
12305    #[tokio::test]
12306    async fn link_edge_as_annotates_source_returns_invalid_input() {
12307        let rt = rt();
12308        let tok = NamespaceToken::local();
12309        let a = rt
12310            .create_entity(&tok, "concept", None, "A", None, None, vec![])
12311            .await
12312            .unwrap();
12313        let b = rt
12314            .create_entity(&tok, "concept", None, "B", None, None, vec![])
12315            .await
12316            .unwrap();
12317        let edge = rt
12318            .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
12319            .await
12320            .unwrap();
12321        let edge_uuid: Uuid = edge.id.into();
12322
12323        // An existing edge used as an annotates source: wrong kind, not absent.
12324        let result = rt
12325            .link(&tok, edge_uuid, a.id, EdgeRelation::Annotates, 1.0, None)
12326            .await;
12327        match result {
12328            Err(RuntimeError::InvalidInput(msg)) => {
12329                assert!(
12330                    msg.contains("source") && msg.contains("note"),
12331                    "edge-as-annotates-source must report wrong kind, not NotFound: {msg}"
12332                );
12333            }
12334            other => panic!("expected InvalidInput for edge source with Annotates, got {other:?}"),
12335        }
12336    }
12337
12338    // Test #5: note→event with annotates succeeds (event is a valid annotates target).
12339    #[tokio::test]
12340    async fn link_note_to_event_annotates_succeeds() {
12341        use khive_storage::Event;
12342        use khive_types::{EventKind, SubstrateKind};
12343
12344        let rt = rt();
12345        let tok = NamespaceToken::local();
12346        let note = rt
12347            .create_note(
12348                &tok,
12349                "observation",
12350                None,
12351                "observing an event",
12352                Some(0.6),
12353                None,
12354                vec![],
12355            )
12356            .await
12357            .unwrap();
12358
12359        // Build an event directly via the store (no runtime create_event exists).
12360        let ns = tok.namespace().as_str();
12361        let event = Event::new(
12362            ns,
12363            "test_verb",
12364            EventKind::Audit,
12365            SubstrateKind::Entity,
12366            "test_actor",
12367        );
12368        let event_id = event.id;
12369        rt.events(&tok).unwrap().append_event(event).await.unwrap();
12370
12371        let result = rt
12372            .link(&tok, note.id, event_id, EdgeRelation::Annotates, 1.0, None)
12373            .await;
12374        assert!(
12375            result.is_ok(),
12376            "note→event Annotates must succeed, got {result:?}"
12377        );
12378    }
12379
12380    // Test #6: create_note with event as annotates target succeeds.
12381    #[tokio::test]
12382    async fn create_note_annotates_event_succeeds() {
12383        use khive_storage::Event;
12384        use khive_types::{EventKind, SubstrateKind};
12385
12386        let rt = rt();
12387        let tok = NamespaceToken::local();
12388        let ns = tok.namespace().as_str();
12389        let event = Event::new(
12390            ns,
12391            "test_verb",
12392            EventKind::Audit,
12393            SubstrateKind::Entity,
12394            "test_actor",
12395        );
12396        let event_id = event.id;
12397        rt.events(&tok).unwrap().append_event(event).await.unwrap();
12398
12399        let result = rt
12400            .create_note(
12401                &tok,
12402                "observation",
12403                None,
12404                "note annotating an event",
12405                Some(0.5),
12406                None,
12407                vec![event_id],
12408            )
12409            .await;
12410        assert!(
12411            result.is_ok(),
12412            "create_note with event annotates target must succeed, got {result:?}"
12413        );
12414        // Verify the annotates edge was created.
12415        let note = result.unwrap();
12416        let neighbors = rt
12417            .neighbors(
12418                &tok,
12419                note.id,
12420                Direction::Out,
12421                None,
12422                Some(vec![EdgeRelation::Annotates]),
12423            )
12424            .await
12425            .unwrap();
12426        assert_eq!(neighbors.len(), 1);
12427        assert_eq!(neighbors[0].node_id, event_id);
12428    }
12429
12430    // ---- supersedes same-substrate contract ----
12431
12432    #[tokio::test]
12433    async fn link_supersedes_note_to_note_succeeds() {
12434        let rt = rt();
12435        let tok = NamespaceToken::local();
12436        let old_note = rt
12437            .create_note(
12438                &tok,
12439                "observation",
12440                None,
12441                "old observation",
12442                Some(0.7),
12443                None,
12444                vec![],
12445            )
12446            .await
12447            .unwrap();
12448        let new_note = rt
12449            .create_note(
12450                &tok,
12451                "observation",
12452                None,
12453                "revised observation superseding the old one",
12454                Some(0.9),
12455                None,
12456                vec![],
12457            )
12458            .await
12459            .unwrap();
12460
12461        let result = rt
12462            .link(
12463                &tok,
12464                new_note.id,
12465                old_note.id,
12466                EdgeRelation::Supersedes,
12467                1.0,
12468                None,
12469            )
12470            .await;
12471        assert!(
12472            result.is_ok(),
12473            "note→note Supersedes must succeed (note supersession), got {result:?}"
12474        );
12475    }
12476
12477    #[tokio::test]
12478    async fn link_supersedes_entity_to_entity_succeeds() {
12479        let rt = rt();
12480        let tok = NamespaceToken::local();
12481        let old_entity = rt
12482            .create_entity(&tok, "concept", None, "OldConcept", None, None, vec![])
12483            .await
12484            .unwrap();
12485        let new_entity = rt
12486            .create_entity(&tok, "concept", None, "NewConcept", None, None, vec![])
12487            .await
12488            .unwrap();
12489
12490        let result = rt
12491            .link(
12492                &tok,
12493                new_entity.id,
12494                old_entity.id,
12495                EdgeRelation::Supersedes,
12496                1.0,
12497                None,
12498            )
12499            .await;
12500        assert!(
12501            result.is_ok(),
12502            "entity→entity Supersedes must succeed, got {result:?}"
12503        );
12504    }
12505
12506    #[tokio::test]
12507    async fn link_supersedes_note_to_entity_returns_invalid_input() {
12508        let rt = rt();
12509        let tok = NamespaceToken::local();
12510        let note = rt
12511            .create_note(&tok, "observation", None, "a note", Some(0.5), None, vec![])
12512            .await
12513            .unwrap();
12514        let entity = rt
12515            .create_entity(&tok, "concept", None, "SomeEntity", None, None, vec![])
12516            .await
12517            .unwrap();
12518
12519        let result = rt
12520            .link(
12521                &tok,
12522                note.id,
12523                entity.id,
12524                EdgeRelation::Supersedes,
12525                1.0,
12526                None,
12527            )
12528            .await;
12529        match result {
12530            Err(RuntimeError::InvalidInput(msg)) => {
12531                assert!(
12532                    msg.contains("same substrate") || msg.contains("same-substrate"),
12533                    "error must name the same-substrate rule: {msg}"
12534                );
12535            }
12536            other => panic!(
12537                "expected InvalidInput for note→entity Supersedes (cross-substrate), got {other:?}"
12538            ),
12539        }
12540    }
12541
12542    #[tokio::test]
12543    async fn link_supersedes_entity_to_note_returns_invalid_input() {
12544        let rt = rt();
12545        let tok = NamespaceToken::local();
12546        let entity = rt
12547            .create_entity(&tok, "concept", None, "SomeEntity", None, None, vec![])
12548            .await
12549            .unwrap();
12550        let note = rt
12551            .create_note(&tok, "observation", None, "a note", Some(0.5), None, vec![])
12552            .await
12553            .unwrap();
12554
12555        let result = rt
12556            .link(
12557                &tok,
12558                entity.id,
12559                note.id,
12560                EdgeRelation::Supersedes,
12561                1.0,
12562                None,
12563            )
12564            .await;
12565        match result {
12566            Err(RuntimeError::InvalidInput(msg)) => {
12567                assert!(
12568                    msg.contains("same substrate") || msg.contains("same-substrate"),
12569                    "error must name the same-substrate rule: {msg}"
12570                );
12571            }
12572            other => panic!(
12573                "expected InvalidInput for entity→note Supersedes (cross-substrate), got {other:?}"
12574            ),
12575        }
12576    }
12577
12578    #[tokio::test]
12579    async fn link_supersedes_event_source_returns_invalid_input() {
12580        use khive_storage::Event;
12581        use khive_types::{EventKind, SubstrateKind};
12582
12583        let rt = rt();
12584        let tok = NamespaceToken::local();
12585        let ns = tok.namespace().as_str();
12586        let event = Event::new(
12587            ns,
12588            "test_verb",
12589            EventKind::Audit,
12590            SubstrateKind::Entity,
12591            "test_actor",
12592        );
12593        let event_id = event.id;
12594        rt.events(&tok).unwrap().append_event(event).await.unwrap();
12595
12596        let entity = rt
12597            .create_entity(&tok, "concept", None, "SomeEntity", None, None, vec![])
12598            .await
12599            .unwrap();
12600
12601        let result = rt
12602            .link(
12603                &tok,
12604                event_id,
12605                entity.id,
12606                EdgeRelation::Supersedes,
12607                1.0,
12608                None,
12609            )
12610            .await;
12611        match result {
12612            Err(RuntimeError::InvalidInput(msg)) => {
12613                assert!(msg.contains("event"), "error must mention 'event': {msg}");
12614            }
12615            other => {
12616                panic!("expected InvalidInput for event source with Supersedes, got {other:?}")
12617            }
12618        }
12619    }
12620
12621    #[tokio::test]
12622    async fn link_supersedes_event_target_returns_invalid_input() {
12623        use khive_storage::Event;
12624        use khive_types::{EventKind, SubstrateKind};
12625
12626        let rt = rt();
12627        let tok = NamespaceToken::local();
12628        let ns = tok.namespace().as_str();
12629        let event = Event::new(
12630            ns,
12631            "test_verb",
12632            EventKind::Audit,
12633            SubstrateKind::Entity,
12634            "test_actor",
12635        );
12636        let event_id = event.id;
12637        rt.events(&tok).unwrap().append_event(event).await.unwrap();
12638
12639        let entity = rt
12640            .create_entity(&tok, "concept", None, "SomeEntity", None, None, vec![])
12641            .await
12642            .unwrap();
12643
12644        let result = rt
12645            .link(
12646                &tok,
12647                entity.id,
12648                event_id,
12649                EdgeRelation::Supersedes,
12650                1.0,
12651                None,
12652            )
12653            .await;
12654        match result {
12655            Err(RuntimeError::InvalidInput(msg)) => {
12656                assert!(msg.contains("event"), "error must mention 'event': {msg}");
12657            }
12658            other => {
12659                panic!("expected InvalidInput for event target with Supersedes, got {other:?}")
12660            }
12661        }
12662    }
12663
12664    #[tokio::test]
12665    async fn link_supersedes_edge_source_returns_invalid_input() {
12666        let rt = rt();
12667        let tok = NamespaceToken::local();
12668        let a = rt
12669            .create_entity(&tok, "concept", None, "A", None, None, vec![])
12670            .await
12671            .unwrap();
12672        let b = rt
12673            .create_entity(&tok, "concept", None, "B", None, None, vec![])
12674            .await
12675            .unwrap();
12676        let edge = rt
12677            .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
12678            .await
12679            .unwrap();
12680        let edge_uuid: Uuid = edge.id.into();
12681
12682        let result = rt
12683            .link(&tok, edge_uuid, a.id, EdgeRelation::Supersedes, 1.0, None)
12684            .await;
12685        match result {
12686            Err(RuntimeError::InvalidInput(msg)) => {
12687                assert!(msg.contains("source"), "error must name 'source': {msg}");
12688            }
12689            other => {
12690                panic!("expected InvalidInput for edge-uuid source with Supersedes, got {other:?}")
12691            }
12692        }
12693    }
12694
12695    #[tokio::test]
12696    async fn link_supersedes_edge_target_returns_invalid_input() {
12697        let rt = rt();
12698        let tok = NamespaceToken::local();
12699        let a = rt
12700            .create_entity(&tok, "concept", None, "A", None, None, vec![])
12701            .await
12702            .unwrap();
12703        let b = rt
12704            .create_entity(&tok, "concept", None, "B", None, None, vec![])
12705            .await
12706            .unwrap();
12707        let edge = rt
12708            .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
12709            .await
12710            .unwrap();
12711        let edge_uuid: Uuid = edge.id.into();
12712
12713        let result = rt
12714            .link(&tok, a.id, edge_uuid, EdgeRelation::Supersedes, 1.0, None)
12715            .await;
12716        match result {
12717            Err(RuntimeError::InvalidInput(msg)) => {
12718                assert!(msg.contains("target"), "error must name 'target': {msg}");
12719            }
12720            other => {
12721                panic!("expected InvalidInput for edge-uuid target with Supersedes, got {other:?}")
12722            }
12723        }
12724    }
12725
12726    #[tokio::test]
12727    async fn link_supersedes_phantom_source_returns_not_found() {
12728        let rt = rt();
12729        let tok = NamespaceToken::local();
12730        let note = rt
12731            .create_note(
12732                &tok,
12733                "observation",
12734                None,
12735                "existing note",
12736                Some(0.5),
12737                None,
12738                vec![],
12739            )
12740            .await
12741            .unwrap();
12742        let phantom = Uuid::new_v4();
12743
12744        let result = rt
12745            .link(&tok, phantom, note.id, EdgeRelation::Supersedes, 1.0, None)
12746            .await;
12747        match result {
12748            Err(RuntimeError::NotFound(msg)) => {
12749                assert!(msg.contains("source"), "error must name 'source': {msg}");
12750            }
12751            other => panic!("expected NotFound for phantom source with Supersedes, got {other:?}"),
12752        }
12753    }
12754
12755    #[tokio::test]
12756    async fn link_supersedes_phantom_target_returns_not_found() {
12757        let rt = rt();
12758        let tok = NamespaceToken::local();
12759        let note = rt
12760            .create_note(
12761                &tok,
12762                "observation",
12763                None,
12764                "existing note",
12765                Some(0.5),
12766                None,
12767                vec![],
12768            )
12769            .await
12770            .unwrap();
12771        let phantom = Uuid::new_v4();
12772
12773        let result = rt
12774            .link(&tok, note.id, phantom, EdgeRelation::Supersedes, 1.0, None)
12775            .await;
12776        match result {
12777            Err(RuntimeError::NotFound(msg)) => {
12778                assert!(msg.contains("target"), "error must name 'target': {msg}");
12779            }
12780            other => panic!("expected NotFound for phantom target with Supersedes, got {other:?}"),
12781        }
12782    }
12783
12784    /// The canonical `remember | supersedes` chain: a `supersedes` source note living
12785    /// in a different namespace than the caller must still resolve as a by-ID endpoint.
12786    #[tokio::test]
12787    async fn link_supersedes_cross_namespace_source_succeeds() {
12788        let rt = rt();
12789        let ns_a = NamespaceToken::for_namespace(Namespace::parse("ns-a").unwrap());
12790        let ns_b = NamespaceToken::for_namespace(Namespace::parse("ns-b").unwrap());
12791        let note_a = rt
12792            .create_note(
12793                &ns_a,
12794                "observation",
12795                None,
12796                "note in ns-a",
12797                Some(0.5),
12798                None,
12799                vec![],
12800            )
12801            .await
12802            .unwrap();
12803        let note_b = rt
12804            .create_note(
12805                &ns_b,
12806                "observation",
12807                None,
12808                "note in ns-b",
12809                Some(0.5),
12810                None,
12811                vec![],
12812            )
12813            .await
12814            .unwrap();
12815
12816        // From ns-a perspective, note_b is in a different namespace — by-ID resolution
12817        // finds it anyway.
12818        let result = rt
12819            .link(
12820                &ns_a,
12821                note_b.id,
12822                note_a.id,
12823                EdgeRelation::Supersedes,
12824                1.0,
12825                None,
12826            )
12827            .await;
12828        assert!(
12829            result.is_ok(),
12830            "cross-namespace supersedes source must resolve (#631), got {result:?}"
12831        );
12832    }
12833
12834    // Sanity: extends (non-annotates, non-supersedes) still requires entity→entity.
12835    #[tokio::test]
12836    async fn link_extends_note_source_still_returns_invalid_input() {
12837        let rt = rt();
12838        let tok = NamespaceToken::local();
12839        let note = rt
12840            .create_note(
12841                &tok,
12842                "observation",
12843                None,
12844                "a note that cannot be an extends source",
12845                Some(0.5),
12846                None,
12847                vec![],
12848            )
12849            .await
12850            .unwrap();
12851        let entity = rt
12852            .create_entity(&tok, "concept", None, "E", None, None, vec![])
12853            .await
12854            .unwrap();
12855
12856        let result = rt
12857            .link(&tok, note.id, entity.id, EdgeRelation::Extends, 1.0, None)
12858            .await;
12859        assert!(
12860            matches!(result, Err(RuntimeError::InvalidInput(_))),
12861            "note source with Extends must still return InvalidInput after this fix, got {result:?}"
12862        );
12863    }
12864
12865    #[tokio::test]
12866    async fn link_annotates_note_to_edge_still_succeeds_after_fix() {
12867        let rt = rt();
12868        let tok = NamespaceToken::local();
12869        let a = rt
12870            .create_entity(&tok, "concept", None, "A", None, None, vec![])
12871            .await
12872            .unwrap();
12873        let b = rt
12874            .create_entity(&tok, "concept", None, "B", None, None, vec![])
12875            .await
12876            .unwrap();
12877        let edge = rt
12878            .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
12879            .await
12880            .unwrap();
12881        let edge_uuid: Uuid = edge.id.into();
12882
12883        let note = rt
12884            .create_note(
12885                &tok,
12886                "observation",
12887                None,
12888                "annotating an edge",
12889                Some(0.5),
12890                None,
12891                vec![],
12892            )
12893            .await
12894            .unwrap();
12895
12896        let result = rt
12897            .link(&tok, note.id, edge_uuid, EdgeRelation::Annotates, 1.0, None)
12898            .await;
12899        assert!(
12900            result.is_ok(),
12901            "note→edge Annotates must still succeed after supersedes fix, got {result:?}"
12902        );
12903    }
12904
12905    // ---- Compensation-path rollback (fix/annotates) ----
12906
12907    // The compensation branch in `create_note_inner` (operations.rs) rolls back
12908    // a partial write — note row + first edge + FTS + vector — when a subsequent
12909    // link call fails. The failure trigger is a storage error (e.g. I/O failure)
12910    // that cannot occur in the in-memory runtime; this test instead exercises the
12911    // exact cleanup operations that the compensation branch performs, starting from
12912    // a manually-constructed partial state, and verifies the post-cleanup invariants.
12913    //
12914    // What this covers: the cleanup sequence (delete_edge, delete_note hard, FTS
12915    // index clean) is correct and leaves the DB in a pristine state. What it does
12916    // not cover: the trigger condition (second link failure). Storage-error injection
12917    // would require a mock GraphStore, which is beyond the current test infrastructure.
12918    #[tokio::test]
12919    async fn create_note_multi_annotates_compensation_cleanup_restores_pristine_state() {
12920        let rt = rt();
12921        let tok = NamespaceToken::local();
12922        let t1 = rt
12923            .create_entity(&tok, "concept", None, "T1", None, None, vec![])
12924            .await
12925            .unwrap();
12926
12927        // Construct the partial state that the compensation branch would encounter:
12928        // note persisted + first annotates edge created.
12929        let note = rt
12930            .create_note(
12931                &tok,
12932                "observation",
12933                None,
12934                "partial note",
12935                Some(0.5),
12936                None,
12937                vec![t1.id],
12938            )
12939            .await
12940            .unwrap();
12941
12942        // Confirm the partial state exists before compensation.
12943        let before_notes = rt.list_notes(&tok, None, 1000, 0).await.unwrap();
12944        assert_eq!(before_notes.len(), 1, "note must be present before cleanup");
12945        let before_edges = rt
12946            .neighbors(
12947                &tok,
12948                note.id,
12949                Direction::Out,
12950                None,
12951                Some(vec![EdgeRelation::Annotates]),
12952            )
12953            .await
12954            .unwrap();
12955        assert_eq!(
12956            before_edges.len(),
12957            1,
12958            "one annotates edge must exist before cleanup"
12959        );
12960        let edge_id: Uuid = before_edges[0].edge_id;
12961
12962        // Execute the same cleanup sequence that `create_note_inner`'s Err branch runs.
12963        rt.delete_edge(&tok, edge_id, true).await.unwrap();
12964        rt.delete_note(&tok, note.id, true /* hard */)
12965            .await
12966            .unwrap();
12967
12968        // Post-compensation invariants:
12969        let after_notes = rt.list_notes(&tok, None, 1000, 0).await.unwrap();
12970        assert!(
12971            after_notes.is_empty(),
12972            "compensation must remove the note row; got {after_notes:?}"
12973        );
12974        let search_hits = rt
12975            .search_notes(&tok, "partial note", None, 10, None, false, &[], None)
12976            .await
12977            .unwrap();
12978        assert!(
12979            search_hits.is_empty(),
12980            "compensation must clean the FTS index; got {search_hits:?}"
12981        );
12982        // The deleted note is no longer a valid neighbor anchor. Inspect the
12983        // surviving target to verify its incoming annotation edge was removed.
12984        let after_edges = rt
12985            .neighbors(
12986                &tok,
12987                t1.id,
12988                Direction::In,
12989                None,
12990                Some(vec![EdgeRelation::Annotates]),
12991            )
12992            .await
12993            .unwrap();
12994        assert!(
12995            after_edges.is_empty(),
12996            "compensation must remove all partial edges; got {after_edges:?}"
12997        );
12998        assert!(matches!(
12999            rt.neighbors(&tok, note.id, Direction::Out, None, None)
13000                .await,
13001            Err(RuntimeError::NotFound(_))
13002        ));
13003    }
13004
13005    // ---- Hard-delete cascade for note and edge annotation targets (fix/annotates) ----
13006
13007    // annotates is note → ANYTHING (entity, note, edge, event);
13008    // targets may be entity, edge, event, or note.
13009    // Hard-deleting any of those targets must cascade incident annotates edges.
13010    // Soft deletes leave edges (data-vs-view rule).
13011
13012    #[tokio::test]
13013    async fn annotated_entity_hard_delete_cascades_annotate_edge() {
13014        let rt = rt();
13015        let tok = NamespaceToken::local();
13016        let entity = rt
13017            .create_entity(&tok, "concept", None, "E", None, None, vec![])
13018            .await
13019            .unwrap();
13020        let note = rt
13021            .create_note(
13022                &tok,
13023                "observation",
13024                None,
13025                "note about entity",
13026                Some(0.5),
13027                None,
13028                vec![entity.id],
13029            )
13030            .await
13031            .unwrap();
13032
13033        // Confirm edge exists before delete.
13034        let before = rt
13035            .neighbors(
13036                &tok,
13037                note.id,
13038                Direction::Out,
13039                None,
13040                Some(vec![EdgeRelation::Annotates]),
13041            )
13042            .await
13043            .unwrap();
13044        assert_eq!(
13045            before.len(),
13046            1,
13047            "annotates edge must exist before entity delete"
13048        );
13049
13050        // Hard delete the entity.
13051        let deleted = rt.delete_entity(&tok, entity.id, true).await.unwrap();
13052        assert!(deleted, "entity hard delete must return true");
13053
13054        // Annotates edge must be gone.
13055        let after = rt
13056            .neighbors(
13057                &tok,
13058                note.id,
13059                Direction::Out,
13060                None,
13061                Some(vec![EdgeRelation::Annotates]),
13062            )
13063            .await
13064            .unwrap();
13065        assert!(
13066            after.is_empty(),
13067            "annotates edge must be cascaded on entity hard delete; got {after:?}"
13068        );
13069    }
13070
13071    #[tokio::test]
13072    async fn annotated_note_hard_delete_cascades_annotate_edge() {
13073        let rt = rt();
13074        let tok = NamespaceToken::local();
13075        // note_target is the thing being annotated (a note itself).
13076        let note_target = rt
13077            .create_note(
13078                &tok,
13079                "observation",
13080                None,
13081                "target note",
13082                Some(0.5),
13083                None,
13084                vec![],
13085            )
13086            .await
13087            .unwrap();
13088        // note_source annotates note_target.
13089        let note_source = rt
13090            .create_note(
13091                &tok,
13092                "insight",
13093                None,
13094                "annotation",
13095                Some(0.5),
13096                None,
13097                vec![note_target.id],
13098            )
13099            .await
13100            .unwrap();
13101
13102        let before = rt
13103            .neighbors(
13104                &tok,
13105                note_source.id,
13106                Direction::Out,
13107                None,
13108                Some(vec![EdgeRelation::Annotates]),
13109            )
13110            .await
13111            .unwrap();
13112        assert_eq!(
13113            before.len(),
13114            1,
13115            "annotates edge must exist before note delete"
13116        );
13117
13118        // Hard delete the annotation TARGET note.
13119        let deleted = rt.delete_note(&tok, note_target.id, true).await.unwrap();
13120        assert!(deleted, "note hard delete must return true");
13121
13122        // The annotates edge targeting note_target must be gone.
13123        let after = rt
13124            .neighbors(
13125                &tok,
13126                note_source.id,
13127                Direction::Out,
13128                None,
13129                Some(vec![EdgeRelation::Annotates]),
13130            )
13131            .await
13132            .unwrap();
13133        assert!(
13134            after.is_empty(),
13135            "annotates edge must be cascaded on note-target hard delete; got {after:?}"
13136        );
13137    }
13138
13139    #[tokio::test]
13140    async fn annotated_edge_delete_cascades_annotate_edge() {
13141        let rt = rt();
13142        let tok = NamespaceToken::local();
13143        let a = rt
13144            .create_entity(&tok, "concept", None, "A", None, None, vec![])
13145            .await
13146            .unwrap();
13147        let b = rt
13148            .create_entity(&tok, "concept", None, "B", None, None, vec![])
13149            .await
13150            .unwrap();
13151        // Create an edge to annotate.
13152        let base_edge = rt
13153            .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
13154            .await
13155            .unwrap();
13156        let base_edge_uuid: Uuid = base_edge.id.into();
13157
13158        // Create a note that annotates the edge.
13159        let note = rt
13160            .create_note(
13161                &tok,
13162                "observation",
13163                None,
13164                "note about edge",
13165                Some(0.5),
13166                None,
13167                vec![base_edge_uuid],
13168            )
13169            .await
13170            .unwrap();
13171
13172        let before = rt
13173            .neighbors(
13174                &tok,
13175                note.id,
13176                Direction::Out,
13177                None,
13178                Some(vec![EdgeRelation::Annotates]),
13179            )
13180            .await
13181            .unwrap();
13182        assert_eq!(
13183            before.len(),
13184            1,
13185            "annotates edge must exist before base edge delete"
13186        );
13187
13188        // Delete the base edge.
13189        let deleted = rt.delete_edge(&tok, base_edge_uuid, true).await.unwrap();
13190        assert!(deleted, "edge delete must return true");
13191
13192        // The annotates edge targeting base_edge must be gone.
13193        let after = rt
13194            .neighbors(
13195                &tok,
13196                note.id,
13197                Direction::Out,
13198                None,
13199                Some(vec![EdgeRelation::Annotates]),
13200            )
13201            .await
13202            .unwrap();
13203        assert!(
13204            after.is_empty(),
13205            "annotates edge must be cascaded on base edge delete; got {after:?}"
13206        );
13207    }
13208
13209    #[tokio::test]
13210    async fn mixed_multi_annotates_partial_target_hard_delete_leaves_remaining_edges() {
13211        let rt = rt();
13212        let tok = NamespaceToken::local();
13213        let t1 = rt
13214            .create_entity(&tok, "concept", None, "T1", None, None, vec![])
13215            .await
13216            .unwrap();
13217        let t2 = rt
13218            .create_entity(&tok, "concept", None, "T2", None, None, vec![])
13219            .await
13220            .unwrap();
13221
13222        // Note annotates both t1 and t2.
13223        let note = rt
13224            .create_note(
13225                &tok,
13226                "observation",
13227                None,
13228                "multi-target note",
13229                Some(0.5),
13230                None,
13231                vec![t1.id, t2.id],
13232            )
13233            .await
13234            .unwrap();
13235
13236        let before = rt
13237            .neighbors(
13238                &tok,
13239                note.id,
13240                Direction::Out,
13241                None,
13242                Some(vec![EdgeRelation::Annotates]),
13243            )
13244            .await
13245            .unwrap();
13246        assert_eq!(
13247            before.len(),
13248            2,
13249            "must have 2 annotates edges before any delete"
13250        );
13251
13252        // Hard delete only t1.
13253        rt.delete_entity(&tok, t1.id, true).await.unwrap();
13254
13255        // Edge to t1 must be gone, edge to t2 must remain.
13256        let after = rt
13257            .neighbors(
13258                &tok,
13259                note.id,
13260                Direction::Out,
13261                None,
13262                Some(vec![EdgeRelation::Annotates]),
13263            )
13264            .await
13265            .unwrap();
13266        assert_eq!(
13267            after.len(),
13268            1,
13269            "only the edge to t1 must be cascaded; t2 edge must remain"
13270        );
13271        assert_eq!(
13272            after[0].node_id, t2.id,
13273            "remaining annotates edge must point to t2"
13274        );
13275    }
13276
13277    #[tokio::test]
13278    async fn annotated_note_soft_delete_preserves_annotate_edge() {
13279        let rt = rt();
13280        let tok = NamespaceToken::local();
13281        let note_target = rt
13282            .create_note(&tok, "observation", None, "target", Some(0.5), None, vec![])
13283            .await
13284            .unwrap();
13285        let note_source = rt
13286            .create_note(
13287                &tok,
13288                "insight",
13289                None,
13290                "annotation",
13291                Some(0.5),
13292                None,
13293                vec![note_target.id],
13294            )
13295            .await
13296            .unwrap();
13297
13298        let before = rt
13299            .neighbors(
13300                &tok,
13301                note_source.id,
13302                Direction::Out,
13303                None,
13304                Some(vec![EdgeRelation::Annotates]),
13305            )
13306            .await
13307            .unwrap();
13308        assert_eq!(before.len(), 1);
13309        let edge_id = before[0].edge_id;
13310
13311        // Soft delete must NOT cascade edges (data-vs-view principle).
13312        let deleted = rt.delete_note(&tok, note_target.id, false).await.unwrap();
13313        assert!(deleted, "soft delete must return true");
13314
13315        // The edge itself must survive the soft delete — checked at the
13316        // storage/edge layer directly (`get_edge`), not through `neighbors()`.
13317        // `neighbors()` is a VIEW query and correctly screens
13318        // out soft-deleted note targets — so it no longer surfaces this edge
13319        // once note_target is soft-deleted, even though the edge row itself
13320        // is untouched (data-vs-view principle: the edge is data, what
13321        // `neighbors()` shows is a view decision).
13322        let edge_after = rt.get_edge(&tok, edge_id).await.unwrap();
13323        assert!(
13324            edge_after.is_some(),
13325            "soft delete must NOT cascade edges; get_edge returned None"
13326        );
13327
13328        let after = rt
13329            .neighbors(
13330                &tok,
13331                note_source.id,
13332                Direction::Out,
13333                None,
13334                Some(vec![EdgeRelation::Annotates]),
13335            )
13336            .await
13337            .unwrap();
13338        assert_eq!(
13339            after.len(),
13340            0,
13341            "#748: neighbors() must screen out the soft-deleted note target; got {after:?}"
13342        );
13343    }
13344
13345    // ---- delete_edge public-API safety ----
13346
13347    // Passing an entity/note UUID to `delete_edge` must return Ok(false) with no
13348    // side effects — it must NOT delete inbound annotates edges targeting that record.
13349    // Without the get_edge guard, the old code would cascade inbound edges before
13350    // returning false.
13351    #[tokio::test]
13352    async fn delete_edge_non_edge_uuid_has_no_side_effects() {
13353        let rt = rt();
13354        let tok = NamespaceToken::local();
13355
13356        // Create an entity that has an inbound annotates edge.
13357        let entity = rt
13358            .create_entity(&tok, "concept", None, "Target", None, None, vec![])
13359            .await
13360            .unwrap();
13361        let note = rt
13362            .create_note(
13363                &tok,
13364                "observation",
13365                None,
13366                "annotates the entity",
13367                Some(0.5),
13368                None,
13369                vec![entity.id],
13370            )
13371            .await
13372            .unwrap();
13373
13374        // Confirm the annotates edge exists.
13375        let before = rt
13376            .neighbors(
13377                &tok,
13378                note.id,
13379                Direction::Out,
13380                None,
13381                Some(vec![EdgeRelation::Annotates]),
13382            )
13383            .await
13384            .unwrap();
13385        assert_eq!(before.len(), 1, "annotates edge must exist before test");
13386        let annotates_edge_id: Uuid = before[0].edge_id;
13387
13388        // Call delete_edge with the entity UUID (NOT an edge UUID).
13389        let result = rt.delete_edge(&tok, entity.id, true).await;
13390        assert!(
13391            result.is_ok(),
13392            "delete_edge must not error on a non-edge UUID"
13393        );
13394        assert!(
13395            !result.unwrap(),
13396            "delete_edge must return false for a non-edge UUID"
13397        );
13398
13399        // The inbound annotates edge to the entity must still exist — no side effects.
13400        let after = rt
13401            .neighbors(
13402                &tok,
13403                note.id,
13404                Direction::Out,
13405                None,
13406                Some(vec![EdgeRelation::Annotates]),
13407            )
13408            .await
13409            .unwrap();
13410        assert_eq!(
13411            after.len(),
13412            1,
13413            "delete_edge with a non-edge UUID must not touch inbound annotates edges"
13414        );
13415        assert_eq!(
13416            after[0].edge_id, annotates_edge_id,
13417            "the original annotates edge must be unchanged"
13418        );
13419    }
13420
13421    // ---- create_note compensation branch ----
13422
13423    // This test injects a deterministic failure on the second `link` call inside
13424    // `create_note_inner` (the one that would create the second annotates edge).
13425    // It verifies that the compensation branch is wired — i.e. this test would
13426    // fail if the `Err(e)` rollback arm at operations.rs were deleted.
13427    //
13428    // Injection mechanism: LINK_FAIL_AFTER thread-local (ops.rs, cfg(test) only).
13429    // Setting it to 2 forces the 2nd link call to return an error.  The counter is
13430    // reset to 0 once triggered, so no other test is affected.
13431    #[tokio::test]
13432    async fn create_note_multi_annotates_second_link_failure_rolls_back_partial_write() {
13433        let rt = rt();
13434        let tok = NamespaceToken::local();
13435        let t1 = rt
13436            .create_entity(&tok, "concept", None, "T1", None, None, vec![])
13437            .await
13438            .unwrap();
13439        let t2 = rt
13440            .create_entity(&tok, "concept", None, "T2", None, None, vec![])
13441            .await
13442            .unwrap();
13443
13444        // Arm the injection: fail on the 2nd link (link_idx+1 == 2).
13445        LINK_FAIL_AFTER.with(|cell| cell.set(2));
13446
13447        let result = rt
13448            .create_note(
13449                &tok,
13450                "observation",
13451                None,
13452                "rollback target",
13453                Some(0.5),
13454                None,
13455                vec![t1.id, t2.id],
13456            )
13457            .await;
13458
13459        // The call must fail with the injected error.
13460        assert!(
13461            result.is_err(),
13462            "create_note must propagate the injected link failure"
13463        );
13464        let err_msg = result.unwrap_err().to_string();
13465        assert!(
13466            err_msg.contains("injected link failure"),
13467            "error must carry injection message; got: {err_msg}"
13468        );
13469
13470        // Compensation must have removed the note row.
13471        let notes = rt.list_notes(&tok, None, 1000, 0).await.unwrap();
13472        assert!(
13473            notes.is_empty(),
13474            "compensation must remove the note row; got {notes:?}"
13475        );
13476
13477        // FTS must have no hit for the content.
13478        let hits = rt
13479            .search_notes(&tok, "rollback target", None, 10, None, false, &[], None)
13480            .await
13481            .unwrap();
13482        assert!(
13483            hits.is_empty(),
13484            "compensation must clean FTS index; got {hits:?}"
13485        );
13486
13487        // No partial annotates edges must remain (first edge must have been deleted).
13488        let edges_from_t1 = rt
13489            .neighbors(
13490                &tok,
13491                t1.id,
13492                Direction::In,
13493                None,
13494                Some(vec![EdgeRelation::Annotates]),
13495            )
13496            .await
13497            .unwrap();
13498        let edges_from_t2 = rt
13499            .neighbors(
13500                &tok,
13501                t2.id,
13502                Direction::In,
13503                None,
13504                Some(vec![EdgeRelation::Annotates]),
13505            )
13506            .await
13507            .unwrap();
13508        assert!(
13509            edges_from_t1.is_empty(),
13510            "compensation must delete the first annotates edge; got {edges_from_t1:?}"
13511        );
13512        assert!(
13513            edges_from_t2.is_empty(),
13514            "no second annotates edge must exist; got {edges_from_t2:?}"
13515        );
13516    }
13517
13518    // Inject an FTS failure after the note row is committed and assert the note
13519    // row is removed (no stranded row). arm_fts_fail_scoped() arms the flag before
13520    // the call and it resets automatically after one trigger.
13521    #[tokio::test]
13522    async fn create_note_fts_failure_rolls_back_note_row() {
13523        let rt = rt();
13524        // Unique namespace: FTS_FAIL_NS is a namespace-keyed set, so a
13525        // concurrently running test arming a different namespace never evicts
13526        // this test's arm. The namespace still guards against a same-test
13527        // mismatch between the armed value and the note actually being created.
13528        let ns = Namespace::parse("fault-fts-rollback").unwrap();
13529        let tok = NamespaceToken::for_namespace(ns.clone());
13530
13531        let _arm = arm_fts_fail_scoped(ns.as_str());
13532
13533        let result = rt
13534            .create_note(
13535                &tok,
13536                "observation",
13537                None,
13538                "fts-fail rollback target",
13539                None,
13540                None,
13541                vec![],
13542            )
13543            .await;
13544
13545        assert!(
13546            result.is_err(),
13547            "create_note must propagate the injected FTS failure"
13548        );
13549        let err_msg = result.unwrap_err().to_string();
13550        assert!(
13551            err_msg.contains("injected FTS failure"),
13552            "error must carry injection message; got: {err_msg}"
13553        );
13554
13555        // Compensation must have removed the note row.
13556        let notes = rt.list_notes(&tok, None, 1000, 0).await.unwrap();
13557        assert!(
13558            notes.is_empty(),
13559            "compensation must remove the note row after FTS failure; got {notes:?}"
13560        );
13561    }
13562
13563    // Arming FTS_FAIL_NS on one OS thread must still fire on a `create_note`
13564    // call that runs on a genuinely different OS thread. Arms here on the
13565    // test's own (tokio current-thread) task, then hands the triggering
13566    // `create_note` call to a `std::thread::spawn` worker running its own
13567    // single-threaded tokio runtime — a stronger guarantee of thread migration
13568    // than `tokio::spawn`, which may schedule the spawned task back onto the
13569    // same worker. Proves the process-wide, namespace-keyed `FTS_FAIL_NS` set
13570    // is thread-independent.
13571    #[tokio::test]
13572    async fn create_note_fts_failure_fires_across_os_threads() {
13573        let rt = std::sync::Arc::new(rt());
13574        let ns = Namespace::parse("fault-fts-rollback-cross-thread").unwrap();
13575        let tok = NamespaceToken::for_namespace(ns.clone());
13576
13577        let _arm = arm_fts_fail_scoped(ns.as_str());
13578
13579        let thread_rt = std::sync::Arc::clone(&rt);
13580        let thread_tok = tok.clone();
13581        let result = std::thread::spawn(move || {
13582            let worker = tokio::runtime::Builder::new_current_thread()
13583                .enable_all()
13584                .build()
13585                .expect("worker runtime must build");
13586            worker.block_on(thread_rt.create_note(
13587                &thread_tok,
13588                "observation",
13589                None,
13590                "fts-fail rollback target (cross-thread)",
13591                None,
13592                None,
13593                vec![],
13594            ))
13595        })
13596        .join()
13597        .expect("worker thread must not panic");
13598
13599        assert!(
13600            result.is_err(),
13601            "create_note on a different OS thread must still observe the injected FTS failure"
13602        );
13603        let err_msg = result.unwrap_err().to_string();
13604        assert!(
13605            err_msg.contains("injected FTS failure"),
13606            "error must carry injection message; got: {err_msg}"
13607        );
13608
13609        // Compensation must have removed the note row.
13610        let notes = rt.list_notes(&tok, None, 1000, 0).await.unwrap();
13611        assert!(
13612            notes.is_empty(),
13613            "compensation must remove the note row after FTS failure; got {notes:?}"
13614        );
13615    }
13616
13617    // Inject a vector insertion failure after note row + FTS commit and assert
13618    // both the note row and the FTS document are removed (no stranded rows).
13619    // Uses a unique namespace (see create_note_fts_failure_rolls_back_note_row)
13620    // so only this test consumes its VECTOR_FAIL_NS entry.
13621    // Since the single registered provider fires embed_document before the
13622    // injection check, the injection converts the successful embedding into an
13623    // error just before the VectorStore insert, then disarms.
13624    #[tokio::test]
13625    async fn create_note_vector_failure_rolls_back_note_row_and_fts() {
13626        const MODEL: &str = "test-vec-inject";
13627        const DIMS: usize = 4;
13628
13629        let rt = KhiveRuntime::memory().unwrap();
13630        let (provider, _counter) = ConstVecProvider::new(MODEL, DIMS);
13631        rt.register_embedder(provider);
13632
13633        let ns = Namespace::parse("fault-vec-rollback").unwrap();
13634        let tok = NamespaceToken::for_namespace(ns.clone());
13635
13636        let _arm = arm_vector_fail_scoped(ns.as_str());
13637
13638        let result = rt
13639            .create_note(
13640                &tok,
13641                "observation",
13642                None,
13643                "vec-fail rollback target",
13644                None,
13645                None,
13646                vec![],
13647            )
13648            .await;
13649
13650        assert!(
13651            result.is_err(),
13652            "create_note must propagate the injected vector failure"
13653        );
13654        let err_msg = result.unwrap_err().to_string();
13655        assert!(
13656            err_msg.contains("injected vector failure"),
13657            "error must carry injection message; got: {err_msg}"
13658        );
13659
13660        // Compensation must have removed the note row.
13661        let notes = rt.list_notes(&tok, None, 1000, 0).await.unwrap();
13662        assert!(
13663            notes.is_empty(),
13664            "compensation must remove note row after vector failure; got {notes:?}"
13665        );
13666    }
13667
13668    #[tokio::test]
13669    async fn vector_failure_injections_for_distinct_namespaces_do_not_overwrite_each_other() {
13670        const MODEL: &str = "test-vec-inject-distinct-namespaces";
13671        const DIMS: usize = 4;
13672
13673        let rt_a = KhiveRuntime::memory().unwrap();
13674        let (provider_a, _counter_a) = ConstVecProvider::new(MODEL, DIMS);
13675        rt_a.register_embedder(provider_a);
13676        let ns_a = Namespace::parse("fault-vec-distinct-a").unwrap();
13677        let tok_a = NamespaceToken::for_namespace(ns_a.clone());
13678
13679        let rt_b = KhiveRuntime::memory().unwrap();
13680        let (provider_b, _counter_b) = ConstVecProvider::new(MODEL, DIMS);
13681        rt_b.register_embedder(provider_b);
13682        let ns_b = Namespace::parse("fault-vec-distinct-b").unwrap();
13683        let tok_b = NamespaceToken::for_namespace(ns_b.clone());
13684
13685        let _arm_a = arm_vector_fail_scoped(ns_a.as_str());
13686        let _arm_b = arm_vector_fail_scoped(ns_b.as_str());
13687
13688        let (result_a, result_b) = tokio::join!(
13689            rt_a.create_note(
13690                &tok_a,
13691                "observation",
13692                None,
13693                "vector failure target A",
13694                None,
13695                None,
13696                vec![],
13697            ),
13698            rt_b.create_note(
13699                &tok_b,
13700                "observation",
13701                None,
13702                "vector failure target B",
13703                None,
13704                None,
13705                vec![],
13706            ),
13707        );
13708
13709        assert!(
13710            result_a.is_err(),
13711            "namespace A must retain its pending vector failure injection"
13712        );
13713        assert!(
13714            result_b.is_err(),
13715            "namespace B must retain its pending vector failure injection"
13716        );
13717    }
13718
13719    // The `embedding_content` override must not bypass the same
13720    // FTS/vector compensation the plain `create_note` path already has —
13721    // both use `create_note_inner` underneath, but these tests exercise it
13722    // through `create_note_with_embedding_content` with a real Some(head)
13723    // override to prove the override path shares the identical rollback.
13724    #[tokio::test]
13725    async fn create_note_with_embedding_content_fts_failure_rolls_back_note_row() {
13726        let rt = rt();
13727        let ns = Namespace::parse("fault-fts-rollback-embedding-content").unwrap();
13728        let tok = NamespaceToken::for_namespace(ns.clone());
13729
13730        let _arm = arm_fts_fail_scoped(ns.as_str());
13731
13732        let full = "fts-fail rollback target with an embedding-content override";
13733        let head = &full[.."fts-fail rollback target".len()];
13734        let result = rt
13735            .create_note_with_embedding_content(
13736                &tok,
13737                "observation",
13738                None,
13739                full,
13740                Some(head),
13741                None,
13742                None,
13743                vec![],
13744            )
13745            .await;
13746
13747        assert!(
13748            result.is_err(),
13749            "create_note_with_embedding_content must propagate the injected FTS failure"
13750        );
13751        let err_msg = result.unwrap_err().to_string();
13752        assert!(
13753            err_msg.contains("injected FTS failure"),
13754            "error must carry injection message; got: {err_msg}"
13755        );
13756
13757        // Compensation must have removed the note row; a failed create must
13758        // never leave a stranded row behind just because it carried an
13759        // embedding_content override.
13760        let notes = rt.list_notes(&tok, None, 1000, 0).await.unwrap();
13761        assert!(
13762            notes.is_empty(),
13763            "compensation must remove the note row after FTS failure; got {notes:?}"
13764        );
13765    }
13766
13767    #[tokio::test]
13768    async fn create_note_with_embedding_content_vector_failure_rolls_back_note_row_and_fts() {
13769        const MODEL: &str = "test-vec-inject-embedding-content";
13770        const DIMS: usize = 4;
13771
13772        let rt = KhiveRuntime::memory().unwrap();
13773        let (provider, _counter) = ConstVecProvider::new(MODEL, DIMS);
13774        rt.register_embedder(provider);
13775
13776        let ns = Namespace::parse("fault-vec-rollback-embedding-content").unwrap();
13777        let tok = NamespaceToken::for_namespace(ns.clone());
13778
13779        let _arm = arm_vector_fail_scoped(ns.as_str());
13780
13781        let full = "vec-fail rollback target with an embedding-content override";
13782        let head = &full[.."vec-fail rollback target".len()];
13783        let result = rt
13784            .create_note_with_embedding_content(
13785                &tok,
13786                "observation",
13787                None,
13788                full,
13789                Some(head),
13790                None,
13791                None,
13792                vec![],
13793            )
13794            .await;
13795
13796        assert!(
13797            result.is_err(),
13798            "create_note_with_embedding_content must propagate the injected vector failure"
13799        );
13800        let err_msg = result.unwrap_err().to_string();
13801        assert!(
13802            err_msg.contains("injected vector failure"),
13803            "error must carry injection message; got: {err_msg}"
13804        );
13805
13806        // Compensation must have removed the note row: the ingest-layer
13807        // truncation counter only increments in the successful-create arm,
13808        // so a failed create — with or without an embedding_content override
13809        // — can never cause a spurious truncation count on the caller side.
13810        let notes = rt.list_notes(&tok, None, 1000, 0).await.unwrap();
13811        assert!(
13812            notes.is_empty(),
13813            "compensation must remove note row after vector failure; got {notes:?}"
13814        );
13815    }
13816
13817    // ---- soft-delete index cleanup tests ----
13818
13819    #[tokio::test]
13820    async fn soft_delete_entity_removes_indexes() {
13821        let rt = rt();
13822        let tok = NamespaceToken::local();
13823        let entity = rt
13824            .create_entity(
13825                &tok,
13826                "concept",
13827                None,
13828                "QuantumEntanglement",
13829                Some("unique FTS term xzqjwv for soft delete test"),
13830                None,
13831                vec![],
13832            )
13833            .await
13834            .unwrap();
13835
13836        let ns = tok.namespace().as_str().to_string();
13837
13838        let before = rt
13839            .text(&tok)
13840            .unwrap()
13841            .search(TextSearchRequest {
13842                query: "xzqjwv".to_string(),
13843                mode: TextQueryMode::Plain,
13844                filter: Some(TextFilter {
13845                    namespaces: vec![ns.clone()],
13846                    ..Default::default()
13847                }),
13848                top_k: 10,
13849                snippet_chars: 100,
13850            })
13851            .await
13852            .unwrap();
13853        assert!(
13854            before.iter().any(|h| h.subject_id == entity.id),
13855            "entity must be in FTS before soft-delete"
13856        );
13857
13858        let deleted = rt.delete_entity(&tok, entity.id, false).await.unwrap();
13859        assert!(deleted, "soft delete must return true");
13860
13861        let after = rt
13862            .text(&tok)
13863            .unwrap()
13864            .search(TextSearchRequest {
13865                query: "xzqjwv".to_string(),
13866                mode: TextQueryMode::Plain,
13867                filter: Some(TextFilter {
13868                    namespaces: vec![ns],
13869                    ..Default::default()
13870                }),
13871                top_k: 10,
13872                snippet_chars: 100,
13873            })
13874            .await
13875            .unwrap();
13876        assert!(
13877            after.iter().all(|h| h.subject_id != entity.id),
13878            "soft-deleted entity must be removed from FTS index"
13879        );
13880    }
13881
13882    #[tokio::test]
13883    async fn soft_delete_note_removes_indexes() {
13884        let rt = rt();
13885        let tok = NamespaceToken::local();
13886        let note = rt
13887            .create_note(
13888                &tok,
13889                "observation",
13890                None,
13891                "SpectralDecomposition unique term yvwkqz for soft delete test",
13892                Some(0.7),
13893                None,
13894                vec![],
13895            )
13896            .await
13897            .unwrap();
13898
13899        let before = rt
13900            .search_notes(&tok, "yvwkqz", None, 10, None, false, &[], None)
13901            .await
13902            .unwrap();
13903        assert!(
13904            before.iter().any(|h| h.note_id == note.id),
13905            "note must be in FTS before soft-delete"
13906        );
13907
13908        let deleted = rt.delete_note(&tok, note.id, false).await.unwrap();
13909        assert!(deleted, "soft delete must return true");
13910
13911        let after = rt
13912            .search_notes(&tok, "yvwkqz", None, 10, None, false, &[], None)
13913            .await
13914            .unwrap();
13915        assert!(
13916            after.iter().all(|h| h.note_id != note.id),
13917            "soft-deleted note must be removed from FTS index"
13918        );
13919    }
13920
13921    // Base endpoint allowlist: unlisted triples must fail closed.
13922    // Document->Document Extends is not in the allowlist.
13923    #[tokio::test]
13924    async fn link_extends_document_to_document_returns_invalid_input() {
13925        let rt = rt();
13926        let tok = NamespaceToken::local();
13927        let d1 = rt
13928            .create_entity(&tok, "document", None, "DocA", None, None, vec![])
13929            .await
13930            .unwrap();
13931        let d2 = rt
13932            .create_entity(&tok, "document", None, "DocB", None, None, vec![])
13933            .await
13934            .unwrap();
13935        let result = rt
13936            .link(&tok, d1.id, d2.id, EdgeRelation::Extends, 1.0, None)
13937            .await;
13938        assert!(
13939            result.is_err(),
13940            "F010: document->document Extends must be rejected by the base allowlist; \
13941             current generic entity fallthrough incorrectly accepts it"
13942        );
13943    }
13944
13945    #[tokio::test]
13946    async fn link_illegal_entity_pair_names_loaded_legal_relations() {
13947        let rt = rt();
13948        let tok = NamespaceToken::local();
13949        rt.install_edge_rules(vec![EdgeEndpointRule {
13950            relation: EdgeRelation::DependsOn,
13951            source: EndpointKind::EntityOfKind("concept"),
13952            target: EndpointKind::EntityOfKind("project"),
13953        }]);
13954        let concept = rt
13955            .create_entity(&tok, "concept", None, "Concept", None, None, vec![])
13956            .await
13957            .unwrap();
13958        let project = rt
13959            .create_entity(&tok, "project", None, "Project", None, None, vec![])
13960            .await
13961            .unwrap();
13962
13963        let error = rt
13964            .link(
13965                &tok,
13966                concept.id,
13967                project.id,
13968                EdgeRelation::CompetesWith,
13969                1.0,
13970                None,
13971            )
13972            .await
13973            .expect_err("concept competes_with project must be rejected");
13974        let message = error.to_string();
13975        assert!(
13976            message.contains(
13977                "currently legal relations for concept -> project under the loaded endpoint rules: depends_on"
13978            ),
13979            "rejection must expose the exact loaded legal set; got: {message}"
13980        );
13981    }
13982
13983    #[test]
13984    fn cross_backend_legal_set_ignores_unenforced_annotates_pack_rule() {
13985        let rt = rt();
13986        rt.install_edge_rules(vec![EdgeEndpointRule {
13987            relation: EdgeRelation::Annotates,
13988            source: EndpointKind::EntityOfKind("concept"),
13989            target: EndpointKind::EntityOfKind("project"),
13990        }]);
13991        let source_id = Uuid::new_v4();
13992        let target_id = Uuid::new_v4();
13993        let source = Resolved::Entity(Entity::new("local", "concept", "Concept"));
13994        let target = Resolved::Entity(Entity::new("local", "project", "Project"));
13995
13996        rt.validate_link_endpoints_by_resolved(
13997            source_id,
13998            target_id,
13999            EdgeRelation::Annotates,
14000            Some(&source),
14001            Some(&target),
14002        )
14003        .expect_err(
14004            "an annotates pack rule cannot override the dedicated note-source validator branch",
14005        );
14006
14007        let error = rt
14008            .validate_link_endpoints_by_resolved(
14009                source_id,
14010                target_id,
14011                EdgeRelation::CompetesWith,
14012                Some(&source),
14013                Some(&target),
14014            )
14015            .expect_err("concept competes_with project must be rejected");
14016        let message = error.to_string();
14017        assert!(
14018            message.contains(
14019                "currently legal relations for concept -> project under the loaded endpoint rules: none"
14020            ),
14021            "an unenforced entity-source annotates pack rule must not be advertised; got: {message}"
14022        );
14023        assert!(
14024            !message.contains("endpoint rules: annotates"),
14025            "the rejection must not call annotates legal when the live validator rejects it; got: {message}"
14026        );
14027    }
14028
14029    // Happy path: Concept->Concept Extends is in the base allowlist and must succeed.
14030    #[tokio::test]
14031    async fn link_extends_concept_to_concept_succeeds() {
14032        let rt = rt();
14033        let tok = NamespaceToken::local();
14034        let a = rt
14035            .create_entity(&tok, "concept", None, "CA", None, None, vec![])
14036            .await
14037            .unwrap();
14038        let b = rt
14039            .create_entity(&tok, "concept", None, "CB", None, None, vec![])
14040            .await
14041            .unwrap();
14042        let result = rt
14043            .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
14044            .await;
14045        assert!(
14046            result.is_ok(),
14047            "F010: concept->concept Extends must be allowed (base allowlist)"
14048        );
14049    }
14050
14051    // CompetesWith is symmetric; reversed pair must deduplicate to one canonical row.
14052    #[tokio::test]
14053    async fn link_symmetric_relation_canonicalizes_endpoint_order() {
14054        use khive_storage::EdgeFilter;
14055        let rt = rt();
14056        let tok = NamespaceToken::local();
14057        let a = rt
14058            .create_entity(&tok, "concept", None, "ConceptP", None, None, vec![])
14059            .await
14060            .unwrap();
14061        let b = rt
14062            .create_entity(&tok, "concept", None, "ConceptQ", None, None, vec![])
14063            .await
14064            .unwrap();
14065        // Link A->B then B->A with the same symmetric relation.
14066        rt.link(&tok, a.id, b.id, EdgeRelation::CompetesWith, 1.0, None)
14067            .await
14068            .unwrap();
14069        rt.link(&tok, b.id, a.id, EdgeRelation::CompetesWith, 1.0, None)
14070            .await
14071            .unwrap();
14072        let count = rt
14073            .graph(&tok)
14074            .unwrap()
14075            .count_edges(EdgeFilter::default())
14076            .await
14077            .unwrap();
14078        assert_eq!(
14079            count,
14080            1,
14081            "F012: CompetesWith is symmetric; A->B and B->A must deduplicate to one canonical row; \
14082             found {count} rows (canonicalization not yet implemented)"
14083        );
14084    }
14085
14086    // Supersedes: positive tests for all 5 allowed entity kinds.
14087    #[tokio::test]
14088    async fn f010_supersedes_document_to_document_allowed() {
14089        let rt = rt();
14090        let tok = NamespaceToken::local();
14091        let a = rt
14092            .create_entity(&tok, "document", None, "DocA", None, None, vec![])
14093            .await
14094            .unwrap();
14095        let b = rt
14096            .create_entity(&tok, "document", None, "DocB", None, None, vec![])
14097            .await
14098            .unwrap();
14099        let result = rt
14100            .link(&tok, b.id, a.id, EdgeRelation::Supersedes, 1.0, None)
14101            .await;
14102        assert!(
14103            result.is_ok(),
14104            "document->document Supersedes must be allowed (allowlist), got {result:?}"
14105        );
14106    }
14107
14108    #[tokio::test]
14109    async fn f010_supersedes_artifact_to_artifact_allowed() {
14110        let rt = rt();
14111        let tok = NamespaceToken::local();
14112        let a = rt
14113            .create_entity(&tok, "artifact", None, "ArtA", None, None, vec![])
14114            .await
14115            .unwrap();
14116        let b = rt
14117            .create_entity(&tok, "artifact", None, "ArtB", None, None, vec![])
14118            .await
14119            .unwrap();
14120        let result = rt
14121            .link(&tok, b.id, a.id, EdgeRelation::Supersedes, 1.0, None)
14122            .await;
14123        assert!(
14124            result.is_ok(),
14125            "artifact->artifact Supersedes must be allowed (allowlist), got {result:?}"
14126        );
14127    }
14128
14129    #[tokio::test]
14130    async fn f010_supersedes_service_to_service_allowed() {
14131        let rt = rt();
14132        let tok = NamespaceToken::local();
14133        let a = rt
14134            .create_entity(&tok, "service", None, "SvcA", None, None, vec![])
14135            .await
14136            .unwrap();
14137        let b = rt
14138            .create_entity(&tok, "service", None, "SvcB", None, None, vec![])
14139            .await
14140            .unwrap();
14141        let result = rt
14142            .link(&tok, b.id, a.id, EdgeRelation::Supersedes, 1.0, None)
14143            .await;
14144        assert!(
14145            result.is_ok(),
14146            "service->service Supersedes must be allowed (allowlist), got {result:?}"
14147        );
14148    }
14149
14150    #[tokio::test]
14151    async fn f010_supersedes_dataset_to_dataset_allowed() {
14152        let rt = rt();
14153        let tok = NamespaceToken::local();
14154        let a = rt
14155            .create_entity(&tok, "dataset", None, "DataA", None, None, vec![])
14156            .await
14157            .unwrap();
14158        let b = rt
14159            .create_entity(&tok, "dataset", None, "DataB", None, None, vec![])
14160            .await
14161            .unwrap();
14162        let result = rt
14163            .link(&tok, b.id, a.id, EdgeRelation::Supersedes, 1.0, None)
14164            .await;
14165        assert!(
14166            result.is_ok(),
14167            "dataset->dataset Supersedes must be allowed (allowlist), got {result:?}"
14168        );
14169    }
14170
14171    // Supersedes: negative tests for rejected entity kinds.
14172    #[tokio::test]
14173    async fn f010_supersedes_project_to_project_rejected() {
14174        let rt = rt();
14175        let tok = NamespaceToken::local();
14176        let a = rt
14177            .create_entity(&tok, "project", None, "ProjA", None, None, vec![])
14178            .await
14179            .unwrap();
14180        let b = rt
14181            .create_entity(&tok, "project", None, "ProjB", None, None, vec![])
14182            .await
14183            .unwrap();
14184        let result = rt
14185            .link(&tok, b.id, a.id, EdgeRelation::Supersedes, 1.0, None)
14186            .await;
14187        assert!(
14188            matches!(result, Err(RuntimeError::InvalidInput(_))),
14189            "project->project Supersedes must be rejected (not in allowlist), got {result:?}"
14190        );
14191    }
14192
14193    #[tokio::test]
14194    async fn f010_supersedes_person_to_person_rejected() {
14195        let rt = rt();
14196        let tok = NamespaceToken::local();
14197        let a = rt
14198            .create_entity(&tok, "person", None, "Alice", None, None, vec![])
14199            .await
14200            .unwrap();
14201        let b = rt
14202            .create_entity(&tok, "person", None, "Bob", None, None, vec![])
14203            .await
14204            .unwrap();
14205        let result = rt
14206            .link(&tok, b.id, a.id, EdgeRelation::Supersedes, 1.0, None)
14207            .await;
14208        assert!(
14209            matches!(result, Err(RuntimeError::InvalidInput(_))),
14210            "person->person Supersedes must be rejected (not in allowlist), got {result:?}"
14211        );
14212    }
14213
14214    #[tokio::test]
14215    async fn f010_supersedes_org_to_org_rejected() {
14216        let rt = rt();
14217        let tok = NamespaceToken::local();
14218        let a = rt
14219            .create_entity(&tok, "org", None, "OrgA", None, None, vec![])
14220            .await
14221            .unwrap();
14222        let b = rt
14223            .create_entity(&tok, "org", None, "OrgB", None, None, vec![])
14224            .await
14225            .unwrap();
14226        let result = rt
14227            .link(&tok, b.id, a.id, EdgeRelation::Supersedes, 1.0, None)
14228            .await;
14229        assert!(
14230            matches!(result, Err(RuntimeError::InvalidInput(_))),
14231            "org->org Supersedes must be rejected (not in allowlist), got {result:?}"
14232        );
14233    }
14234
14235    // Supersedes entity→entity: same kind (concept→concept) must be allowed.
14236    #[tokio::test]
14237    async fn f010_supersedes_same_kind_entity_allowed() {
14238        let rt = rt();
14239        let tok = NamespaceToken::local();
14240        let a = rt
14241            .create_entity(&tok, "concept", None, "OldV", None, None, vec![])
14242            .await
14243            .unwrap();
14244        let b = rt
14245            .create_entity(&tok, "concept", None, "NewV", None, None, vec![])
14246            .await
14247            .unwrap();
14248        let result = rt
14249            .link(&tok, b.id, a.id, EdgeRelation::Supersedes, 1.0, None)
14250            .await;
14251        assert!(
14252            result.is_ok(),
14253            "concept->concept Supersedes must be allowed by the base allowlist, got {result:?}"
14254        );
14255    }
14256
14257    // target_backend invariant: all edges written through link() must have
14258    // target_backend = None because validate_edge_relation_endpoints already ensured the
14259    // target exists locally.
14260    #[tokio::test]
14261    async fn f161_link_always_writes_null_target_backend() {
14262        let rt = rt();
14263        let tok = NamespaceToken::local();
14264        let a = rt
14265            .create_entity(&tok, "concept", None, "A", None, None, vec![])
14266            .await
14267            .unwrap();
14268        let b = rt
14269            .create_entity(&tok, "concept", None, "B", None, None, vec![])
14270            .await
14271            .unwrap();
14272        let edge = rt
14273            .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
14274            .await
14275            .unwrap();
14276        assert!(
14277            edge.target_backend.is_none(),
14278            "F161: target_backend must be None for locally-routed edges; got {:?}",
14279            edge.target_backend
14280        );
14281    }
14282
14283    // link_many must also write null target_backend for all local edges.
14284    #[tokio::test]
14285    async fn f161_link_many_always_writes_null_target_backend() {
14286        let rt = rt();
14287        let tok = NamespaceToken::local();
14288        let a = rt
14289            .create_entity(&tok, "concept", None, "A", None, None, vec![])
14290            .await
14291            .unwrap();
14292        let b = rt
14293            .create_entity(&tok, "concept", None, "B", None, None, vec![])
14294            .await
14295            .unwrap();
14296        let c = rt
14297            .create_entity(&tok, "concept", None, "C", None, None, vec![])
14298            .await
14299            .unwrap();
14300        let specs = vec![
14301            LinkSpec {
14302                namespace: None,
14303                source_id: a.id,
14304                target_id: b.id,
14305                relation: EdgeRelation::Extends,
14306                weight: 1.0,
14307                metadata: None,
14308                resurrect: false,
14309            },
14310            LinkSpec {
14311                namespace: None,
14312                source_id: a.id,
14313                target_id: c.id,
14314                relation: EdgeRelation::Enables,
14315                weight: 1.0,
14316                metadata: None,
14317                resurrect: false,
14318            },
14319        ];
14320        let edges = rt.link_many(&tok, specs).await.unwrap();
14321        for edge in &edges {
14322            assert!(
14323                edge.target_backend.is_none(),
14324                "F161: target_backend must be None for locally-routed edges in link_many; got {:?}",
14325                edge.target_backend
14326            );
14327        }
14328    }
14329
14330    // Symmetric relation neighbors: competes_with queried from the non-canonical
14331    // endpoint must still return results when direction=Out is requested.
14332    #[tokio::test]
14333    async fn f012_symmetric_neighbors_visible_from_both_endpoints() {
14334        let rt = rt();
14335        let tok = NamespaceToken::local();
14336        let a = rt
14337            .create_entity(&tok, "concept", None, "A", None, None, vec![])
14338            .await
14339            .unwrap();
14340        let b = rt
14341            .create_entity(&tok, "concept", None, "B", None, None, vec![])
14342            .await
14343            .unwrap();
14344        // Link A→B competes_with; if A.id > B.id the edge is stored as B→A (canonical).
14345        rt.link(&tok, a.id, b.id, EdgeRelation::CompetesWith, 1.0, None)
14346            .await
14347            .unwrap();
14348        // Both endpoints should see the edge regardless of direction=Out.
14349        let from_a = rt
14350            .neighbors(
14351                &tok,
14352                a.id,
14353                Direction::Out,
14354                None,
14355                Some(vec![EdgeRelation::CompetesWith]),
14356            )
14357            .await
14358            .unwrap();
14359        let from_b = rt
14360            .neighbors(
14361                &tok,
14362                b.id,
14363                Direction::Out,
14364                None,
14365                Some(vec![EdgeRelation::CompetesWith]),
14366            )
14367            .await
14368            .unwrap();
14369        assert_eq!(
14370            from_a.len(),
14371            1,
14372            "node A must see competes_with neighbor from Direction::Out (F012); got {from_a:?}"
14373        );
14374        assert_eq!(
14375            from_b.len(),
14376            1,
14377            "node B must see competes_with neighbor from Direction::Out (F012); got {from_b:?}"
14378        );
14379    }
14380
14381    // Fix 1: Supersedes entity→entity — cross-kind (concept→document) must be rejected.
14382    #[tokio::test]
14383    async fn f010_supersedes_cross_kind_entity_rejected() {
14384        let rt = rt();
14385        let tok = NamespaceToken::local();
14386        let concept = rt
14387            .create_entity(&tok, "concept", None, "MyConcept", None, None, vec![])
14388            .await
14389            .unwrap();
14390        let doc = rt
14391            .create_entity(&tok, "document", None, "MyDoc", None, None, vec![])
14392            .await
14393            .unwrap();
14394        let result = rt
14395            .link(
14396                &tok,
14397                concept.id,
14398                doc.id,
14399                EdgeRelation::Supersedes,
14400                1.0,
14401                None,
14402            )
14403            .await;
14404        assert!(
14405            matches!(result, Err(RuntimeError::InvalidInput(_))),
14406            "concept->document Supersedes must be rejected by the base allowlist, got {result:?}"
14407        );
14408    }
14409
14410    // Cross-namespace delete_note now succeeds (UUID v4 is globally unique,
14411    // no namespace isolation on by-ID ops).
14412    #[tokio::test]
14413    async fn delete_note_cross_namespace_succeeds() {
14414        let rt = rt();
14415        let ns_a = NamespaceToken::for_namespace(Namespace::parse("ns-a").unwrap());
14416        let ns_b = NamespaceToken::for_namespace(Namespace::parse("ns-b").unwrap());
14417        let note = rt
14418            .create_note(
14419                &ns_a,
14420                "observation",
14421                None,
14422                "note in ns-a",
14423                Some(0.8),
14424                None,
14425                vec![],
14426            )
14427            .await
14428            .unwrap();
14429
14430        // Delete from a different namespace must now SUCCEED.
14431        let result = rt.delete_note(&ns_b, note.id, false).await;
14432        assert!(
14433            result.unwrap(),
14434            "cross-namespace delete_note (soft) must return Ok(true)"
14435        );
14436
14437        // Note must be gone from ns-a storage after the cross-ns soft delete.
14438        let note_store = rt.notes(&ns_a).unwrap();
14439        let gone = note_store.get_note(note.id).await.unwrap();
14440        assert!(
14441            gone.is_none(),
14442            "note must be soft-deleted in its home namespace after cross-ns delete"
14443        );
14444
14445        // Hard-delete path: create a fresh note and hard-delete from foreign token.
14446        let note2 = rt
14447            .create_note(
14448                &ns_a,
14449                "observation",
14450                None,
14451                "note2 in ns-a",
14452                Some(0.5),
14453                None,
14454                vec![],
14455            )
14456            .await
14457            .unwrap();
14458        let hard_result = rt.delete_note(&ns_b, note2.id, true).await;
14459        assert!(
14460            hard_result.unwrap(),
14461            "cross-namespace hard delete_note must return Ok(true)"
14462        );
14463        let gone2 = rt
14464            .get_note_including_deleted(&ns_a, note2.id)
14465            .await
14466            .unwrap();
14467        assert!(
14468            gone2.is_none(),
14469            "hard-deleted note must not appear even in including_deleted query"
14470        );
14471    }
14472
14473    // Regression: parallel link_many calls with overlapping triples must
14474    // return the identical persisted edge ID, not locally-generated phantom IDs.
14475    //
14476    // Sequence:
14477    //   1. First link_many creates the A→B Extends edge (persisted with ID₁).
14478    //   2. Second link_many upserts the same triple (ON CONFLICT DO UPDATE keeps ID₁).
14479    //   3. Both callers must see ID₁ in their returned Vec<Edge>.
14480    #[tokio::test]
14481    async fn link_many_overlapping_triple_returns_persisted_ids() {
14482        let rt = rt();
14483        let tok = NamespaceToken::local();
14484        let a = rt
14485            .create_entity(&tok, "concept", None, "A", None, None, vec![])
14486            .await
14487            .unwrap();
14488        let b = rt
14489            .create_entity(&tok, "concept", None, "B", None, None, vec![])
14490            .await
14491            .unwrap();
14492
14493        let spec = || LinkSpec {
14494            namespace: None,
14495            source_id: a.id,
14496            target_id: b.id,
14497            relation: EdgeRelation::Extends,
14498            weight: 1.0,
14499            metadata: None,
14500            resurrect: false,
14501        };
14502
14503        // First call — creates the edge.
14504        let first = rt.link_many(&tok, vec![spec()]).await.unwrap();
14505        assert_eq!(first.len(), 1);
14506        let persisted_id: Uuid = first[0].id.into();
14507
14508        // Second call — same natural-key triple; ON CONFLICT updates, preserving the
14509        // existing row ID. link_many must read back the row and return that same ID.
14510        let second = rt.link_many(&tok, vec![spec()]).await.unwrap();
14511        assert_eq!(second.len(), 1);
14512        let second_id: Uuid = second[0].id.into();
14513
14514        assert_eq!(
14515            persisted_id, second_id,
14516            "link_many with an existing triple must return the persisted row ID ({persisted_id}), \
14517             not a new phantom ID ({second_id})"
14518        );
14519
14520        // Confirm only one edge row exists in the graph store.
14521        let count = rt
14522            .count_edges(&tok, crate::curation::EdgeListFilter::default())
14523            .await
14524            .unwrap();
14525        assert_eq!(count, 1, "upsert must not duplicate the edge row");
14526    }
14527
14528    // ── create_many: batch entity creation ───────────────────────────────────
14529
14530    #[tokio::test]
14531    async fn create_many_empty_and_invalid_batches_preserve_pending_fts_failure() {
14532        async fn assert_empty(rt: &KhiveRuntime, tok: &NamespaceToken) {
14533            assert_eq!(rt.count_entities(tok, None).await.unwrap(), 0);
14534            assert_eq!(
14535                rt.text(tok)
14536                    .unwrap()
14537                    .count(TextFilter {
14538                        namespaces: vec![tok.namespace().as_str().to_owned()],
14539                        ..Default::default()
14540                    })
14541                    .await
14542                    .unwrap(),
14543                0,
14544                "BULK_ADMISSION_NO_FTS"
14545            );
14546        }
14547
14548        let rt = rt();
14549        let ns = format!("bulk-admission-{}", Uuid::new_v4());
14550        let tok = NamespaceToken::for_namespace(Namespace::parse(&ns).unwrap());
14551        let clean = |name: &str| EntityCreateSpec {
14552            kind: "concept".into(),
14553            entity_type: None,
14554            name: name.into(),
14555            description: None,
14556            properties: None,
14557            tags: vec![],
14558        };
14559        let _arm = arm_fts_fail_many_scoped(&ns);
14560        assert!(rt.create_many(&tok, vec![]).await.unwrap().is_empty());
14561        assert_empty(&rt, &tok).await;
14562
14563        let error = rt
14564            .create_many(
14565                &tok,
14566                vec![
14567                    clean("Clean first item"),
14568                    clean("ghp_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"),
14569                ],
14570            )
14571            .await
14572            .expect_err("BULK_ADMISSION_INVALID");
14573        let RuntimeError::SecretDetected(matched) = error else {
14574            panic!("expected indexed secret refusal, got {error:?}");
14575        };
14576        assert_eq!(matched.location.as_deref(), Some("entity[1].name"));
14577        assert_empty(&rt, &tok).await;
14578
14579        let error = rt
14580            .create_many(&tok, vec![clean("Valid after refusal")])
14581            .await
14582            .expect_err("BULK_ADMISSION_FAULT_RETAINED");
14583        assert!(
14584            error.to_string().contains("atomic batch rolled back"),
14585            "{error}"
14586        );
14587        assert_empty(&rt, &tok).await;
14588
14589        let entities = rt
14590            .create_many(&tok, vec![clean("Valid after consumed fault")])
14591            .await
14592            .unwrap();
14593        assert_eq!(entities.len(), 1, "BULK_ADMISSION_EFFECT_CONTROL");
14594        assert!(rt
14595            .text(&tok)
14596            .unwrap()
14597            .get_document(&ns, entities[0].id)
14598            .await
14599            .unwrap()
14600            .is_some());
14601    }
14602
14603    #[tokio::test]
14604    async fn bulk_entity_paths_preserve_rows_fts_order_and_defer_embeddings() {
14605        for batch in [true, false] {
14606            let rt = rt();
14607            let ns = format!("bulk-plan-{}", Uuid::new_v4());
14608            let tok = NamespaceToken::for_namespace(Namespace::parse(&ns).unwrap());
14609            let captured = Arc::new(std::sync::Mutex::new(Vec::new()));
14610            rt.register_embedder(CapturingVecProvider {
14611                provider_name: "bulk-plan-test".into(),
14612                dims: 4,
14613                captured: Arc::clone(&captured),
14614            });
14615            rt.install_entity_type_validator(Arc::new(|kind, entity_type| {
14616                if kind == "concept" && entity_type == Some("algo") {
14617                    Ok(Some("algorithm".into()))
14618                } else {
14619                    Err(RuntimeError::InvalidInput("expected fixture alias".into()))
14620                }
14621            }));
14622            let specs: Vec<_> = ["Zulu concept", "Alpha concept"]
14623                .into_iter()
14624                .enumerate()
14625                .map(|(index, name)| EntityCreateSpec {
14626                    kind: "concept".into(),
14627                    entity_type: Some("algo".into()),
14628                    name: name.into(),
14629                    description: Some(format!("glimmerneedle{index}")),
14630                    properties: Some(serde_json::json!({"ordinal":index,"enabled":true})),
14631                    tags: vec![format!("tag-{index}")],
14632                })
14633                .collect();
14634            let entities = if batch {
14635                rt.create_many(&tok, specs.clone()).await.unwrap()
14636            } else {
14637                let mut entities = Vec::new();
14638                let mut plans = Vec::new();
14639                for spec in specs.clone() {
14640                    let (entity, plan) = rt.prepare_bulk_entity_plan(&tok, spec).await.unwrap();
14641                    entities.push(entity);
14642                    plans.push(plan);
14643                }
14644                assert_eq!(rt.count_entities(&tok, None).await.unwrap(), 0);
14645                match run_atomic_unit(rt.sql().as_ref(), plans).await.unwrap() {
14646                    AtomicRunOutcome::Committed { post_commit } => {
14647                        assert!(post_commit.as_slice().is_empty(), "BULK_PLAN_NO_REINDEX");
14648                    }
14649                    outcome => panic!("expected committed entity plans, got {outcome:?}"),
14650                }
14651                entities
14652            };
14653            assert_eq!(entities.len(), specs.len());
14654            for (entity, spec) in entities.iter().zip(&specs) {
14655                let stored = rt.get_entity(&tok, entity.id).await.unwrap();
14656                assert_eq!(entity.name, spec.name, "BULK_PLAN_INPUT_ORDER");
14657                assert_eq!(stored.namespace, ns);
14658                assert_eq!(stored.kind, spec.kind);
14659                assert_eq!(stored.name, spec.name);
14660                assert_eq!(stored.description, spec.description);
14661                assert_eq!(stored.properties, spec.properties);
14662                assert_eq!(stored.tags, spec.tags);
14663                assert_eq!(stored.entity_type.as_deref(), Some("algorithm"));
14664                assert_eq!(stored.version, 1);
14665                let document = rt
14666                    .text(&tok)
14667                    .unwrap()
14668                    .get_document(&ns, entity.id)
14669                    .await
14670                    .unwrap()
14671                    .expect("BULK_PLAN_FTS_VISIBLE");
14672                assert_eq!(document.title.as_deref(), Some(spec.name.as_str()));
14673                assert_eq!(
14674                    document.body,
14675                    format!("{} {}", spec.name, spec.description.as_deref().unwrap())
14676                );
14677                assert_eq!(document.tags, spec.tags);
14678                assert_eq!(document.metadata, spec.properties);
14679                assert_eq!(document.record_kind.as_deref(), Some("concept"));
14680                assert_eq!(document.updated_at.timestamp_micros(), stored.updated_at);
14681                let hits = rt
14682                    .text(&tok)
14683                    .unwrap()
14684                    .search(TextSearchRequest {
14685                        query: spec.description.clone().unwrap(),
14686                        mode: TextQueryMode::Plain,
14687                        filter: Some(TextFilter {
14688                            namespaces: vec![ns.clone()],
14689                            ..Default::default()
14690                        }),
14691                        top_k: 10,
14692                        snippet_chars: 100,
14693                    })
14694                    .await
14695                    .unwrap();
14696                assert_eq!(
14697                    hits.iter().map(|hit| hit.subject_id).collect::<Vec<_>>(),
14698                    vec![entity.id],
14699                    "BULK_PLAN_SEARCH_VISIBLE"
14700                );
14701            }
14702            assert!(
14703                captured.lock().unwrap().is_empty(),
14704                "BULK_PLAN_EMBEDDING_DEFERRED"
14705            );
14706            assert_eq!(
14707                rt.vectors_for_model(&tok, "bulk-plan-test")
14708                    .unwrap()
14709                    .info()
14710                    .await
14711                    .unwrap()
14712                    .entry_count,
14713                0
14714            );
14715            rt.reindex_entity(&tok, &entities[0]).await.unwrap();
14716            assert_eq!(
14717                captured.lock().unwrap().len(),
14718                1,
14719                "BULK_PLAN_EMBEDDER_EFFECT_CONTROL"
14720            );
14721            assert_eq!(
14722                rt.vectors_for_model(&tok, "bulk-plan-test")
14723                    .unwrap()
14724                    .info()
14725                    .await
14726                    .unwrap()
14727                    .entry_count,
14728                1
14729            );
14730        }
14731    }
14732
14733    #[tokio::test]
14734    async fn create_many_persists_all_entities() {
14735        let rt = rt();
14736        let tok = NamespaceToken::local();
14737
14738        let specs: Vec<EntityCreateSpec> = (0..5)
14739            .map(|i| EntityCreateSpec {
14740                kind: "concept".into(),
14741                entity_type: None,
14742                name: format!("BulkConcept-{i}"),
14743                description: Some(format!("desc {i}")),
14744                properties: None,
14745                tags: vec!["bulk-test".into()],
14746            })
14747            .collect();
14748
14749        let entities = rt.create_many(&tok, specs).await.unwrap();
14750        assert_eq!(entities.len(), 5, "all 5 entities must be returned");
14751
14752        // Verify each one is retrievable from storage.
14753        for entity in &entities {
14754            let fetched = rt.get_entity(&tok, entity.id).await.unwrap();
14755            assert_eq!(fetched.id, entity.id);
14756        }
14757    }
14758
14759    #[tokio::test]
14760    async fn create_many_empty_name_rejects_atomically() {
14761        let rt = rt();
14762        let tok = NamespaceToken::local();
14763
14764        let specs = vec![
14765            EntityCreateSpec {
14766                kind: "concept".into(),
14767                entity_type: None,
14768                name: "ValidEntity".into(),
14769                description: None,
14770                properties: None,
14771                tags: vec![],
14772            },
14773            EntityCreateSpec {
14774                kind: "concept".into(),
14775                entity_type: None,
14776                name: "".into(), // invalid — triggers atomic rejection
14777                description: None,
14778                properties: None,
14779                tags: vec![],
14780            },
14781        ];
14782
14783        let result = rt.create_many(&tok, specs).await;
14784        assert!(
14785            matches!(result, Err(RuntimeError::InvalidInput(_))),
14786            "empty name must produce InvalidInput error"
14787        );
14788
14789        // Nothing must have been written — list_entities returns 0 items.
14790        let rows = rt.list_entities(&tok, None, None, 100, 0).await.unwrap();
14791        assert_eq!(
14792            rows.len(),
14793            0,
14794            "atomic rejection must leave storage unchanged"
14795        );
14796    }
14797
14798    // entity_type validated at runtime layer when validator is installed.
14799    /// A gate refusal has to name the field it fired on, and the arms differ only in
14800    /// WHICH field carries the credential-shaped token. An implementation that named a
14801    /// constant location, or named the record and not the field, fails both arms; one
14802    /// that named the field and not the batch position fails the first.
14803    #[tokio::test]
14804    async fn create_many_secret_refusal_names_the_record_and_field() {
14805        let rt = rt();
14806        let tok = NamespaceToken::local();
14807        let token_span = "ghp_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
14808        let clean = |name: &str| EntityCreateSpec {
14809            kind: "concept".into(),
14810            entity_type: None,
14811            name: name.into(),
14812            description: None,
14813            properties: None,
14814            tags: vec![],
14815        };
14816
14817        let err = rt
14818            .create_many(&tok, vec![clean("FirstIsClean"), clean(token_span)])
14819            .await
14820            .expect_err("a credential-shaped name must fail the secret gate");
14821        let RuntimeError::SecretDetected(matched) = err else {
14822            panic!("expected SecretDetected, got {err:?}");
14823        };
14824        assert_eq!(
14825            matched.location.as_deref(),
14826            Some("entity[1].name"),
14827            "the refusal must name the offending record and field"
14828        );
14829
14830        let mut second = clean("SecondIsClean");
14831        second.description = Some(format!("{token_span} sitting in a description"));
14832        let err = rt
14833            .create_many(&tok, vec![clean("FirstIsClean"), second])
14834            .await
14835            .expect_err("a credential-shaped description must fail the secret gate");
14836        let RuntimeError::SecretDetected(matched) = err else {
14837            panic!("expected SecretDetected, got {err:?}");
14838        };
14839        assert_eq!(
14840            matched.location.as_deref(),
14841            Some("entity[1].description"),
14842            "same record, different field: the field half of the location must move"
14843        );
14844
14845        let count = rt.count_entities(&tok, None).await.unwrap();
14846        assert_eq!(count, 0, "a rejected batch must leave no entity behind");
14847    }
14848
14849    /// The single-record path needs this as much as the batch path: one write, several
14850    /// scanned fields, and before this the writer was told only that something in the
14851    /// payload matched.
14852    #[tokio::test]
14853    async fn create_note_secret_refusal_names_the_field() {
14854        let rt = rt();
14855        let tok = NamespaceToken::local();
14856        let token_span = "ghp_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
14857
14858        let err = rt
14859            .create_note_with_embedding_content(
14860                &tok,
14861                "observation",
14862                Some(token_span),
14863                "content with nothing credential-shaped in it",
14864                None,
14865                None,
14866                None,
14867                vec![],
14868            )
14869            .await
14870            .expect_err("a credential-shaped name must fail the secret gate");
14871        let RuntimeError::SecretDetected(matched) = err else {
14872            panic!("expected SecretDetected, got {err:?}");
14873        };
14874        assert_eq!(matched.location.as_deref(), Some("note.name"));
14875
14876        let err = rt
14877            .create_note_with_embedding_content(
14878                &tok,
14879                "observation",
14880                Some("a clean name"),
14881                &format!("{token_span} sitting in the content"),
14882                None,
14883                None,
14884                None,
14885                vec![],
14886            )
14887            .await
14888            .expect_err("a credential-shaped content must fail the secret gate");
14889        let RuntimeError::SecretDetected(matched) = err else {
14890            panic!("expected SecretDetected, got {err:?}");
14891        };
14892        assert_eq!(
14893            matched.location.as_deref(),
14894            Some("note.content"),
14895            "the location must follow the field that actually matched"
14896        );
14897    }
14898
14899    #[tokio::test]
14900    async fn create_many_rejects_unknown_entity_type_when_validator_installed() {
14901        let rt = rt();
14902        let tok = NamespaceToken::local();
14903
14904        // Install a mock validator that only accepts "algorithm" for "concept".
14905        rt.install_entity_type_validator(Arc::new(|kind, entity_type| {
14906            let Some(raw) = entity_type else {
14907                return Ok(None);
14908            };
14909            if kind == "concept" && raw == "algorithm" {
14910                return Ok(Some("algorithm".to_string()));
14911            }
14912            Err(RuntimeError::InvalidInput(format!(
14913                "unknown entity_type {raw:?} for {kind:?}; valid: algorithm"
14914            )))
14915        }));
14916
14917        let bad_spec = vec![EntityCreateSpec {
14918            kind: "concept".into(),
14919            entity_type: Some("not_a_registered_type".into()),
14920            name: "ShouldNotLand".into(),
14921            description: None,
14922            properties: None,
14923            tags: vec![],
14924        }];
14925
14926        let result = rt.create_many(&tok, bad_spec).await;
14927        assert!(
14928            matches!(result, Err(RuntimeError::InvalidInput(_))),
14929            "unknown entity_type must be rejected by the runtime-layer validator; got {result:?}"
14930        );
14931
14932        // Zero rows written — validator fires before any storage call.
14933        let rows = rt.list_entities(&tok, None, None, 100, 0).await.unwrap();
14934        assert_eq!(
14935            rows.len(),
14936            0,
14937            "validator rejection must leave storage empty"
14938        );
14939    }
14940
14941    // Valid entity_type passes through and is normalised by the validator.
14942    #[tokio::test]
14943    async fn create_many_accepts_valid_entity_type_via_validator() {
14944        let rt = rt();
14945        let tok = NamespaceToken::local();
14946
14947        rt.install_entity_type_validator(Arc::new(|kind, entity_type| {
14948            let Some(raw) = entity_type else {
14949                return Ok(None);
14950            };
14951            if kind == "concept" && raw == "algorithm" {
14952                return Ok(Some("algorithm".to_string()));
14953            }
14954            Err(RuntimeError::InvalidInput(format!(
14955                "unknown entity_type {raw:?} for {kind:?}"
14956            )))
14957        }));
14958
14959        let specs = vec![EntityCreateSpec {
14960            kind: "concept".into(),
14961            entity_type: Some("algorithm".into()),
14962            name: "BubbleSort".into(),
14963            description: None,
14964            properties: None,
14965            tags: vec![],
14966        }];
14967
14968        let entities = rt.create_many(&tok, specs).await.unwrap();
14969        assert_eq!(entities.len(), 1, "valid entity_type must be accepted");
14970        assert_eq!(
14971            entities[0].entity_type.as_deref(),
14972            Some("algorithm"),
14973            "entity_type must be stored as returned by the validator"
14974        );
14975    }
14976
14977    // FTS failure in create_many rolls back both substrates.
14978    //
14979    // Arm `arm_fts_fail_many_scoped` before the call; the FTS phase returns an injected
14980    // error; the test asserts zero rows in both `entities` and `fts_entities`.
14981    #[tokio::test]
14982    async fn create_many_fts_failure_rolls_back_both_substrates() {
14983        // Use a unique namespace so only this test consumes its failure entry.
14984        let ns = format!("fts-fail-many-{}", uuid::Uuid::new_v4().as_simple());
14985        let rt = rt();
14986        let tok = NamespaceToken::for_namespace(Namespace::parse(&ns).unwrap());
14987
14988        let specs = vec![
14989            EntityCreateSpec {
14990                kind: "concept".into(),
14991                entity_type: None,
14992                name: "FtsRollbackA".into(),
14993                description: None,
14994                properties: None,
14995                tags: vec![],
14996            },
14997            EntityCreateSpec {
14998                kind: "concept".into(),
14999                entity_type: None,
15000                name: "FtsRollbackB".into(),
15001                description: None,
15002                properties: None,
15003                tags: vec![],
15004            },
15005        ];
15006
15007        let _arm = arm_fts_fail_many_scoped(&ns);
15008        let result = rt.create_many(&tok, specs).await;
15009
15010        assert!(
15011            result.is_err(),
15012            "create_many must return Err when FTS write fails"
15013        );
15014
15015        // Entity substrate must be empty — entity rows must have been rolled back.
15016        let entity_rows = rt.list_entities(&tok, None, None, 100, 0).await.unwrap();
15017        assert_eq!(
15018            entity_rows.len(),
15019            0,
15020            "entity rows must be rolled back on FTS failure; found {entity_rows:?}"
15021        );
15022
15023        // FTS substrate must be empty — no stale fts_entities rows.
15024        let fts = rt.text(&tok).unwrap();
15025        let fts_count = fts
15026            .count(TextFilter {
15027                ids: vec![],
15028                kinds: vec![],
15029                record_kinds: vec![],
15030                namespaces: vec![ns.clone()],
15031            })
15032            .await
15033            .unwrap();
15034        assert_eq!(
15035            fts_count, 0,
15036            "fts_entities must be empty after FTS-failure rollback; found {fts_count}"
15037        );
15038    }
15039
15040    // Restore publishes the text index inside the same unit as the row (#2699).
15041    //
15042    // The soft delete removed the FTS row. Before this change the restore
15043    // committed `deleted_at=NULL` and only then reindexed, so a reindex
15044    // failure left a live row that `get` returned and `search` could not
15045    // find. The fault is armed at the post-commit step; the row and its FTS
15046    // document must already be live when the error comes back.
15047    #[tokio::test]
15048    async fn restore_note_publishes_fts_in_the_same_unit_as_the_row() {
15049        let ns = format!("restore-fts-note-{}", uuid::Uuid::new_v4().as_simple());
15050        let rt = rt();
15051        let tok = NamespaceToken::for_namespace(Namespace::parse(&ns).unwrap());
15052        let note = rt
15053            .create_note(
15054                &tok,
15055                "observation",
15056                None,
15057                "quartz tombstone restore lookup marker",
15058                None,
15059                None,
15060                vec![],
15061            )
15062            .await
15063            .unwrap();
15064        let fts = rt.text_for_notes(&tok).unwrap();
15065        let filter = || TextFilter {
15066            ids: vec![note.id],
15067            kinds: vec![],
15068            record_kinds: vec![],
15069            namespaces: vec![ns.clone()],
15070        };
15071        assert_eq!(
15072            fts.count(filter()).await.unwrap(),
15073            1,
15074            "control: the filter must see the live note's FTS row before the delete"
15075        );
15076        assert!(rt.delete_note(&tok, note.id, false).await.unwrap());
15077        assert_eq!(
15078            fts.count(filter()).await.unwrap(),
15079            0,
15080            "soft delete must remove the FTS row"
15081        );
15082
15083        let _arm = arm_fts_fail_scoped(&ns);
15084        let err = rt
15085            .restore_note(&tok, note.id)
15086            .await
15087            .expect_err("the post-commit reindex failure must surface");
15088        assert!(
15089            err.to_string().contains("is restored and text-indexed"),
15090            "the error must name the live row: {err}"
15091        );
15092
15093        let live = rt
15094            .notes(&tok)
15095            .unwrap()
15096            .get_note(note.id)
15097            .await
15098            .unwrap()
15099            .expect("the row is live after the committed unit");
15100        assert!(live.deleted_at.is_none());
15101        assert_eq!(
15102            fts.count(filter()).await.unwrap(),
15103            1,
15104            "the FTS row must be published by the restore unit, not by the reindex"
15105        );
15106        let hits = rt
15107            .search_notes(&tok, "quartz tombstone", None, 5, None, false, &[], None)
15108            .await
15109            .unwrap();
15110        assert!(
15111            hits.iter().any(|h| h.note_id == note.id),
15112            "search must find the restored note: {hits:?}"
15113        );
15114    }
15115
15116    #[tokio::test]
15117    async fn restore_entity_publishes_fts_in_the_same_unit_as_the_row() {
15118        let ns = format!("restore-fts-entity-{}", uuid::Uuid::new_v4().as_simple());
15119        let rt = rt();
15120        let tok = NamespaceToken::for_namespace(Namespace::parse(&ns).unwrap());
15121        let entity = rt
15122            .create_entity(
15123                &tok,
15124                "concept",
15125                None,
15126                "QuartzRestoreMarker",
15127                Some("tombstone restore lookup marker"),
15128                None,
15129                vec![],
15130            )
15131            .await
15132            .unwrap();
15133        let fts = rt.text(&tok).unwrap();
15134        let filter = || TextFilter {
15135            ids: vec![entity.id],
15136            kinds: vec![],
15137            record_kinds: vec![],
15138            namespaces: vec![ns.clone()],
15139        };
15140        assert_eq!(
15141            fts.count(filter()).await.unwrap(),
15142            1,
15143            "control: the filter must see the live entity's FTS row before the delete"
15144        );
15145        assert!(rt.delete_entity(&tok, entity.id, false).await.unwrap());
15146        assert_eq!(fts.count(filter()).await.unwrap(), 0);
15147
15148        let _arm = arm_fts_fail_scoped(&ns);
15149        let err = rt
15150            .restore_entity(&tok, entity.id)
15151            .await
15152            .expect_err("the post-commit reindex failure must surface");
15153        assert!(
15154            err.to_string().contains("is restored and text-indexed"),
15155            "{err}"
15156        );
15157        let live = rt.get_entity(&tok, entity.id).await.unwrap();
15158        assert!(live.deleted_at.is_none());
15159        assert_eq!(
15160            fts.count(filter()).await.unwrap(),
15161            1,
15162            "the FTS row must be published by the restore unit, not by the reindex"
15163        );
15164    }
15165
15166    #[tokio::test]
15167    async fn create_many_fts_failure_injections_for_distinct_namespaces_do_not_overwrite_each_other(
15168    ) {
15169        let rt_a = rt();
15170        let ns_a = Namespace::parse("fts-fail-many-distinct-a").unwrap();
15171        let tok_a = NamespaceToken::for_namespace(ns_a.clone());
15172        let rt_b = rt();
15173        let ns_b = Namespace::parse("fts-fail-many-distinct-b").unwrap();
15174        let tok_b = NamespaceToken::for_namespace(ns_b.clone());
15175
15176        let _arm_a = arm_fts_fail_many_scoped(ns_a.as_str());
15177        let _arm_b = arm_fts_fail_many_scoped(ns_b.as_str());
15178
15179        let (result_a, result_b) = tokio::join!(
15180            rt_a.create_many(
15181                &tok_a,
15182                vec![EntityCreateSpec {
15183                    kind: "concept".into(),
15184                    entity_type: None,
15185                    name: "FtsFailureTargetA".into(),
15186                    description: None,
15187                    properties: None,
15188                    tags: vec![],
15189                }],
15190            ),
15191            rt_b.create_many(
15192                &tok_b,
15193                vec![EntityCreateSpec {
15194                    kind: "concept".into(),
15195                    entity_type: None,
15196                    name: "FtsFailureTargetB".into(),
15197                    description: None,
15198                    properties: None,
15199                    tags: vec![],
15200                }],
15201            ),
15202        );
15203
15204        assert!(
15205            result_a.is_err(),
15206            "namespace A must retain its pending create_many FTS failure injection"
15207        );
15208        assert!(
15209            result_b.is_err(),
15210            "namespace B must retain its pending create_many FTS failure injection"
15211        );
15212    }
15213
15214    // A failure after the first entity and FTS document have been written rolls
15215    // back both substrates for the entire batch. Injected via
15216    // `arm_fts_fail_many_partial_scoped`.
15217    #[tokio::test]
15218    async fn create_many_mid_batch_storage_failure_rolls_back_both_substrates() {
15219        let ns = format!("fts-fail-partial-{}", uuid::Uuid::new_v4().as_simple());
15220        let rt = rt();
15221        let tok = NamespaceToken::for_namespace(Namespace::parse(&ns).unwrap());
15222
15223        let specs = vec![
15224            EntityCreateSpec {
15225                kind: "concept".into(),
15226                entity_type: None,
15227                name: "PartialRollbackA".into(),
15228                description: None,
15229                properties: None,
15230                tags: vec![],
15231            },
15232            EntityCreateSpec {
15233                kind: "concept".into(),
15234                entity_type: None,
15235                name: "PartialRollbackB".into(),
15236                description: None,
15237                properties: None,
15238                tags: vec![],
15239            },
15240        ];
15241
15242        let _arm = arm_fts_fail_many_partial_scoped(&ns);
15243        let result = rt.create_many(&tok, specs).await;
15244
15245        assert!(
15246            result.is_err(),
15247            "create_many must return Err when an FTS write fails mid-batch"
15248        );
15249        let error = result.unwrap_err().to_string();
15250        assert!(
15251            error.contains("atomic batch rolled back at entity index 1"),
15252            "the failure must occur inside the atomic batch after one complete row; got: {error}"
15253        );
15254
15255        // Entity substrate must be empty — entity rows must have been rolled back.
15256        let entity_rows = rt.list_entities(&tok, None, None, 100, 0).await.unwrap();
15257        assert_eq!(
15258            entity_rows.len(),
15259            0,
15260            "entity rows must be empty after a mid-batch FTS failure; found {entity_rows:?}"
15261        );
15262
15263        // FTS substrate must be empty — no stale fts_entities rows.
15264        let fts = rt.text(&tok).unwrap();
15265        let fts_count = fts
15266            .count(TextFilter {
15267                ids: vec![],
15268                kinds: vec![],
15269                record_kinds: vec![],
15270                namespaces: vec![ns.clone()],
15271            })
15272            .await
15273            .unwrap();
15274        assert_eq!(
15275            fts_count, 0,
15276            "fts_entities must be empty after a mid-batch FTS failure; found {fts_count}"
15277        );
15278    }
15279
15280    #[tokio::test]
15281    async fn create_many_fts_partial_failure_injections_for_distinct_namespaces_do_not_overwrite_each_other(
15282    ) {
15283        let rt_a = rt();
15284        let ns_a = Namespace::parse("fts-fail-many-partial-distinct-a").unwrap();
15285        let tok_a = NamespaceToken::for_namespace(ns_a.clone());
15286        let rt_b = rt();
15287        let ns_b = Namespace::parse("fts-fail-many-partial-distinct-b").unwrap();
15288        let tok_b = NamespaceToken::for_namespace(ns_b.clone());
15289
15290        let _arm_a = arm_fts_fail_many_partial_scoped(ns_a.as_str());
15291        let _arm_b = arm_fts_fail_many_partial_scoped(ns_b.as_str());
15292
15293        let (result_a, result_b) = tokio::join!(
15294            rt_a.create_many(
15295                &tok_a,
15296                vec![EntityCreateSpec {
15297                    kind: "concept".into(),
15298                    entity_type: None,
15299                    name: "FtsPartialFailureTargetA".into(),
15300                    description: None,
15301                    properties: None,
15302                    tags: vec![],
15303                }],
15304            ),
15305            rt_b.create_many(
15306                &tok_b,
15307                vec![EntityCreateSpec {
15308                    kind: "concept".into(),
15309                    entity_type: None,
15310                    name: "FtsPartialFailureTargetB".into(),
15311                    description: None,
15312                    properties: None,
15313                    tags: vec![],
15314                }],
15315            ),
15316        );
15317
15318        assert!(
15319            result_a.is_err(),
15320            "namespace A must retain its pending create_many partial FTS failure injection"
15321        );
15322        assert!(
15323            result_b.is_err(),
15324            "namespace B must retain its pending create_many partial FTS failure injection"
15325        );
15326    }
15327
15328    // ── Cross-namespace get_edge now succeeds (UUID v4 is globally unique) ──
15329
15330    #[tokio::test]
15331    async fn get_edge_cross_namespace_succeeds() {
15332        let rt = rt();
15333        let ns_a = NamespaceToken::for_namespace(Namespace::parse("ns-a").unwrap());
15334        let ns_b = NamespaceToken::for_namespace(Namespace::parse("ns-b").unwrap());
15335
15336        let src = rt
15337            .create_entity(&ns_a, "concept", None, "Src", None, None, vec![])
15338            .await
15339            .unwrap();
15340        let tgt = rt
15341            .create_entity(&ns_a, "concept", None, "Tgt", None, None, vec![])
15342            .await
15343            .unwrap();
15344        let edge = rt
15345            .link(&ns_a, src.id, tgt.id, EdgeRelation::Extends, 1.0, None)
15346            .await
15347            .unwrap();
15348
15349        // Visible from own namespace.
15350        let own_ns = rt.get_edge(&ns_a, Uuid::from(edge.id)).await;
15351        assert!(
15352            own_ns.is_ok() && own_ns.unwrap().is_some(),
15353            "edge must be visible in its own namespace"
15354        );
15355
15356        // Foreign namespace must now SUCCEED: by-ID get is namespace-agnostic.
15357        let cross_ns = rt.get_edge(&ns_b, Uuid::from(edge.id)).await;
15358        assert!(
15359            matches!(cross_ns, Ok(Some(_))),
15360            "cross-namespace get_edge must return Ok(Some(_)) after PR-A1, got {cross_ns:?}"
15361        );
15362
15363        // Absent edge UUID still returns None regardless of token namespace.
15364        let absent = rt.get_edge(&ns_b, Uuid::new_v4()).await;
15365        assert!(
15366            matches!(absent, Ok(None)),
15367            "absent edge must return Ok(None), got {absent:?}"
15368        );
15369    }
15370
15371    // `deleted_entity_ids` now runs its query on the bounded reader pool
15372    // (same as any other read) instead of a dedicated standalone connection.
15373    // A saturated pool must surface the retryable `AdmissionTimeout`, not
15374    // silently report "nothing is deleted" — the old `if let Ok(...) = ...`
15375    // best-effort shape would have returned `Ok(HashSet::new())` here,
15376    // which would let `neighbors`/`traverse` return soft-deleted nodes
15377    // instead of the admission failure.
15378    //
15379    // A saturation broad enough to also block `substrate_exists_in_ns` (the
15380    // check `traverse` runs before it ever reaches `deleted_entity_ids`)
15381    // would already have failed the traversal before my fix, since that
15382    // earlier read already propagates its error correctly — it would not
15383    // isolate this specific defect. This test instead calls
15384    // `deleted_entity_ids` directly, which is the exact unit whose error
15385    // was swallowed.
15386    #[tokio::test]
15387    async fn deleted_entity_ids_propagates_reader_pool_admission_timeout_instead_of_swallowing_it()
15388    {
15389        let dir = tempfile::tempdir().unwrap();
15390        let db_path = dir.path().join("deleted-entity-ids-saturation.db");
15391        let rt = KhiveRuntime::new_for_test(crate::config::RuntimeConfig {
15392            db_path: Some(db_path),
15393            ..crate::config::RuntimeConfig::no_embeddings()
15394        })
15395        .expect("file-backed runtime construction must succeed");
15396        let ns = NamespaceToken::for_namespace(Namespace::parse("saturation-ns").unwrap());
15397        let a = rt
15398            .create_entity(&ns, "concept", None, "A", None, None, vec![])
15399            .await
15400            .unwrap();
15401
15402        let pool = rt.backend().pool_arc();
15403        let capacity = pool.reader_acquisition_snapshot().reader_admission_capacity;
15404        let held: Vec<_> = (0..capacity)
15405            .map(|_| pool.reader().expect("hold every pooled reader"))
15406            .collect();
15407
15408        let result = rt.deleted_entity_ids(vec![a.id]).await;
15409        assert!(
15410            matches!(
15411                result,
15412                Err(RuntimeError::Storage(
15413                    khive_storage::StorageError::AdmissionTimeout { .. }
15414                ))
15415            ),
15416            "a saturated reader pool must surface AdmissionTimeout, not an empty deleted \
15417             set; got {result:?}"
15418        );
15419
15420        drop(held);
15421    }
15422
15423    // A full UUID identifies the root by ID, while traversal edges still come
15424    // only from the caller's visible namespaces.
15425    #[tokio::test]
15426    async fn traverse_foreign_full_uuid_root_uses_caller_edge_scope() {
15427        use khive_storage::types::TraversalOptions;
15428
15429        let rt = rt();
15430        let ns_a = NamespaceToken::for_namespace(Namespace::parse("ns-a").unwrap());
15431        let ns_b = NamespaceToken::for_namespace(Namespace::parse("ns-b").unwrap());
15432
15433        let a = rt
15434            .create_entity(&ns_a, "concept", None, "A", None, None, vec![])
15435            .await
15436            .unwrap();
15437        let owner_target = rt
15438            .create_entity(&ns_a, "concept", None, "B", None, None, vec![])
15439            .await
15440            .unwrap();
15441        let caller_target = rt
15442            .create_entity(&ns_b, "concept", None, "C", None, None, vec![])
15443            .await
15444            .unwrap();
15445        rt.link(
15446            &ns_a,
15447            a.id,
15448            owner_target.id,
15449            EdgeRelation::Extends,
15450            1.0,
15451            None,
15452        )
15453        .await
15454        .unwrap();
15455        rt.link(
15456            &ns_b,
15457            a.id,
15458            caller_target.id,
15459            EdgeRelation::Extends,
15460            1.0,
15461            None,
15462        )
15463        .await
15464        .unwrap();
15465
15466        let request = TraversalRequest {
15467            roots: vec![a.id],
15468            options: TraversalOptions {
15469                max_depth: 1,
15470                direction: Direction::Out,
15471                ..Default::default()
15472            },
15473            include_roots: false,
15474            include_properties: false,
15475            execution_budget: Default::default(),
15476        };
15477        let paths = rt
15478            .traverse(&ns_b, request.clone())
15479            .await
15480            .expect("foreign full-UUID root must be accepted");
15481        assert_eq!(paths.len(), 1);
15482        assert_eq!(paths[0].nodes.len(), 1);
15483        assert_eq!(paths[0].nodes[0].node_id, caller_target.id);
15484
15485        let missing = Uuid::new_v4();
15486        let error = rt
15487            .traverse(
15488                &ns_b,
15489                TraversalRequest {
15490                    roots: vec![a.id, missing],
15491                    ..request
15492                },
15493            )
15494            .await
15495            .unwrap_err();
15496        assert!(matches!(
15497            error,
15498            RuntimeError::NotFound(message) if message.contains(&missing.to_string())
15499        ));
15500    }
15501
15502    // ── Single root visible in multiple namespaces must yield exactly one
15503    //    traversal object (see merge_traversal_paths_by_root) ─────────────
15504    #[tokio::test]
15505    async fn traverse_single_root_across_visible_namespaces_yields_one_path() {
15506        use khive_storage::types::TraversalOptions;
15507
15508        let rt = rt();
15509        let owner = NamespaceToken::for_namespace(Namespace::parse("owner-ns").unwrap());
15510        let a = rt
15511            .create_entity(&owner, "concept", None, "A", None, None, vec![])
15512            .await
15513            .unwrap();
15514        let b = rt
15515            .create_entity(&owner, "concept", None, "B", None, None, vec![])
15516            .await
15517            .unwrap();
15518        rt.link(&owner, a.id, b.id, EdgeRelation::Extends, 1.0, None)
15519            .await
15520            .unwrap();
15521
15522        // Token whose primary namespace ("caller-ns") does not own the root,
15523        // but whose visible set also includes "owner-ns" (where the root and
15524        // its edge actually live) — the shape produced by pack.rs always
15525        // widening visibility to include `local`.
15526        let caller = NamespaceToken::mint_with_visibility(
15527            Namespace::parse("caller-ns").unwrap(),
15528            vec![Namespace::parse("owner-ns").unwrap()],
15529            ActorRef::anonymous(),
15530        );
15531        assert_eq!(caller.visible_namespaces().len(), 2);
15532
15533        let result = rt
15534            .traverse(
15535                &caller,
15536                TraversalRequest {
15537                    roots: vec![a.id],
15538                    options: TraversalOptions {
15539                        max_depth: 1,
15540                        direction: Direction::Out,
15541                        ..Default::default()
15542                    },
15543                    include_roots: true,
15544                    include_properties: false,
15545                    execution_budget: Default::default(),
15546                },
15547            )
15548            .await
15549            .unwrap();
15550
15551        assert_eq!(
15552            result.len(),
15553            1,
15554            "one root visible across 2 namespaces must yield exactly one \
15555             GraphPath, got {result:#?}"
15556        );
15557        assert_eq!(result[0].root_id, a.id);
15558        let node_ids: std::collections::HashSet<Uuid> =
15559            result[0].nodes.iter().map(|n| n.node_id).collect();
15560        assert!(node_ids.contains(&a.id));
15561        assert!(
15562            node_ids.contains(&b.id),
15563            "merged path must retain the neighbor discovered in the owning \
15564             namespace, got {result:#?}"
15565        );
15566    }
15567
15568    /// Namespace fan-out clones one request but must not clone a fresh work
15569    /// allowance. With one adjacency row in each visible namespace, a one-row
15570    /// shared budget admits the first and makes the later namespace fail the
15571    /// whole operation instead of returning a partial merged path.
15572    #[tokio::test]
15573    async fn traverse_visible_namespaces_share_one_work_budget() {
15574        use khive_storage::types::{TraversalExecutionBudget, TraversalOptions};
15575
15576        let rt = rt();
15577        let ns_a = Namespace::parse("traverse-budget-a").unwrap();
15578        let ns_b = Namespace::parse("traverse-budget-b").unwrap();
15579        let tok_a = NamespaceToken::for_namespace(ns_a.clone());
15580        let tok_b = NamespaceToken::for_namespace(ns_b.clone());
15581        let visible = NamespaceToken::mint_with_visibility(ns_a, vec![ns_b], ActorRef::anonymous());
15582
15583        let root = rt
15584            .create_entity(&tok_a, "concept", None, "BudgetRoot", None, None, vec![])
15585            .await
15586            .unwrap();
15587        let child_a = rt
15588            .create_entity(&tok_a, "concept", None, "BudgetChildA", None, None, vec![])
15589            .await
15590            .unwrap();
15591        let child_b = rt
15592            .create_entity(&tok_b, "concept", None, "BudgetChildB", None, None, vec![])
15593            .await
15594            .unwrap();
15595        rt.link(
15596            &tok_a,
15597            root.id,
15598            child_a.id,
15599            EdgeRelation::Extends,
15600            1.0,
15601            None,
15602        )
15603        .await
15604        .unwrap();
15605        rt.link(
15606            &tok_b,
15607            root.id,
15608            child_b.id,
15609            EdgeRelation::Extends,
15610            1.0,
15611            None,
15612        )
15613        .await
15614        .unwrap();
15615
15616        let result = rt
15617            .traverse(
15618                &visible,
15619                TraversalRequest {
15620                    roots: vec![root.id],
15621                    options: TraversalOptions {
15622                        max_depth: 1,
15623                        direction: Direction::Out,
15624                        relations: None,
15625                        min_weight: None,
15626                        limit: Some(2),
15627                    },
15628                    include_roots: false,
15629                    include_properties: false,
15630                    execution_budget: TraversalExecutionBudget::new(
15631                        1,
15632                        std::time::Duration::from_secs(5),
15633                    ),
15634                },
15635            )
15636            .await;
15637
15638        assert!(matches!(
15639            result,
15640            Err(RuntimeError::Storage(khive_storage::StorageError::InvalidInput {
15641                message,
15642                ..
15643            })) if message.contains("work budget exceeded after 1 adjacency rows")
15644        ));
15645    }
15646
15647    // ── Multi-root traverse: one object per distinct root, including a
15648    //    root supplied both as itself and as a duplicate re-resolution ────
15649    #[tokio::test]
15650    async fn traverse_multi_root_one_path_per_distinct_root() {
15651        use khive_storage::types::TraversalOptions;
15652
15653        let rt = rt();
15654        let owner = NamespaceToken::for_namespace(Namespace::parse("owner-ns2").unwrap());
15655        let a = rt
15656            .create_entity(&owner, "concept", None, "A", None, None, vec![])
15657            .await
15658            .unwrap();
15659        let c = rt
15660            .create_entity(&owner, "concept", None, "C", None, None, vec![])
15661            .await
15662            .unwrap();
15663
15664        // `a` appears twice in the roots list — this is what the pack
15665        // handler produces when a caller passes the same root once as a
15666        // short prefix and once as the full UUID: both resolve to the same
15667        // `Uuid` value by the time the request reaches the runtime.
15668        let result = rt
15669            .traverse(
15670                &owner,
15671                TraversalRequest {
15672                    roots: vec![a.id, a.id, c.id],
15673                    options: TraversalOptions {
15674                        max_depth: 1,
15675                        direction: Direction::Out,
15676                        ..Default::default()
15677                    },
15678                    include_roots: true,
15679                    include_properties: false,
15680                    execution_budget: Default::default(),
15681                },
15682            )
15683            .await
15684            .unwrap();
15685
15686        let root_ids: Vec<Uuid> = result.iter().map(|p| p.root_id).collect();
15687        assert_eq!(
15688            root_ids.len(),
15689            2,
15690            "duplicate root value must not produce a duplicate GraphPath, got {result:#?}"
15691        );
15692        assert!(root_ids.contains(&a.id));
15693        assert!(root_ids.contains(&c.id));
15694    }
15695
15696    // ── Note-kind nodes reached via traversal must use the same enrichment
15697    //    shape as neighbors, without restoring per-namespace phantom paths ──
15698    #[tokio::test]
15699    async fn traverse_note_node_matches_neighbors_in_one_path() {
15700        use khive_storage::types::TraversalOptions;
15701
15702        let rt = rt();
15703        let owner = NamespaceToken::for_namespace(Namespace::parse("owner-ns3").unwrap());
15704        let a = rt
15705            .create_entity(&owner, "concept", None, "A", None, None, vec![])
15706            .await
15707            .unwrap();
15708        let note = rt
15709            .create_note(
15710                &owner,
15711                "observation",
15712                Some("TraversalNote"),
15713                "note body",
15714                None,
15715                None,
15716                vec![a.id],
15717            )
15718            .await
15719            .unwrap();
15720
15721        let caller = NamespaceToken::mint_with_visibility(
15722            Namespace::parse("caller-ns3").unwrap(),
15723            vec![Namespace::parse("owner-ns3").unwrap()],
15724            ActorRef::anonymous(),
15725        );
15726        let neighbors = rt
15727            .neighbors(
15728                &caller,
15729                a.id,
15730                Direction::In,
15731                None,
15732                Some(vec![EdgeRelation::Annotates]),
15733            )
15734            .await
15735            .unwrap();
15736        let note_hit = neighbors
15737            .iter()
15738            .find(|hit| hit.node_id == note.id)
15739            .unwrap_or_else(|| panic!("note must be present in neighbors, got {neighbors:#?}"));
15740
15741        let result = rt
15742            .traverse(
15743                &caller,
15744                TraversalRequest {
15745                    roots: vec![a.id],
15746                    options: TraversalOptions {
15747                        max_depth: 1,
15748                        direction: Direction::In,
15749                        relations: Some(vec![EdgeRelation::Annotates]),
15750                        ..Default::default()
15751                    },
15752                    include_roots: false,
15753                    include_properties: false,
15754                    execution_budget: Default::default(),
15755                },
15756            )
15757            .await
15758            .unwrap();
15759
15760        assert_eq!(
15761            result.len(),
15762            1,
15763            "one requested root must yield one path across the caller and owner namespaces"
15764        );
15765        let note_node = result[0]
15766            .nodes
15767            .iter()
15768            .find(|n| n.node_id == note.id)
15769            .unwrap_or_else(|| panic!("note must be present in traversal nodes, got {result:#?}"));
15770        assert_eq!(note_node.name.as_deref(), note_hit.name.as_deref());
15771        assert_eq!(note_node.kind.as_deref(), note_hit.kind.as_deref());
15772        assert_eq!(note_node.name.as_deref(), Some("TraversalNote"));
15773        assert_eq!(note_node.kind.as_deref(), Some("observation"));
15774    }
15775
15776    // ── A nameless annotation note reached via traversal must fall back to
15777    //    the same `[kind]` placeholder that `neighbors` produces ──
15778    #[tokio::test]
15779    async fn traverse_nameless_note_falls_back_to_bracketed_kind() {
15780        use khive_storage::types::TraversalOptions;
15781
15782        let rt = rt();
15783        let owner = NamespaceToken::for_namespace(Namespace::parse("owner-ns4").unwrap());
15784        let a = rt
15785            .create_entity(&owner, "concept", None, "A", None, None, vec![])
15786            .await
15787            .unwrap();
15788        let note = rt
15789            .create_note(
15790                &owner,
15791                "observation",
15792                None,
15793                "note body",
15794                None,
15795                None,
15796                vec![a.id],
15797            )
15798            .await
15799            .unwrap();
15800
15801        let caller = NamespaceToken::mint_with_visibility(
15802            Namespace::parse("caller-ns4").unwrap(),
15803            vec![Namespace::parse("owner-ns4").unwrap()],
15804            ActorRef::anonymous(),
15805        );
15806        let neighbors = rt
15807            .neighbors(
15808                &caller,
15809                a.id,
15810                Direction::In,
15811                None,
15812                Some(vec![EdgeRelation::Annotates]),
15813            )
15814            .await
15815            .unwrap();
15816        let note_hit = neighbors
15817            .iter()
15818            .find(|hit| hit.node_id == note.id)
15819            .unwrap_or_else(|| panic!("note must be present in neighbors, got {neighbors:#?}"));
15820
15821        let result = rt
15822            .traverse(
15823                &caller,
15824                TraversalRequest {
15825                    roots: vec![a.id],
15826                    options: TraversalOptions {
15827                        max_depth: 1,
15828                        direction: Direction::In,
15829                        relations: Some(vec![EdgeRelation::Annotates]),
15830                        ..Default::default()
15831                    },
15832                    include_roots: false,
15833                    include_properties: false,
15834                    execution_budget: Default::default(),
15835                },
15836            )
15837            .await
15838            .unwrap();
15839
15840        let note_node = result[0]
15841            .nodes
15842            .iter()
15843            .find(|n| n.node_id == note.id)
15844            .unwrap_or_else(|| panic!("note must be present in traversal nodes, got {result:#?}"));
15845        assert_eq!(note_node.name.as_deref(), note_hit.name.as_deref());
15846        assert_eq!(note_node.kind.as_deref(), note_hit.kind.as_deref());
15847        assert_eq!(note_node.name.as_deref(), Some("[observation]"));
15848        assert_eq!(note_node.kind.as_deref(), Some("observation"));
15849    }
15850
15851    // ---- purge cascade must include already-soft-deleted edges ----
15852    //
15853    // Hard delete must cascade ALL incident edges synchronously. A cascade driven
15854    // through `neighbors()`, which filters `deleted_at IS NULL`, would let incident
15855    // edges that were already soft-deleted survive endpoint purge as dangling rows.
15856    // `purge_incident_edges` issues a single DELETE without a `deleted_at` guard.
15857
15858    /// Count ALL `graph_edges` rows for a given UUID (source OR target), including soft-deleted.
15859    async fn count_all_incident_edges(rt: &KhiveRuntime, node_id: Uuid, ns: &str) -> u64 {
15860        let mut reader = rt.sql().reader().await.expect("sql reader must open");
15861        let row = reader
15862            .query_scalar(SqlStatement {
15863                sql: "SELECT COUNT(*) FROM graph_edges \
15864                      WHERE namespace = ?1 AND (source_id = ?2 OR target_id = ?2)"
15865                    .into(),
15866                params: vec![
15867                    SqlValue::Text(ns.to_string()),
15868                    SqlValue::Text(node_id.to_string()),
15869                ],
15870                label: Some("count_all_incident_edges".into()),
15871            })
15872            .await
15873            .expect("count query must succeed");
15874        match row {
15875            Some(SqlValue::Integer(n)) => n as u64,
15876            _ => panic!("count must return an integer"),
15877        }
15878    }
15879
15880    async fn lineage_warning_events(
15881        rt: &KhiveRuntime,
15882        tok: &NamespaceToken,
15883        target_id: Uuid,
15884    ) -> Vec<Event> {
15885        rt.events(tok)
15886            .expect("event store")
15887            .query_events(
15888                EventFilter {
15889                    kinds: vec![EventKind::Audit],
15890                    ..Default::default()
15891                },
15892                PageRequest::default(),
15893            )
15894            .await
15895            .expect("query warning events")
15896            .items
15897            .into_iter()
15898            .filter(|event| event.target_id == Some(target_id))
15899            .collect()
15900    }
15901
15902    #[tokio::test]
15903    async fn hard_delete_emits_relation_specific_lineage_warnings_only() {
15904        let rt = rt();
15905        let tok = NamespaceToken::local();
15906        let doomed = rt
15907            .create_entity(&tok, "document", None, "doomed", None, None, vec![])
15908            .await
15909            .unwrap();
15910
15911        let provenance_source = rt
15912            .create_entity(&tok, "artifact", None, "source", None, None, vec![])
15913            .await
15914            .unwrap();
15915        rt.link(
15916            &tok,
15917            provenance_source.id,
15918            doomed.id,
15919            EdgeRelation::DerivedFrom,
15920            1.0,
15921            None,
15922        )
15923        .await
15924        .unwrap();
15925
15926        for (kind, name, relation) in [
15927            ("document", "old", EdgeRelation::Supersedes),
15928            ("document", "next", EdgeRelation::Precedes),
15929            ("concept", "supported", EdgeRelation::Supports),
15930            ("concept", "refuted", EdgeRelation::Refutes),
15931        ] {
15932            let target = rt
15933                .create_entity(&tok, kind, None, name, None, None, vec![])
15934                .await
15935                .unwrap();
15936            rt.link(&tok, doomed.id, target.id, relation, 1.0, None)
15937                .await
15938                .unwrap();
15939        }
15940        let unrelated = rt
15941            .create_entity(&tok, "concept", None, "unrelated", None, None, vec![])
15942            .await
15943            .unwrap();
15944        rt.link(
15945            &tok,
15946            unrelated.id,
15947            doomed.id,
15948            EdgeRelation::IntroducedBy,
15949            1.0,
15950            None,
15951        )
15952        .await
15953        .unwrap();
15954
15955        assert!(rt.delete_entity(&tok, doomed.id, false).await.unwrap());
15956        assert!(
15957            lineage_warning_events(&rt, &tok, doomed.id)
15958                .await
15959                .is_empty(),
15960            "soft delete must not emit cascade-loss warnings"
15961        );
15962
15963        assert!(rt.delete_entity(&tok, doomed.id, true).await.unwrap());
15964        let warnings = lineage_warning_events(&rt, &tok, doomed.id).await;
15965        assert_eq!(warnings.len(), 5);
15966
15967        let mut actual = warnings
15968            .iter()
15969            .map(|event| {
15970                assert_eq!(event.substrate, SubstrateKind::Entity);
15971                assert_eq!(event.payload["severity"], "warning");
15972                assert_eq!(event.payload["deleted_id"], doomed.id.to_string());
15973                assert_eq!(event.payload["edge_count"], 1);
15974                assert_eq!(event.payload["edges"].as_array().unwrap().len(), 1);
15975                (
15976                    event.payload["relation"].as_str().unwrap().to_string(),
15977                    event.payload["warning"].as_str().unwrap().to_string(),
15978                )
15979            })
15980            .collect::<Vec<_>>();
15981        actual.sort();
15982        assert_eq!(
15983            actual,
15984            vec![
15985                ("derived_from".into(), "provenance_loss".into()),
15986                ("precedes".into(), "temporal_sequence_loss".into()),
15987                ("refutes".into(), "evidential_link_loss".into()),
15988                ("supersedes".into(), "replacement_lineage_loss".into()),
15989                ("supports".into(), "evidential_link_loss".into()),
15990            ]
15991        );
15992        assert!(warnings
15993            .iter()
15994            .all(|event| event.payload["relation"] != "introduced_by"));
15995    }
15996
15997    #[tokio::test]
15998    async fn hard_delete_lineage_warning_is_filterable_by_caller_actor() {
15999        let rt = rt();
16000        let tok = NamespaceToken::mint_authorized(
16001            Namespace::local(),
16002            ActorRef::new("agent", "lineage-deleter"),
16003        );
16004        let expected_actor = "agent:lineage-deleter";
16005        let doomed = rt
16006            .create_entity(&tok, "document", None, "actor-doomed", None, None, vec![])
16007            .await
16008            .unwrap();
16009        let source = rt
16010            .create_entity(&tok, "artifact", None, "actor-source", None, None, vec![])
16011            .await
16012            .unwrap();
16013        rt.link(
16014            &tok,
16015            source.id,
16016            doomed.id,
16017            EdgeRelation::DerivedFrom,
16018            1.0,
16019            None,
16020        )
16021        .await
16022        .unwrap();
16023
16024        assert!(rt.delete_entity(&tok, doomed.id, true).await.unwrap());
16025
16026        let warnings = rt
16027            .events(&tok)
16028            .expect("event store")
16029            .query_events(
16030                EventFilter {
16031                    kinds: vec![EventKind::Audit],
16032                    actors: vec![expected_actor.to_string()],
16033                    ..Default::default()
16034                },
16035                PageRequest::default(),
16036            )
16037            .await
16038            .expect("query actor-filtered warning events")
16039            .items
16040            .into_iter()
16041            .filter(|event| event.target_id == Some(doomed.id))
16042            .collect::<Vec<_>>();
16043        assert_eq!(warnings.len(), 1);
16044        assert_eq!(warnings[0].actor, expected_actor);
16045        assert_eq!(warnings[0].payload["relation"], "derived_from");
16046    }
16047
16048    #[tokio::test]
16049    async fn hard_delete_warns_for_tombstoned_protected_incident_edge() {
16050        let rt = rt();
16051        let tok = NamespaceToken::local();
16052        let doomed = rt
16053            .create_entity(
16054                &tok,
16055                "document",
16056                None,
16057                "tombstoned-edge-doomed",
16058                None,
16059                None,
16060                vec![],
16061            )
16062            .await
16063            .unwrap();
16064        let source = rt
16065            .create_entity(
16066                &tok,
16067                "artifact",
16068                None,
16069                "tombstoned-edge-source",
16070                None,
16071                None,
16072                vec![],
16073            )
16074            .await
16075            .unwrap();
16076        let edge = rt
16077            .link(
16078                &tok,
16079                source.id,
16080                doomed.id,
16081                EdgeRelation::DerivedFrom,
16082                1.0,
16083                None,
16084            )
16085            .await
16086            .unwrap();
16087        let edge_id = Uuid::from(edge.id);
16088        assert!(rt.delete_edge(&tok, edge_id, false).await.unwrap());
16089
16090        assert!(rt.delete_entity(&tok, doomed.id, true).await.unwrap());
16091
16092        let warnings = lineage_warning_events(&rt, &tok, doomed.id).await;
16093        assert_eq!(warnings.len(), 1);
16094        assert_eq!(warnings[0].payload["edge_count"], 1);
16095        let warned_edge = &warnings[0].payload["edges"].as_array().unwrap()[0];
16096        assert_eq!(warned_edge["id"], edge_id.to_string());
16097        assert!(
16098            warned_edge["deleted_at"].is_number(),
16099            "warning must preserve the edge's prior tombstone timestamp"
16100        );
16101        assert!(
16102            rt.get_edge_including_deleted(&tok, edge_id)
16103                .await
16104                .unwrap()
16105                .is_none(),
16106            "the warned tombstoned edge must still be physically cascaded"
16107        );
16108    }
16109
16110    #[tokio::test]
16111    async fn hard_delete_note_emits_evidential_lineage_warning() {
16112        let rt = rt();
16113        let tok = NamespaceToken::local();
16114        let evidence = rt
16115            .create_note(&tok, "observation", None, "evidence", None, None, vec![])
16116            .await
16117            .unwrap();
16118        let claim = rt
16119            .create_note(&tok, "insight", None, "claim", None, None, vec![])
16120            .await
16121            .unwrap();
16122        rt.link(
16123            &tok,
16124            evidence.id,
16125            claim.id,
16126            EdgeRelation::Supports,
16127            1.0,
16128            None,
16129        )
16130        .await
16131        .unwrap();
16132
16133        assert!(rt.delete_note(&tok, evidence.id, true).await.unwrap());
16134        let warnings = lineage_warning_events(&rt, &tok, evidence.id).await;
16135        assert_eq!(warnings.len(), 1);
16136        assert_eq!(warnings[0].substrate, SubstrateKind::Note);
16137        assert_eq!(warnings[0].payload["relation"], "supports");
16138        assert_eq!(warnings[0].payload["warning"], "evidential_link_loss");
16139    }
16140
16141    #[tokio::test]
16142    async fn lineage_warning_insert_failure_rolls_back_hard_delete_and_cascade() {
16143        let rt = rt();
16144        let tok = NamespaceToken::local();
16145        let doomed = rt
16146            .create_entity(&tok, "document", None, "doomed", None, None, vec![])
16147            .await
16148            .unwrap();
16149        let source = rt
16150            .create_entity(&tok, "artifact", None, "source", None, None, vec![])
16151            .await
16152            .unwrap();
16153        let edge = rt
16154            .link(
16155                &tok,
16156                source.id,
16157                doomed.id,
16158                EdgeRelation::DerivedFrom,
16159                1.0,
16160                None,
16161            )
16162            .await
16163            .unwrap();
16164
16165        let mut writer = rt.sql().writer().await.expect("sql writer");
16166        writer
16167            .execute_script(
16168                "CREATE TRIGGER reject_lineage_warning \
16169                 BEFORE INSERT ON events \
16170                 WHEN NEW.kind = 'audit' \
16171                   AND json_extract(NEW.payload, '$.severity') = 'warning' \
16172                 BEGIN \
16173                   SELECT RAISE(ABORT, 'injected lineage warning failure'); \
16174                 END;"
16175                    .into(),
16176            )
16177            .await
16178            .expect("install warning failure trigger");
16179        drop(writer);
16180
16181        let error = rt.delete_entity(&tok, doomed.id, true).await.unwrap_err();
16182        assert!(error
16183            .to_string()
16184            .contains("injected lineage warning failure"));
16185        assert!(
16186            rt.entities(&tok)
16187                .unwrap()
16188                .get_entity(doomed.id)
16189                .await
16190                .unwrap()
16191                .is_some(),
16192            "the endpoint row must roll back with the failed warning insert"
16193        );
16194        assert!(
16195            rt.get_edge(&tok, Uuid::from(edge.id))
16196                .await
16197                .unwrap()
16198                .is_some(),
16199            "the incident edge must roll back with the failed warning insert"
16200        );
16201    }
16202
16203    #[tokio::test]
16204    async fn hard_delete_entity_purges_already_soft_deleted_incident_edge() {
16205        let rt = rt();
16206        let tok = NamespaceToken::local();
16207        let ns = tok.namespace().to_string();
16208
16209        let a = rt
16210            .create_entity(&tok, "concept", None, "SrcA", None, None, vec![])
16211            .await
16212            .unwrap();
16213        let b = rt
16214            .create_entity(&tok, "concept", None, "TgtB", None, None, vec![])
16215            .await
16216            .unwrap();
16217
16218        rt.link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
16219            .await
16220            .unwrap();
16221
16222        // Soft-delete the edge — it is now invisible to `neighbors` but still in storage.
16223        let edge_hit = rt
16224            .neighbors(&tok, a.id, Direction::Out, None, None)
16225            .await
16226            .unwrap();
16227        assert_eq!(edge_hit.len(), 1, "edge must exist before soft-delete");
16228        let edge_uuid = edge_hit[0].edge_id;
16229        rt.delete_edge(&tok, edge_uuid, false).await.unwrap();
16230
16231        // Confirm the edge is invisible to normal read paths but present in raw storage.
16232        let visible = rt
16233            .neighbors(&tok, a.id, Direction::Out, None, None)
16234            .await
16235            .unwrap();
16236        assert!(visible.is_empty(), "soft-deleted edge must be invisible");
16237        let raw_before = count_all_incident_edges(&rt, a.id, &ns).await;
16238        assert_eq!(
16239            raw_before, 1,
16240            "soft-deleted edge must still be a physical row"
16241        );
16242
16243        // Hard-delete (purge) the source entity — cascade must also remove the soft-deleted edge.
16244        rt.delete_entity(&tok, a.id, true).await.unwrap();
16245
16246        let raw_after = count_all_incident_edges(&rt, a.id, &ns).await;
16247        assert_eq!(
16248            raw_after, 0,
16249            "purge_incident_edges must physically remove soft-deleted edge rows (ADR-002)"
16250        );
16251    }
16252
16253    #[tokio::test]
16254    async fn hard_delete_note_purges_already_soft_deleted_incident_edge() {
16255        let rt = rt();
16256        let tok = NamespaceToken::local();
16257        let ns = tok.namespace().to_string();
16258
16259        let target = rt
16260            .create_note(
16261                &tok,
16262                "observation",
16263                None,
16264                "purge-cascade target note",
16265                Some(0.5),
16266                None,
16267                vec![],
16268            )
16269            .await
16270            .unwrap();
16271        let annotating = rt
16272            .create_note(
16273                &tok,
16274                "insight",
16275                None,
16276                "annotator note",
16277                Some(0.5),
16278                None,
16279                vec![target.id],
16280            )
16281            .await
16282            .unwrap();
16283
16284        // Soft-delete the annotates edge.
16285        let edge_hit = rt
16286            .neighbors(
16287                &tok,
16288                annotating.id,
16289                Direction::Out,
16290                None,
16291                Some(vec![EdgeRelation::Annotates]),
16292            )
16293            .await
16294            .unwrap();
16295        assert_eq!(edge_hit.len(), 1, "annotates edge must exist");
16296        let edge_uuid = edge_hit[0].edge_id;
16297        rt.delete_edge(&tok, edge_uuid, false).await.unwrap();
16298
16299        let raw_before = count_all_incident_edges(&rt, target.id, &ns).await;
16300        assert_eq!(
16301            raw_before, 1,
16302            "soft-deleted edge must still be a physical row before note purge"
16303        );
16304
16305        // Hard-delete the target note — cascade must remove the soft-deleted edge row.
16306        rt.delete_note(&tok, target.id, true).await.unwrap();
16307
16308        let raw_after = count_all_incident_edges(&rt, target.id, &ns).await;
16309        assert_eq!(
16310            raw_after, 0,
16311            "purge_incident_edges must physically remove soft-deleted edge rows on note purge (ADR-002)"
16312        );
16313    }
16314
16315    // ---- cross-namespace entity hard-delete purges ALL incident edges ----
16316    //
16317    // `purge_incident_edges` must not scope its DELETE by `WHERE namespace = caller_ns`,
16318    // or a foreign-namespace entity's incident edges in ITS namespace would survive
16319    // the cascade as dangling rows.
16320
16321    /// Count ALL `graph_edges` rows for a given node UUID, across every namespace.
16322    async fn count_all_incident_edges_global(rt: &KhiveRuntime, node_id: Uuid) -> u64 {
16323        let mut reader = rt.sql().reader().await.expect("sql reader must open");
16324        let row = reader
16325            .query_scalar(SqlStatement {
16326                sql: "SELECT COUNT(*) FROM graph_edges WHERE source_id = ?1 OR target_id = ?1"
16327                    .into(),
16328                params: vec![SqlValue::Text(node_id.to_string())],
16329                label: Some("count_all_incident_edges_global".into()),
16330            })
16331            .await
16332            .expect("count query must succeed");
16333        match row {
16334            Some(SqlValue::Integer(n)) => n as u64,
16335            _ => panic!("count must return an integer"),
16336        }
16337    }
16338
16339    #[tokio::test]
16340    async fn cross_namespace_hard_delete_entity_purges_all_incident_edges() {
16341        // Entity lives in ns-owner. Edges live in ns-owner.
16342        // Delete is driven from ns-caller (a different namespace).
16343        // Assertion: after hard delete, no incident edges remain in ANY namespace.
16344        let rt = rt();
16345        let ns_owner = NamespaceToken::for_namespace(Namespace::parse("ns-owner").unwrap());
16346        let ns_caller = NamespaceToken::for_namespace(Namespace::parse("ns-caller").unwrap());
16347
16348        let entity = rt
16349            .create_entity(
16350                &ns_owner,
16351                "concept",
16352                None,
16353                "ForeignEntity",
16354                None,
16355                None,
16356                vec![],
16357            )
16358            .await
16359            .unwrap();
16360        let peer = rt
16361            .create_entity(&ns_owner, "concept", None, "Peer", None, None, vec![])
16362            .await
16363            .unwrap();
16364        // Create two incident edges in ns_owner. concept->Extends->concept is in the allowlist.
16365        rt.link(
16366            &ns_owner,
16367            entity.id,
16368            peer.id,
16369            EdgeRelation::Extends,
16370            1.0,
16371            None,
16372        )
16373        .await
16374        .unwrap();
16375        rt.link(
16376            &ns_owner,
16377            peer.id,
16378            entity.id,
16379            EdgeRelation::Extends,
16380            1.0,
16381            None,
16382        )
16383        .await
16384        .unwrap();
16385
16386        let before = count_all_incident_edges_global(&rt, entity.id).await;
16387        assert_eq!(before, 2, "two incident edges must exist before delete");
16388
16389        // Hard-delete entity from a DIFFERENT namespace token.
16390        let deleted = rt.delete_entity(&ns_caller, entity.id, true).await.unwrap();
16391        assert!(deleted, "cross-ns hard delete must return true");
16392
16393        // All incident edges must be gone regardless of namespace.
16394        let after = count_all_incident_edges_global(&rt, entity.id).await;
16395        assert_eq!(
16396            after, 0,
16397            "purge_incident_edges must remove all incident edges across namespaces (ADR-002, ADR-007)"
16398        );
16399    }
16400
16401    // ---- edge-ID hard-delete path ----
16402    //
16403    // Bug class: delete_edge drove the primary-edge guard through get_edge()
16404    // (live-only) and the cascade through neighbors() (live-only). Two reachable holes:
16405    // (a) soft-deleted primary edge cannot be hard-purged via its own ID;
16406    // (b) an already-soft-deleted annotates edge targeting a base edge survives that
16407    //     edge's hard delete as a dangling row with target_id = physically-gone edge id.
16408
16409    /// Count graph_edges rows matching the given edge ID, including soft-deleted rows.
16410    async fn count_edge_rows_by_id(rt: &KhiveRuntime, edge_id: Uuid, ns: &str) -> u64 {
16411        let mut reader = rt.sql().reader().await.expect("sql reader must open");
16412        let row = reader
16413            .query_scalar(SqlStatement {
16414                sql: "SELECT COUNT(*) FROM graph_edges WHERE namespace = ?1 AND id = ?2".into(),
16415                params: vec![
16416                    SqlValue::Text(ns.to_string()),
16417                    SqlValue::Text(edge_id.to_string()),
16418                ],
16419                label: Some("count_edge_rows_by_id".into()),
16420            })
16421            .await
16422            .expect("count query must succeed");
16423        match row {
16424            Some(SqlValue::Integer(n)) => n as u64,
16425            _ => panic!("count must return an integer"),
16426        }
16427    }
16428
16429    #[tokio::test]
16430    async fn hard_delete_edge_purges_already_soft_deleted_primary_edge() {
16431        let rt = rt();
16432        let tok = NamespaceToken::local();
16433        let ns = tok.namespace().to_string();
16434
16435        let a = rt
16436            .create_entity(&tok, "concept", None, "EA", None, None, vec![])
16437            .await
16438            .unwrap();
16439        let b = rt
16440            .create_entity(&tok, "concept", None, "EB", None, None, vec![])
16441            .await
16442            .unwrap();
16443
16444        let edge = rt
16445            .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
16446            .await
16447            .unwrap();
16448        let edge_uuid: Uuid = edge.id.into();
16449
16450        // Soft-delete the edge first.
16451        let soft = rt.delete_edge(&tok, edge_uuid, false).await.unwrap();
16452        assert!(soft, "soft delete must succeed");
16453
16454        // Edge is now invisible to normal reads but still a physical row.
16455        assert!(
16456            rt.get_edge(&tok, edge_uuid).await.unwrap().is_none(),
16457            "soft-deleted edge must be invisible to get_edge"
16458        );
16459        assert_eq!(
16460            count_edge_rows_by_id(&rt, edge_uuid, &ns).await,
16461            1,
16462            "soft-deleted edge must still be a physical row"
16463        );
16464
16465        // Hard-delete (purge) via the edge ID — must succeed and remove the row.
16466        let purged = rt.delete_edge(&tok, edge_uuid, true).await.unwrap();
16467        assert!(
16468            purged,
16469            "hard delete of a soft-deleted edge must return true"
16470        );
16471
16472        assert_eq!(
16473            count_edge_rows_by_id(&rt, edge_uuid, &ns).await,
16474            0,
16475            "hard-delete must physically remove the soft-deleted edge row (ADR-002)"
16476        );
16477    }
16478
16479    #[tokio::test]
16480    async fn hard_delete_base_edge_purges_already_soft_deleted_annotates_edge() {
16481        let rt = rt();
16482        let tok = NamespaceToken::local();
16483        let ns = tok.namespace().to_string();
16484
16485        let a = rt
16486            .create_entity(&tok, "concept", None, "CA", None, None, vec![])
16487            .await
16488            .unwrap();
16489        let b = rt
16490            .create_entity(&tok, "concept", None, "CB", None, None, vec![])
16491            .await
16492            .unwrap();
16493
16494        // Create the base edge to be annotated.
16495        let base_edge = rt
16496            .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
16497            .await
16498            .unwrap();
16499        let base_edge_uuid: Uuid = base_edge.id.into();
16500
16501        // Create a note that annotates the base edge.
16502        let note = rt
16503            .create_note(
16504                &tok,
16505                "observation",
16506                None,
16507                "note about base edge",
16508                Some(0.5),
16509                None,
16510                vec![base_edge_uuid],
16511            )
16512            .await
16513            .unwrap();
16514
16515        // Find the annotates edge.
16516        let ann_hits = rt
16517            .neighbors(
16518                &tok,
16519                note.id,
16520                Direction::Out,
16521                None,
16522                Some(vec![EdgeRelation::Annotates]),
16523            )
16524            .await
16525            .unwrap();
16526        assert_eq!(ann_hits.len(), 1, "annotates edge must exist");
16527        let ann_edge_uuid = ann_hits[0].edge_id;
16528
16529        // Soft-delete the annotates edge — now invisible but still a physical row.
16530        rt.delete_edge(&tok, ann_edge_uuid, false).await.unwrap();
16531        assert_eq!(
16532            count_edge_rows_by_id(&rt, ann_edge_uuid, &ns).await,
16533            1,
16534            "soft-deleted annotates edge must still be a physical row"
16535        );
16536
16537        // Hard-delete the base edge — cascade must also remove the soft-deleted annotates row.
16538        let purged = rt.delete_edge(&tok, base_edge_uuid, true).await.unwrap();
16539        assert!(purged, "hard delete of base edge must return true");
16540
16541        assert_eq!(
16542            count_edge_rows_by_id(&rt, ann_edge_uuid, &ns).await,
16543            0,
16544            "hard-delete of base edge must purge already-soft-deleted annotates edge row (ADR-002)"
16545        );
16546        assert_eq!(
16547            count_edge_rows_by_id(&rt, base_edge_uuid, &ns).await,
16548            0,
16549            "hard-delete must physically remove the base edge row"
16550        );
16551    }
16552
16553    // ---- entity create/update multi-model embed fan-out tests ----
16554
16555    // FTS failure after entity row commit rolls back the entity row.
16556    // Mirrors create_note_fts_failure_rolls_back_note_row but for entities.
16557    // Uses a unique namespace so this test's arm never fires for the wrong
16558    // write path, even under full-suite parallelism.
16559    #[tokio::test]
16560    async fn create_entity_fts_failure_rolls_back_entity_row() {
16561        let rt = KhiveRuntime::memory().unwrap();
16562        let ns = Namespace::parse("fault-entity-fts").unwrap();
16563        let tok = NamespaceToken::for_namespace(ns.clone());
16564
16565        let _arm = arm_fts_fail_scoped(ns.as_str());
16566
16567        let result = rt
16568            .create_entity(
16569                &tok,
16570                "concept",
16571                None,
16572                "fts-fail rollback target",
16573                None,
16574                None,
16575                vec![],
16576            )
16577            .await;
16578
16579        assert!(
16580            result.is_err(),
16581            "create_entity must propagate the injected FTS failure"
16582        );
16583        let err_msg = result.unwrap_err().to_string();
16584        assert!(
16585            err_msg.contains("injected FTS failure"),
16586            "error must carry injection message; got: {err_msg}"
16587        );
16588
16589        let entities = rt.list_entities(&tok, None, None, 1000, 0).await.unwrap();
16590        assert!(
16591            entities.is_empty(),
16592            "compensation must remove the entity row after FTS failure; got {entities:?}"
16593        );
16594    }
16595
16596    #[tokio::test]
16597    async fn create_entity_fts_and_compensation_failures_report_partial_persistence() {
16598        let rt = KhiveRuntime::memory().unwrap();
16599        let ns = Namespace::parse("partial-persistence-fts").unwrap();
16600        let tok = NamespaceToken::for_namespace(ns.clone());
16601        let _fts_arm = arm_fts_fail_scoped(ns.as_str());
16602        let _cleanup_arm = arm_entity_compensation_fail_scoped(ns.as_str());
16603
16604        let error = rt
16605            .create_entity(
16606                &tok,
16607                "concept",
16608                None,
16609                "FTS partial persistence",
16610                None,
16611                None,
16612                vec![],
16613            )
16614            .await
16615            .expect_err("FTS and compensation failures must fail create_entity");
16616        assert!(
16617            matches!(&error, RuntimeError::Khive(_)),
16618            "combined failures must use the structured wire error path"
16619        );
16620        let error_message = error.to_string();
16621        assert!(error_message.contains("injected FTS failure"));
16622        assert!(error_message.contains("injected compensation failure"));
16623        assert!(error_message.contains("partial persistence is possible"));
16624
16625        let entities = rt.list_entities(&tok, None, None, 10, 0).await.unwrap();
16626        assert_eq!(
16627            entities.len(),
16628            1,
16629            "the failed row cleanup must leave residue"
16630        );
16631        let record_id = entities[0].id;
16632        assert!(
16633            error_message.contains(&record_id.to_string()),
16634            "the error must identify the durable row that needs reconciliation"
16635        );
16636        assert!(
16637            rt.text(&tok)
16638                .unwrap()
16639                .get_document(ns.as_str(), record_id)
16640                .await
16641                .unwrap()
16642                .is_none(),
16643            "FTS cleanup must run even when the FTS upsert reports failure"
16644        );
16645    }
16646
16647    // Vector insert failure after entity row + FTS commit rolls back both.
16648    // Uses a unique namespace so only this test consumes its VECTOR_FAIL_NS entry.
16649    #[tokio::test]
16650    async fn create_entity_vector_failure_rolls_back_entity_row_and_fts() {
16651        const MODEL: &str = "test-entity-vec-inject";
16652        const DIMS: usize = 4;
16653
16654        let rt = KhiveRuntime::memory().unwrap();
16655        let (provider, _counter) = ConstVecProvider::new(MODEL, DIMS);
16656        rt.register_embedder(provider);
16657
16658        let ns = Namespace::parse("fault-entity-vec").unwrap();
16659        let tok = NamespaceToken::for_namespace(ns.clone());
16660
16661        let _arm = arm_vector_fail_scoped(ns.as_str());
16662
16663        let result = rt
16664            .create_entity(
16665                &tok,
16666                "concept",
16667                None,
16668                "vec-fail rollback target",
16669                Some("description so embed body is non-empty"),
16670                None,
16671                vec![],
16672            )
16673            .await;
16674
16675        assert!(
16676            result.is_err(),
16677            "create_entity must propagate the injected vector failure"
16678        );
16679        let err_msg = result.unwrap_err().to_string();
16680        assert!(
16681            err_msg.contains("injected vector failure"),
16682            "error must carry injection message; got: {err_msg}"
16683        );
16684
16685        let entities = rt.list_entities(&tok, None, None, 1000, 0).await.unwrap();
16686        assert!(
16687            entities.is_empty(),
16688            "compensation must remove entity row after vector failure; got {entities:?}"
16689        );
16690
16691        // FTS document must also be removed.
16692        use khive_storage::types::{TextFilter, TextQueryMode, TextSearchRequest};
16693        let fts_hits = rt
16694            .text(&tok)
16695            .unwrap()
16696            .search(TextSearchRequest {
16697                query: "vec-fail rollback target".to_string(),
16698                mode: TextQueryMode::Plain,
16699                filter: Some(TextFilter {
16700                    namespaces: vec![ns.as_str().to_string()],
16701                    ..Default::default()
16702                }),
16703                top_k: 10,
16704                snippet_chars: 100,
16705            })
16706            .await
16707            .unwrap();
16708        assert!(
16709            fts_hits.is_empty(),
16710            "compensation must remove FTS document after vector failure; got {fts_hits:?}"
16711        );
16712    }
16713
16714    #[tokio::test]
16715    async fn create_entity_single_model_reports_primary_and_compensation_failures() {
16716        const MODEL: &str = "partial-persistence-single";
16717
16718        let rt = KhiveRuntime::memory().unwrap();
16719        let (provider, _counter) = ConstVecProvider::new(MODEL, 4);
16720        rt.register_embedder(provider);
16721
16722        let ns = Namespace::parse("partial-persistence-single").unwrap();
16723        let tok = NamespaceToken::for_namespace(ns.clone());
16724        let _vector_arm = arm_vector_fail_scoped(ns.as_str());
16725        let _cleanup_arm = arm_entity_compensation_fail_scoped(ns.as_str());
16726
16727        let error = rt
16728            .create_entity(
16729                &tok,
16730                "concept",
16731                None,
16732                "single-model partial persistence",
16733                Some("the FTS document lands before the injected vector failure"),
16734                None,
16735                vec![],
16736            )
16737            .await
16738            .expect_err("vector and compensation failures must fail create_entity");
16739
16740        let error_message = error.to_string();
16741        assert!(error_message.contains("injected vector failure"));
16742        assert!(error_message.contains("injected compensation failure"));
16743        assert!(error_message.contains("partial persistence is possible"));
16744
16745        let entities = rt.list_entities(&tok, None, None, 10, 0).await.unwrap();
16746        assert_eq!(
16747            entities.len(),
16748            1,
16749            "the failed row cleanup must leave residue"
16750        );
16751        let record_id = entities[0].id;
16752        assert!(
16753            error_message.contains(&record_id.to_string()),
16754            "the caller-visible error must carry the recovery record ID"
16755        );
16756        assert!(
16757            rt.text(&tok)
16758                .unwrap()
16759                .get_document(ns.as_str(), record_id)
16760                .await
16761                .unwrap()
16762                .is_none(),
16763            "independent FTS compensation must still complete"
16764        );
16765        assert_eq!(
16766            rt.vectors_for_model(&tok, MODEL)
16767                .unwrap()
16768                .count()
16769                .await
16770                .unwrap(),
16771            0,
16772            "the failed model must be cleaned even though INSERT returned an error"
16773        );
16774    }
16775
16776    // Multi-model create_entity: second model's vector INSERT fails after the
16777    // first model's insert succeeds, triggering inserted_models rollback.
16778    // Uses arm_vector_fail_after(1) so the first insert passes and the second fails,
16779    // exercising the inserted_models compensation path in create_entity.
16780    // Thread-local VECTOR_FAIL_AFTER is per-thread isolated (current-thread tokio runtime),
16781    // so this test does not race with namespace-targeted VECTOR_FAIL_NS tests.
16782    #[tokio::test]
16783    async fn create_entity_multi_model_second_vector_failure_rolls_back_all() {
16784        const DIMS: usize = 4;
16785
16786        let rt = KhiveRuntime::memory().unwrap();
16787        let (provider_a, _ca) = ConstVecProvider::new("model-a", DIMS);
16788        let (provider_b, _cb) = ConstVecProvider::new("model-b", DIMS);
16789        rt.register_embedder(provider_a);
16790        rt.register_embedder(provider_b);
16791
16792        let ns = Namespace::parse("fault-entity-multi").unwrap();
16793        let tok = NamespaceToken::for_namespace(ns.clone());
16794
16795        // Let the first vector insert succeed, fail on the second.
16796        arm_vector_fail_after(1);
16797
16798        let result = rt
16799            .create_entity(
16800                &tok,
16801                "concept",
16802                None,
16803                "multi-model rollback target",
16804                Some("description for embedding"),
16805                None,
16806                vec![],
16807            )
16808            .await;
16809
16810        assert!(
16811            result.is_err(),
16812            "create_entity must propagate the injected multi-model vector failure"
16813        );
16814
16815        let entities = rt.list_entities(&tok, None, None, 1000, 0).await.unwrap();
16816        assert!(
16817            entities.is_empty(),
16818            "compensation must remove entity row; got {entities:?}"
16819        );
16820
16821        // Both model-a and model-b vector stores must be empty for the entity id.
16822        // (The entity was never returned so we can't get its id from the result;
16823        // list_entities returning empty is the primary assertion. Additionally confirm
16824        // both stores have zero rows via a broad vector search.)
16825        use khive_storage::types::VectorSearchRequest;
16826        let query_vec = vec![1.0_f32; DIMS];
16827        let hits_a = rt
16828            .vectors_for_model(&tok, "model-a")
16829            .unwrap()
16830            .search(VectorSearchRequest {
16831                query_vectors: vec![query_vec.clone()],
16832                top_k: 100,
16833                namespace: Some(ns.as_str().to_string()),
16834                kind: Some(khive_types::SubstrateKind::Entity),
16835                embedding_model: Some("model-a".to_string()),
16836                filter: None,
16837                backend_hints: None,
16838            })
16839            .await
16840            .unwrap();
16841        assert!(
16842            hits_a.is_empty(),
16843            "model-a vector store must be empty after rollback; got {hits_a:?}"
16844        );
16845        let hits_b = rt
16846            .vectors_for_model(&tok, "model-b")
16847            .unwrap()
16848            .search(VectorSearchRequest {
16849                query_vectors: vec![query_vec],
16850                top_k: 100,
16851                namespace: Some(ns.as_str().to_string()),
16852                kind: Some(khive_types::SubstrateKind::Entity),
16853                embedding_model: Some("model-b".to_string()),
16854                filter: None,
16855                backend_hints: None,
16856            })
16857            .await
16858            .unwrap();
16859        assert!(
16860            hits_b.is_empty(),
16861            "model-b vector store must be empty after rollback; got {hits_b:?}"
16862        );
16863    }
16864
16865    #[tokio::test]
16866    async fn create_entity_multi_model_reports_cleanup_failure_and_removes_all_vectors() {
16867        let rt = KhiveRuntime::memory().unwrap();
16868        let (provider_a, _ca) = ConstVecProvider::new("partial-multi-a", 4);
16869        let (provider_b, _cb) = ConstVecProvider::new("partial-multi-b", 4);
16870        rt.register_embedder(provider_a);
16871        rt.register_embedder(provider_b);
16872
16873        let ns = Namespace::parse("partial-persistence-multi").unwrap();
16874        let tok = NamespaceToken::for_namespace(ns.clone());
16875        let _cleanup_arm = arm_entity_compensation_fail_scoped(ns.as_str());
16876        arm_vector_fail_after(1);
16877
16878        let error = rt
16879            .create_entity(
16880                &tok,
16881                "concept",
16882                None,
16883                "multi-model partial persistence",
16884                Some("the second model fails after the first vector lands"),
16885                None,
16886                vec![],
16887            )
16888            .await
16889            .expect_err("second vector and compensation failures must fail create_entity");
16890
16891        let error_message = error.to_string();
16892        assert!(
16893            error_message.contains("injected vector insert failure"),
16894            "primary cause must be retained: {error_message}"
16895        );
16896        assert!(error_message.contains("injected compensation failure"));
16897
16898        let entities = rt.list_entities(&tok, None, None, 10, 0).await.unwrap();
16899        assert_eq!(
16900            entities.len(),
16901            1,
16902            "the failed row cleanup must leave residue"
16903        );
16904        let record_id = entities[0].id;
16905        assert!(error_message.contains(&record_id.to_string()));
16906
16907        for model in ["partial-multi-a", "partial-multi-b"] {
16908            assert_eq!(
16909                rt.vectors_for_model(&tok, model)
16910                    .unwrap()
16911                    .count()
16912                    .await
16913                    .unwrap(),
16914                0,
16915                "compensation must clean both the successful and failed model ({model})"
16916            );
16917        }
16918        assert!(
16919            rt.text(&tok)
16920                .unwrap()
16921                .get_document(ns.as_str(), record_id)
16922                .await
16923                .unwrap()
16924                .is_none(),
16925            "FTS compensation must succeed even when row cleanup fails"
16926        );
16927    }
16928
16929    // ADR-103 Amendment 2 regression: multi-model create_entity spawns one
16930    // embed task per configured model via tokio::spawn. Task-locals do not
16931    // cross a spawn boundary, so each spawned task must re-enter the
16932    // dispatch's usage scope explicitly for its embed to be counted.
16933    #[tokio::test]
16934    async fn create_entity_multi_model_counts_all_executed_embeds() {
16935        const DIMS: usize = 4;
16936
16937        let rt = KhiveRuntime::memory().unwrap();
16938        let (provider_a, _ca) = ConstVecProvider::new("usage-entity-model-a", DIMS);
16939        let (provider_b, _cb) = ConstVecProvider::new("usage-entity-model-b", DIMS);
16940        rt.register_embedder(provider_a);
16941        rt.register_embedder(provider_b);
16942
16943        let ns = Namespace::parse("usage-entity-multi").unwrap();
16944        let tok = NamespaceToken::for_namespace(ns.clone());
16945
16946        let ctx = crate::usage::UsageContext::new();
16947        crate::usage::scope(ctx.clone(), async {
16948            rt.create_entity(
16949                &tok,
16950                "concept",
16951                None,
16952                "usage-counted entity",
16953                Some("description so embed body is non-empty"),
16954                None,
16955                vec![],
16956            )
16957            .await
16958        })
16959        .await
16960        .expect("create_entity must succeed");
16961
16962        let snap = ctx.snapshot();
16963        assert_eq!(
16964            snap["embed_calls"], 2,
16965            "both configured models' executed embeds must be counted; got {snap:?}"
16966        );
16967    }
16968
16969    // Same regression for the note create path's multi-model embed fan-out.
16970    #[tokio::test]
16971    async fn create_note_multi_model_counts_all_executed_embeds() {
16972        const DIMS: usize = 4;
16973
16974        let rt = KhiveRuntime::memory().unwrap();
16975        let (provider_a, _ca) = ConstVecProvider::new("usage-note-model-a", DIMS);
16976        let (provider_b, _cb) = ConstVecProvider::new("usage-note-model-b", DIMS);
16977        rt.register_embedder(provider_a);
16978        rt.register_embedder(provider_b);
16979
16980        let ns = Namespace::parse("usage-note-multi").unwrap();
16981        let tok = NamespaceToken::for_namespace(ns.clone());
16982
16983        let ctx = crate::usage::UsageContext::new();
16984        crate::usage::scope(ctx.clone(), async {
16985            rt.create_note(
16986                &tok,
16987                "observation",
16988                None,
16989                "usage-counted note body",
16990                None,
16991                None,
16992                vec![],
16993            )
16994            .await
16995        })
16996        .await
16997        .expect("create_note must succeed");
16998
16999        let snap = ctx.snapshot();
17000        assert_eq!(
17001            snap["embed_calls"], 2,
17002            "both configured models' executed embeds must be counted; got {snap:?}"
17003        );
17004    }
17005
17006    // Embed calls are counted when issued, so detached completion after a
17007    // sibling failure cannot change the response's usage snapshot.
17008    #[tokio::test]
17009    async fn create_entity_multi_model_error_keeps_issued_usage_stable() {
17010        const DIMS: usize = 4;
17011
17012        let rt = KhiveRuntime::memory().unwrap();
17013        rt.register_embedder(FailFastProvider::new("usage-entity-fail-fast"));
17014        rt.register_embedder(SlowVecProvider::new("usage-entity-slow", DIMS));
17015
17016        let ns = Namespace::parse("usage-entity-error-drain").unwrap();
17017        let tok = NamespaceToken::for_namespace(ns.clone());
17018
17019        let ctx = crate::usage::UsageContext::new();
17020        let result = crate::usage::scope(ctx.clone(), async {
17021            rt.create_entity(
17022                &tok,
17023                "concept",
17024                None,
17025                "usage-drain entity",
17026                Some("description so embed body is non-empty"),
17027                None,
17028                vec![],
17029            )
17030            .await
17031        })
17032        .await;
17033
17034        assert!(
17035            result.is_err(),
17036            "one model failing must fail the whole create_entity call"
17037        );
17038
17039        let snap_immediately_after = ctx.snapshot();
17040        tokio::time::sleep(std::time::Duration::from_millis(200)).await;
17041        let snap_after_delay = ctx.snapshot();
17042
17043        assert_eq!(
17044            snap_immediately_after, snap_after_delay,
17045            "embed completion after create_entity returns must not change issued \
17046             usage; got immediately_after={snap_immediately_after:?} \
17047             after_delay={snap_after_delay:?}"
17048        );
17049    }
17050
17051    // Same regression for the note create path's multi-model embed fan-out.
17052    #[tokio::test]
17053    async fn create_note_multi_model_error_keeps_issued_usage_stable() {
17054        const DIMS: usize = 4;
17055
17056        let rt = KhiveRuntime::memory().unwrap();
17057        rt.register_embedder(FailFastProvider::new("usage-note-fail-fast"));
17058        rt.register_embedder(SlowVecProvider::new("usage-note-slow", DIMS));
17059
17060        let ns = Namespace::parse("usage-note-error-drain").unwrap();
17061        let tok = NamespaceToken::for_namespace(ns.clone());
17062
17063        let ctx = crate::usage::UsageContext::new();
17064        let result = crate::usage::scope(ctx.clone(), async {
17065            rt.create_note(
17066                &tok,
17067                "observation",
17068                None,
17069                "usage-drain note body",
17070                None,
17071                None,
17072                vec![],
17073            )
17074            .await
17075        })
17076        .await;
17077
17078        assert!(
17079            result.is_err(),
17080            "one model failing must fail the whole create_note call"
17081        );
17082
17083        let snap_immediately_after = ctx.snapshot();
17084        tokio::time::sleep(std::time::Duration::from_millis(200)).await;
17085        let snap_after_delay = ctx.snapshot();
17086
17087        assert_eq!(
17088            snap_immediately_after, snap_after_delay,
17089            "embed completion after create_note returns must not change issued \
17090             usage; got immediately_after={snap_immediately_after:?} \
17091             after_delay={snap_after_delay:?}"
17092        );
17093    }
17094
17095    // A fast provider failure must not wait on a slow sibling: the drain
17096    // aborts remaining embed tasks on the first error instead of awaiting
17097    // them to completion. The sibling here parks until a release that never
17098    // fires, so under await-to-completion this call would never return —
17099    // a bounded prompt error return is only reachable through the abort path.
17100    #[tokio::test]
17101    async fn create_entity_fast_embed_failure_does_not_wait_for_hung_sibling() {
17102        const DIMS: usize = 4;
17103
17104        let rt = KhiveRuntime::memory().unwrap();
17105        rt.register_embedder(FailFastProvider::new("latency-fail-fast"));
17106        let (parked, _release, _entered) = ParkedVecProvider::new("latency-parked", DIMS);
17107        rt.register_embedder(parked);
17108
17109        let ns = Namespace::parse("usage-entity-latency").unwrap();
17110        let tok = NamespaceToken::for_namespace(ns.clone());
17111
17112        let started = std::time::Instant::now();
17113        // Outer timeout so a regression to await-to-completion FAILS this test
17114        // within the bound instead of hanging the suite on the parked sibling.
17115        let result = tokio::time::timeout(
17116            std::time::Duration::from_secs(60),
17117            rt.create_entity(
17118                &tok,
17119                "concept",
17120                None,
17121                "latency entity",
17122                Some("description so embed body is non-empty"),
17123                None,
17124                vec![],
17125            ),
17126        )
17127        .await
17128        .expect("create_entity must return within the timeout — a hang means the abort path regressed to await-to-completion");
17129        let elapsed = started.elapsed();
17130
17131        assert!(
17132            result.is_err(),
17133            "one model failing must fail the whole create_entity call"
17134        );
17135        assert!(
17136            elapsed < std::time::Duration::from_secs(5),
17137            "a fast embed failure must return without waiting on the hung \
17138             sibling (which parks forever); took {elapsed:?}"
17139        );
17140    }
17141
17142    #[test]
17143    fn create_entity_embed_failure_returns_under_single_worker_saturation() {
17144        let (blocking, controls) = BlockingVecProvider::new("latency-blocking", 4);
17145        let fail_after_entry = FailFastProvider::after_signal(
17146            "latency-fail-after-entry",
17147            Arc::clone(&controls.entered),
17148        );
17149        let (result_tx, result_rx) = std::sync::mpsc::sync_channel(1);
17150
17151        let runtime_thread = std::thread::spawn(move || {
17152            let runtime = tokio::runtime::Builder::new_multi_thread()
17153                .worker_threads(1)
17154                .enable_all()
17155                .build()
17156                .expect("single-worker runtime must build");
17157            let rt = KhiveRuntime::memory().unwrap();
17158            rt.register_embedder(blocking);
17159            rt.register_embedder(fail_after_entry);
17160            let tok =
17161                NamespaceToken::for_namespace(Namespace::parse("embed-failure-latency").unwrap());
17162            let result = runtime.block_on(rt.create_entity(
17163                &tok,
17164                "concept",
17165                None,
17166                "blocked sibling entity",
17167                None,
17168                None,
17169                vec![],
17170            ));
17171            result_tx
17172                .send(result.map_err(|error| error.to_string()))
17173                .expect("test receiver must remain connected");
17174        });
17175
17176        let result = result_rx.recv_timeout(std::time::Duration::from_secs(3));
17177
17178        let (released, wake) = &*controls.release;
17179        *released.lock().expect("release lock must not be poisoned") = true;
17180        wake.notify_all();
17181        runtime_thread
17182            .join()
17183            .expect("single-worker runtime thread must join after release");
17184
17185        let error = result
17186            .expect("embed failure must return while synchronous inference remains blocked")
17187            .expect_err("one failed model must fail entity creation");
17188        assert!(error.contains("injected embed failure"));
17189    }
17190
17191    // Issued-at-dispatch accounting: an embed call that was handed to the
17192    // provider must count toward embed_calls even if the task is aborted
17193    // while parked on the provider await — the increment sits before the
17194    // await, so cancellation cannot undercount issued work.
17195    #[tokio::test]
17196    async fn aborted_embed_task_still_counts_issued_embed_call() {
17197        const DIMS: usize = 4;
17198
17199        let rt = std::sync::Arc::new(KhiveRuntime::memory().unwrap());
17200        let (parked, _release, entered) = ParkedVecProvider::new("abort-count-parked", DIMS);
17201        rt.register_embedder(parked);
17202
17203        let ctx = crate::usage::UsageContext::new();
17204        let task = {
17205            let rt = std::sync::Arc::clone(&rt);
17206            let ctx = ctx.clone();
17207            tokio::spawn(crate::usage::scope(ctx, async move {
17208                rt.embed_document_with_model("abort-count-parked", "abort count body")
17209                    .await
17210            }))
17211        };
17212
17213        // Wait until the parked service's embed was entered — the dispatch
17214        // point (and its count) is strictly before that entry.
17215        entered.notified().await;
17216        task.abort();
17217        let joined = task.await;
17218        assert!(
17219            joined.is_err() && joined.unwrap_err().is_cancelled(),
17220            "task must end as cancelled by the abort"
17221        );
17222
17223        assert_eq!(
17224            ctx.snapshot()["embed_calls"],
17225            1,
17226            "an issued embed call must be counted even when the task is \
17227             aborted while parked on the provider await"
17228        );
17229    }
17230
17231    // Note search must count its FTS5 execution the same way entity
17232    // hybrid_search does (retrieval.rs:435) — the search_notes FTS leg was
17233    // silently uncounted.
17234    #[tokio::test]
17235    async fn search_notes_counts_one_fts_pass() {
17236        let rt = KhiveRuntime::memory().unwrap();
17237        let ns = Namespace::parse("usage-note-search").unwrap();
17238        let tok = NamespaceToken::for_namespace(ns.clone());
17239
17240        rt.create_note(
17241            &tok,
17242            "observation",
17243            None,
17244            "usage counted note search body",
17245            None,
17246            None,
17247            vec![],
17248        )
17249        .await
17250        .expect("create_note must succeed");
17251
17252        let ctx = crate::usage::UsageContext::new();
17253        crate::usage::scope(ctx.clone(), async {
17254            rt.search_notes(&tok, "usage counted", None, 10, None, false, &[], None)
17255                .await
17256        })
17257        .await
17258        .expect("search_notes must succeed");
17259
17260        let snap = ctx.snapshot();
17261        assert!(
17262            snap["fts_passes"].as_u64().unwrap_or(0) >= 1,
17263            "note search FTS execution must count fts_passes; got {snap:?}"
17264        );
17265    }
17266
17267    #[tokio::test]
17268    async fn search_notes_batches_supersedes_suppression_round_trip() {
17269        let rt = KhiveRuntime::memory().unwrap();
17270        let ns = Namespace::parse("usage-note-supersedes-batch").unwrap();
17271        let tok = NamespaceToken::for_namespace(ns);
17272
17273        let mut notes = Vec::new();
17274        for ordinal in 0..4 {
17275            notes.push(
17276                rt.create_note(
17277                    &tok,
17278                    "observation",
17279                    None,
17280                    &format!("batchsupersedesprobe live candidate {ordinal}"),
17281                    None,
17282                    None,
17283                    vec![],
17284                )
17285                .await
17286                .expect("create_note must succeed"),
17287            );
17288        }
17289        rt.link(
17290            &tok,
17291            notes[3].id,
17292            notes[0].id,
17293            EdgeRelation::Supersedes,
17294            1.0,
17295            None,
17296        )
17297        .await
17298        .expect("supersedes link must succeed");
17299
17300        let ctx = crate::usage::UsageContext::new();
17301        let hits = crate::usage::scope(ctx.clone(), async {
17302            rt.search_notes(
17303                &tok,
17304                "batchsupersedesprobe",
17305                None,
17306                10,
17307                None,
17308                false,
17309                &[],
17310                None,
17311            )
17312            .await
17313        })
17314        .await
17315        .expect("search_notes must succeed");
17316
17317        let hit_ids: std::collections::HashSet<Uuid> = hits.iter().map(|hit| hit.note_id).collect();
17318        assert_eq!(
17319            hit_ids.len(),
17320            3,
17321            "all live, non-superseded notes must remain"
17322        );
17323        assert!(
17324            !hit_ids.contains(&notes[0].id),
17325            "the superseded note must be excluded"
17326        );
17327        assert!(
17328            notes[1..].iter().all(|note| hit_ids.contains(&note.id)),
17329            "every live, non-superseded note must be returned"
17330        );
17331
17332        let snap = ctx.snapshot();
17333        assert_eq!(
17334            snap["db_round_trips"], 1,
17335            "supersedes suppression must use one batched adjacency query; got {snap:?}"
17336        );
17337    }
17338
17339    // update_entity fans out to ALL registered models.
17340    // After create + update with a changed description, both model-a and model-b
17341    // vector stores hold a row for the entity id.
17342    #[tokio::test]
17343    async fn update_entity_fans_out_to_all_registered_models() {
17344        const DIMS: usize = 4;
17345
17346        let rt = KhiveRuntime::memory().unwrap();
17347        let (provider_a, _ca) = ConstVecProvider::new("embed-a", DIMS);
17348        let (provider_b, _cb) = ConstVecProvider::new("embed-b", DIMS);
17349        rt.register_embedder(provider_a);
17350        rt.register_embedder(provider_b);
17351
17352        let ns = Namespace::parse("update-entity-fanout").unwrap();
17353        let tok = NamespaceToken::for_namespace(ns.clone());
17354
17355        let entity = rt
17356            .create_entity(
17357                &tok,
17358                "concept",
17359                None,
17360                "FanOutEntity",
17361                Some("initial description"),
17362                None,
17363                vec![],
17364            )
17365            .await
17366            .expect("create_entity must succeed");
17367
17368        use crate::curation::EntityPatch;
17369        let patch = EntityPatch {
17370            description: Some(Some("updated description after fan-out fix".to_string())),
17371            ..Default::default()
17372        };
17373        rt.update_entity(&tok, entity.id, patch)
17374            .await
17375            .expect("update_entity must succeed");
17376
17377        use khive_storage::types::VectorSearchRequest;
17378        let query_vec = vec![1.0_f32; DIMS];
17379
17380        let hits_a = rt
17381            .vectors_for_model(&tok, "embed-a")
17382            .unwrap()
17383            .search(VectorSearchRequest {
17384                query_vectors: vec![query_vec.clone()],
17385                top_k: 10,
17386                namespace: Some(ns.as_str().to_string()),
17387                kind: Some(khive_types::SubstrateKind::Entity),
17388                embedding_model: Some("embed-a".to_string()),
17389                filter: None,
17390                backend_hints: None,
17391            })
17392            .await
17393            .unwrap();
17394        assert!(
17395            hits_a.iter().any(|h| h.subject_id == entity.id),
17396            "embed-a must hold a vector for the entity after update; got {hits_a:?}"
17397        );
17398
17399        let hits_b = rt
17400            .vectors_for_model(&tok, "embed-b")
17401            .unwrap()
17402            .search(VectorSearchRequest {
17403                query_vectors: vec![query_vec],
17404                top_k: 10,
17405                namespace: Some(ns.as_str().to_string()),
17406                kind: Some(khive_types::SubstrateKind::Entity),
17407                embedding_model: Some("embed-b".to_string()),
17408                filter: None,
17409                backend_hints: None,
17410            })
17411            .await
17412            .unwrap();
17413        assert!(
17414            hits_b.iter().any(|h| h.subject_id == entity.id),
17415            "embed-b must hold a vector for the entity after update; got {hits_b:?}"
17416        );
17417    }
17418
17419    // update_note fans out to ALL registered models.
17420    // After create + update with changed content, both embed-a and embed-b
17421    // vector stores hold a row for the note id.
17422    #[tokio::test]
17423    async fn update_note_fans_out_to_all_registered_models() {
17424        const DIMS: usize = 4;
17425
17426        let rt = KhiveRuntime::memory().unwrap();
17427        let (provider_a, _ca) = ConstVecProvider::new("embed-a", DIMS);
17428        let (provider_b, _cb) = ConstVecProvider::new("embed-b", DIMS);
17429        rt.register_embedder(provider_a);
17430        rt.register_embedder(provider_b);
17431
17432        let ns = Namespace::parse("update-note-fanout").unwrap();
17433        let tok = NamespaceToken::for_namespace(ns.clone());
17434
17435        let note = rt
17436            .create_note(
17437                &tok,
17438                "observation",
17439                None,
17440                "initial note content for fan-out test",
17441                None,
17442                None,
17443                vec![],
17444            )
17445            .await
17446            .expect("create_note must succeed");
17447
17448        use crate::curation::NotePatch;
17449        let patch = NotePatch {
17450            content: Some("updated content after fan-out fix".to_string()),
17451            ..Default::default()
17452        };
17453        rt.update_note(&tok, note.id, patch)
17454            .await
17455            .expect("update_note must succeed");
17456
17457        use khive_storage::types::VectorSearchRequest;
17458        let query_vec = vec![1.0_f32; DIMS];
17459
17460        let hits_a = rt
17461            .vectors_for_model(&tok, "embed-a")
17462            .unwrap()
17463            .search(VectorSearchRequest {
17464                query_vectors: vec![query_vec.clone()],
17465                top_k: 10,
17466                namespace: Some(ns.as_str().to_string()),
17467                kind: Some(khive_types::SubstrateKind::Note),
17468                embedding_model: Some("embed-a".to_string()),
17469                filter: None,
17470                backend_hints: None,
17471            })
17472            .await
17473            .unwrap();
17474        assert!(
17475            hits_a.iter().any(|h| h.subject_id == note.id),
17476            "embed-a must hold a vector for the note after update; got {hits_a:?}"
17477        );
17478
17479        let hits_b = rt
17480            .vectors_for_model(&tok, "embed-b")
17481            .unwrap()
17482            .search(VectorSearchRequest {
17483                query_vectors: vec![query_vec],
17484                top_k: 10,
17485                namespace: Some(ns.as_str().to_string()),
17486                kind: Some(khive_types::SubstrateKind::Note),
17487                embedding_model: Some("embed-b".to_string()),
17488                filter: None,
17489                backend_hints: None,
17490            })
17491            .await
17492            .unwrap();
17493        assert!(
17494            hits_b.iter().any(|h| h.subject_id == note.id),
17495            "embed-b must hold a vector for the note after update; got {hits_b:?}"
17496        );
17497    }
17498
17499    // ── By-ID ops must not filter by namespace ──────────────────────────────
17500    //
17501    // A namespace-gated by-ID op on an entity stamped "foreign" from a "local"
17502    // token would return NotFound, causing gtd.complete / update blindness.
17503    // UUID is globally unique; by-ID ops find the record regardless of
17504    // which namespace the caller's token carries.
17505
17506    #[tokio::test]
17507    async fn get_entity_cross_namespace_succeeds() {
17508        let rt = rt();
17509        // Create under "lambda:leo".
17510        let leo_tok = NamespaceToken::for_namespace(Namespace::parse("lambda:leo").unwrap());
17511        let entity = rt
17512            .create_entity(&leo_tok, "concept", None, "Peer-Entity", None, None, vec![])
17513            .await
17514            .unwrap();
17515        assert_eq!(entity.namespace, "lambda:leo");
17516
17517        // Read from "local" — must succeed (no namespace gate on by-ID get).
17518        let local_tok = NamespaceToken::local();
17519        let fetched = rt.get_entity(&local_tok, entity.id).await;
17520        assert!(
17521            fetched.is_ok(),
17522            "get_entity from local token must find lambda:leo entity; got {:?}",
17523            fetched
17524        );
17525        assert_eq!(fetched.unwrap().id, entity.id);
17526    }
17527
17528    #[tokio::test]
17529    async fn update_entity_cross_namespace_succeeds() {
17530        let rt = rt();
17531        let leo_tok = NamespaceToken::for_namespace(Namespace::parse("lambda:leo").unwrap());
17532        let entity = rt
17533            .create_entity(
17534                &leo_tok,
17535                "concept",
17536                None,
17537                "Peer-Entity-Update",
17538                None,
17539                None,
17540                vec![],
17541            )
17542            .await
17543            .unwrap();
17544
17545        // Update from "local" token — must not error with NotFound.
17546        let local_tok = NamespaceToken::local();
17547        let patch = crate::curation::EntityPatch {
17548            name: Some("Peer-Entity-Updated".to_string()),
17549            ..Default::default()
17550        };
17551        let result = rt.update_entity(&local_tok, entity.id, patch).await;
17552        assert!(
17553            result.is_ok(),
17554            "update_entity from local token must succeed on lambda:leo entity; got {:?}",
17555            result
17556        );
17557        assert_eq!(result.unwrap().name, "Peer-Entity-Updated");
17558    }
17559
17560    #[tokio::test]
17561    async fn delete_entity_cross_namespace_succeeds() {
17562        let rt = rt();
17563        let leo_tok = NamespaceToken::for_namespace(Namespace::parse("lambda:leo").unwrap());
17564        let entity = rt
17565            .create_entity(
17566                &leo_tok,
17567                "concept",
17568                None,
17569                "Peer-Entity-Delete",
17570                None,
17571                None,
17572                vec![],
17573            )
17574            .await
17575            .unwrap();
17576
17577        // Delete from "local" token — must succeed.
17578        let local_tok = NamespaceToken::local();
17579        let deleted = rt.delete_entity(&local_tok, entity.id, false).await;
17580        assert!(
17581            deleted.is_ok(),
17582            "delete_entity from local token must succeed on lambda:leo entity; got {:?}",
17583            deleted
17584        );
17585        assert!(
17586            deleted.unwrap(),
17587            "delete must return true when entity existed"
17588        );
17589    }
17590
17591    #[tokio::test]
17592    async fn namespace_preserved_on_entity_after_cross_namespace_get() {
17593        let rt = rt();
17594        let leo_tok = NamespaceToken::for_namespace(Namespace::parse("lambda:leo").unwrap());
17595        let entity = rt
17596            .create_entity(
17597                &leo_tok,
17598                "concept",
17599                None,
17600                "NS-Preserved",
17601                None,
17602                None,
17603                vec![],
17604            )
17605            .await
17606            .unwrap();
17607
17608        // The namespace column on the fetched record must still say "lambda:leo".
17609        let local_tok = NamespaceToken::local();
17610        let fetched = rt.get_entity(&local_tok, entity.id).await.unwrap();
17611        assert_eq!(
17612            fetched.namespace, "lambda:leo",
17613            "namespace column must be preserved; not overwritten with caller's namespace"
17614        );
17615    }
17616
17617    // ── PackByIdResolver unit tests ──────────────────────────────────────────
17618
17619    use crate::pack::PackByIdResolver;
17620    use tokio::sync::Mutex as TokioMutex;
17621
17622    #[derive(Debug, Default)]
17623    struct MockResolverState {
17624        owned: Vec<Uuid>,
17625        deleted: Vec<Uuid>,
17626        delete_calls: Vec<(Uuid, bool)>,
17627    }
17628
17629    struct MockPackResolver(TokioMutex<MockResolverState>);
17630
17631    impl MockPackResolver {
17632        fn new() -> Self {
17633            Self(TokioMutex::new(MockResolverState::default()))
17634        }
17635    }
17636
17637    #[async_trait::async_trait]
17638    impl crate::pack::PackByIdResolver for MockPackResolver {
17639        async fn resolve_by_id(&self, id: Uuid) -> Result<Option<Resolved>, RuntimeError> {
17640            let state = self.0.lock().await;
17641            if state.owned.contains(&id) && !state.deleted.contains(&id) {
17642                Ok(Some(Resolved::PackRecord {
17643                    pack: "mock".into(),
17644                    kind: "widget".into(),
17645                    data: serde_json::json!({ "id": id.to_string(), "name": "test-widget" }),
17646                }))
17647            } else {
17648                Ok(None)
17649            }
17650        }
17651
17652        async fn resolve_by_id_including_deleted(
17653            &self,
17654            id: Uuid,
17655        ) -> Result<Option<Resolved>, RuntimeError> {
17656            let state = self.0.lock().await;
17657            if state.owned.contains(&id) {
17658                Ok(Some(Resolved::PackRecord {
17659                    pack: "mock".into(),
17660                    kind: "widget".into(),
17661                    data: serde_json::json!({ "id": id.to_string(), "name": "test-widget" }),
17662                }))
17663            } else {
17664                Ok(None)
17665            }
17666        }
17667
17668        async fn delete_by_id(
17669            &self,
17670            id: Uuid,
17671            hard: bool,
17672        ) -> Result<serde_json::Value, RuntimeError> {
17673            let mut state = self.0.lock().await;
17674            if !state.owned.contains(&id) {
17675                return Err(RuntimeError::NotFound(format!(
17676                    "mock widget not found: {id}"
17677                )));
17678            }
17679            state.delete_calls.push((id, hard));
17680            if hard {
17681                state.owned.retain(|&x| x != id);
17682                state.deleted.retain(|&x| x != id);
17683            } else {
17684                state.deleted.push(id);
17685            }
17686            Ok(
17687                serde_json::json!({ "deleted": true, "id": id.to_string(), "kind": "widget", "hard": hard }),
17688            )
17689        }
17690    }
17691
17692    fn registry_with_mock_resolver(
17693        rt: KhiveRuntime,
17694        resolver: Box<dyn crate::pack::PackByIdResolver>,
17695    ) -> crate::VerbRegistry {
17696        use crate::pack::{PackRuntime, VerbRegistryBuilder};
17697        use khive_types::{HandlerDef, VerbCategory, Visibility};
17698
17699        static MINIMAL_HANDLERS: &[HandlerDef] = &[HandlerDef {
17700            name: "minimal.noop",
17701            description: "noop",
17702            visibility: Visibility::Verb,
17703            category: VerbCategory::Commissive,
17704            params: &[],
17705        }];
17706
17707        struct MinimalPack;
17708        impl khive_types::Pack for MinimalPack {
17709            const NAME: &'static str = "minimal";
17710            const NOTE_KINDS: &'static [&'static str] = &[];
17711            const ENTITY_KINDS: &'static [&'static str] = &[];
17712            const HANDLERS: &'static [HandlerDef] = MINIMAL_HANDLERS;
17713        }
17714        #[async_trait::async_trait]
17715        impl PackRuntime for MinimalPack {
17716            fn name(&self) -> &str {
17717                "minimal"
17718            }
17719            fn note_kinds(&self) -> &'static [&'static str] {
17720                &[]
17721            }
17722            fn entity_kinds(&self) -> &'static [&'static str] {
17723                &[]
17724            }
17725            fn handlers(&self) -> &'static [HandlerDef] {
17726                MINIMAL_HANDLERS
17727            }
17728            async fn dispatch(
17729                &self,
17730                _verb: &str,
17731                _params: serde_json::Value,
17732                _registry: &crate::VerbRegistry,
17733                _token: &NamespaceToken,
17734            ) -> Result<serde_json::Value, RuntimeError> {
17735                Err(RuntimeError::InvalidInput("stub".into()))
17736            }
17737        }
17738
17739        let _ = rt;
17740        let mut builder = VerbRegistryBuilder::new();
17741        builder.register(MinimalPack);
17742        builder.register_resolver("mock", resolver);
17743        builder.build().expect("registry build failed")
17744    }
17745
17746    #[tokio::test]
17747    async fn pack_record_resolved_pair_returns_none() {
17748        let pr = Resolved::PackRecord {
17749            pack: "knowledge".into(),
17750            kind: "atom".into(),
17751            data: serde_json::json!({}),
17752        };
17753        assert!(
17754            resolved_pair(Some(&pr)).is_none(),
17755            "PackRecord must not be a valid edge endpoint"
17756        );
17757    }
17758
17759    #[test]
17760    fn resolved_pair_surfaces_entity_type() {
17761        let e = Resolved::Entity(
17762            Entity::new("mathlib", "concept", "Nat.add_comm").with_entity_type(Some("theorem")),
17763        );
17764        assert_eq!(
17765            resolved_pair(Some(&e)),
17766            Some(("entity", "concept", Some("theorem"))),
17767            "entity_type subtype must be surfaced alongside base kind"
17768        );
17769    }
17770
17771    #[test]
17772    fn endpoint_of_type_matches_subtype_not_base_kind() {
17773        // An entity whose base kind is "concept" and subtype is "theorem".
17774        let kind = "concept";
17775        let et = Some("theorem");
17776
17777        // EntityOfType matches only when BOTH base kind and subtype match.
17778        assert!(endpoint_matches(
17779            &EndpointKind::EntityOfType {
17780                kind: "concept",
17781                entity_type: "theorem",
17782            },
17783            "entity",
17784            kind,
17785            et
17786        ));
17787        assert!(!endpoint_matches(
17788            &EndpointKind::EntityOfType {
17789                kind: "concept",
17790                entity_type: "definition",
17791            },
17792            "entity",
17793            kind,
17794            et
17795        ));
17796
17797        // The silently-inert trap: EntityOfKind sees only the BASE
17798        // kind, so EntityOfKind("theorem") never matches a concept/theorem.
17799        assert!(!endpoint_matches(
17800            &EndpointKind::EntityOfKind("theorem"),
17801            "entity",
17802            kind,
17803            et
17804        ));
17805        // EntityOfKind still matches the base kind.
17806        assert!(endpoint_matches(
17807            &EndpointKind::EntityOfKind("concept"),
17808            "entity",
17809            kind,
17810            et
17811        ));
17812
17813        // EntityOfType rejects non-entity substrates and entities with no subtype.
17814        assert!(!endpoint_matches(
17815            &EndpointKind::EntityOfType {
17816                kind: "concept",
17817                entity_type: "theorem",
17818            },
17819            "note",
17820            "task",
17821            None
17822        ));
17823        assert!(!endpoint_matches(
17824            &EndpointKind::EntityOfType {
17825                kind: "concept",
17826                entity_type: "theorem",
17827            },
17828            "entity",
17829            kind,
17830            None
17831        ));
17832    }
17833
17834    #[test]
17835    fn endpoint_of_type_requires_base_kind_match() {
17836        // Regression: an entity with entity_type="theorem" but base kind != "concept"
17837        // must NOT match a formal concept rule. This was the exact bypass:
17838        // before the fix, EntityOfType("theorem") ignored the base kind entirely.
17839        let wrong_base_kind = "project"; // not "concept"
17840        let et = Some("theorem");
17841
17842        // The formal rule requires kind="concept". A "project" entity with
17843        // entity_type="theorem" must not match — even though the subtype string
17844        // matches — because the base kind differs.
17845        assert!(
17846            !endpoint_matches(
17847                &EndpointKind::EntityOfType {
17848                    kind: "concept",
17849                    entity_type: "theorem",
17850                },
17851                "entity",
17852                wrong_base_kind,
17853                et
17854            ),
17855            "EntityOfType must reject an entity whose base kind != rule.kind \
17856             even when entity_type matches — the pre-fix bug admitted this"
17857        );
17858
17859        // The correct concept entity with the same subtype still matches.
17860        assert!(endpoint_matches(
17861            &EndpointKind::EntityOfType {
17862                kind: "concept",
17863                entity_type: "theorem",
17864            },
17865            "entity",
17866            "concept",
17867            et
17868        ));
17869    }
17870
17871    #[tokio::test]
17872    async fn registry_resolvers_accessor_returns_registered() {
17873        let resolver = Box::new(MockPackResolver::new());
17874        let registry = registry_with_mock_resolver(rt(), resolver);
17875        assert_eq!(registry.resolvers().len(), 1);
17876        assert_eq!(registry.resolvers()[0].0, "mock");
17877    }
17878
17879    #[tokio::test]
17880    async fn mock_resolver_resolve_by_id_returns_pack_record() {
17881        let id = Uuid::new_v4();
17882        let resolver: Box<dyn PackByIdResolver> = Box::new(MockPackResolver::new());
17883        // We need interior access — downcast first, then use via trait.
17884        let inner = MockPackResolver::new();
17885        inner.0.lock().await.owned.push(id);
17886        let result: Result<Option<Resolved>, RuntimeError> = inner.resolve_by_id(id).await;
17887        match result.unwrap() {
17888            Some(Resolved::PackRecord { pack, kind, data }) => {
17889                assert_eq!(pack, "mock");
17890                assert_eq!(kind, "widget");
17891                assert_eq!(data["id"].as_str().unwrap(), id.to_string());
17892            }
17893            other => panic!("expected PackRecord, got {:?}", other),
17894        }
17895        let _ = resolver;
17896    }
17897
17898    #[tokio::test]
17899    async fn mock_resolver_resolve_unknown_uuid_returns_none() {
17900        let inner = MockPackResolver::new();
17901        let id = Uuid::new_v4();
17902        let result: Result<Option<Resolved>, RuntimeError> = inner.resolve_by_id(id).await;
17903        assert!(result.unwrap().is_none());
17904    }
17905
17906    #[tokio::test]
17907    async fn mock_resolver_delete_soft_records_call() {
17908        let id = Uuid::new_v4();
17909        let inner = MockPackResolver::new();
17910        inner.0.lock().await.owned.push(id);
17911
17912        let result: Result<serde_json::Value, RuntimeError> = inner.delete_by_id(id, false).await;
17913        let result = result.unwrap();
17914        assert_eq!(result["deleted"], serde_json::json!(true));
17915        assert_eq!(result["hard"], serde_json::json!(false));
17916
17917        // After soft-delete: resolve_by_id returns None, but including_deleted returns Some.
17918        let live: Result<Option<Resolved>, RuntimeError> = inner.resolve_by_id(id).await;
17919        assert!(live.unwrap().is_none());
17920        let incl: Result<Option<Resolved>, RuntimeError> =
17921            inner.resolve_by_id_including_deleted(id).await;
17922        assert!(incl.unwrap().is_some());
17923    }
17924
17925    #[tokio::test]
17926    async fn mock_resolver_delete_hard_removes_record() {
17927        let id = Uuid::new_v4();
17928        let inner = MockPackResolver::new();
17929        inner.0.lock().await.owned.push(id);
17930
17931        let result: Result<serde_json::Value, RuntimeError> = inner.delete_by_id(id, true).await;
17932        assert_eq!(result.unwrap()["hard"], serde_json::json!(true));
17933
17934        // After hard-delete: neither probe finds the record.
17935        let incl: Result<Option<Resolved>, RuntimeError> =
17936            inner.resolve_by_id_including_deleted(id).await;
17937        assert!(incl.unwrap().is_none());
17938    }
17939
17940    #[tokio::test]
17941    async fn pack_record_not_valid_context_entity() {
17942        // Validates the GTD handler arm compiles and returns InvalidInput.
17943        // We exercise the match logic directly by constructing a PackRecord Resolved.
17944        let pr = Resolved::PackRecord {
17945            pack: "knowledge".into(),
17946            kind: "atom".into(),
17947            data: serde_json::json!({}),
17948        };
17949        // The match in GTD handlers.rs now handles PackRecord → InvalidInput.
17950        // We can verify the enum variant is reachable.
17951        assert!(matches!(pr, Resolved::PackRecord { .. }));
17952    }
17953
17954    // ── Batched enrich_neighbor_hits / enrich_path_nodes ────────────────────
17955
17956    fn neighbor_hit(node_id: Uuid) -> NeighborHit {
17957        NeighborHit {
17958            node_id,
17959            edge_id: Uuid::new_v4(),
17960            relation: EdgeRelation::Extends,
17961            weight: 1.0,
17962            name: None,
17963            kind: None,
17964            entity_type: None,
17965        }
17966    }
17967
17968    fn path_node(node_id: Uuid, depth: usize) -> PathNode {
17969        PathNode {
17970            node_id,
17971            via_edge: None,
17972            depth,
17973            name: None,
17974            kind: None,
17975            properties: None,
17976            weight: 0.0,
17977        }
17978    }
17979
17980    /// merge_traversal_paths_by_root: three namespaces each contribute the
17981    /// same root plus 2 distinct non-root nodes (each namespace already at
17982    /// its own `limit`, matching the per-namespace SQL-layer cap). The
17983    /// union across namespaces is 6 distinct non-root nodes; the merge must
17984    /// re-enforce `limit` on that union rather than passing it through.
17985    #[test]
17986    fn merge_traversal_paths_reenforces_limit_across_namespaces() {
17987        let root = Uuid::new_v4();
17988        let path_for = |n: usize| GraphPath {
17989            root_id: root,
17990            nodes: (0..n).map(|_| path_node(Uuid::new_v4(), 1)).collect(),
17991            total_weight: 1.0,
17992        };
17993
17994        let paths = vec![path_for(2), path_for(2), path_for(2)];
17995        let merged = merge_traversal_paths_by_root(paths, Some(2));
17996
17997        assert_eq!(merged.len(), 1);
17998        assert_eq!(
17999            merged[0].nodes.len(),
18000            2,
18001            "merge must re-enforce limit=2 on the unioned nodes, got {:?}",
18002            merged[0].nodes
18003        );
18004    }
18005
18006    /// merge_traversal_paths_by_root: a node reachable at different depths
18007    /// via two namespaces must report its shallowest depth and the
18008    /// `via_edge` that produced that depth, and the merged node order must
18009    /// be BFS (ascending depth) rather than the concatenation order of the
18010    /// per-namespace inputs.
18011    #[test]
18012    fn merge_traversal_paths_keeps_shortest_depth_and_bfs_order() {
18013        let root = Uuid::new_v4();
18014        let shared = Uuid::new_v4();
18015        let far_node = Uuid::new_v4();
18016        let deep_edge = Uuid::new_v4();
18017        let shallow_edge = Uuid::new_v4();
18018
18019        // Namespace processed first: an unrelated node at depth 1, and the
18020        // shared node reached the long way, at depth 4.
18021        let ns_first = GraphPath {
18022            root_id: root,
18023            nodes: vec![
18024                path_node(far_node, 1),
18025                PathNode {
18026                    node_id: shared,
18027                    via_edge: Some(deep_edge),
18028                    depth: 4,
18029                    name: None,
18030                    kind: None,
18031                    properties: None,
18032                    weight: 0.0,
18033                },
18034            ],
18035            total_weight: 1.0,
18036        };
18037        // Namespace processed second: the same shared node, reached at depth 2.
18038        let ns_second = GraphPath {
18039            root_id: root,
18040            nodes: vec![PathNode {
18041                node_id: shared,
18042                via_edge: Some(shallow_edge),
18043                depth: 2,
18044                name: None,
18045                kind: None,
18046                properties: None,
18047                weight: 0.0,
18048            }],
18049            total_weight: 1.0,
18050        };
18051
18052        let merged = merge_traversal_paths_by_root(vec![ns_first, ns_second], None);
18053
18054        assert_eq!(merged.len(), 1);
18055        let nodes = &merged[0].nodes;
18056        assert!(
18057            nodes.windows(2).all(|w| w[0].depth <= w[1].depth),
18058            "merged nodes must be in BFS (ascending depth) order, got {:?}",
18059            nodes
18060                .iter()
18061                .map(|n| (n.node_id, n.depth))
18062                .collect::<Vec<_>>()
18063        );
18064        let shared_node = nodes
18065            .iter()
18066            .find(|n| n.node_id == shared)
18067            .expect("shared node must survive the merge");
18068        assert_eq!(
18069            shared_node.depth, 2,
18070            "shared node must report its shortest depth across namespaces"
18071        );
18072        assert_eq!(
18073            shared_node.via_edge,
18074            Some(shallow_edge),
18075            "shared node must carry the via_edge that produced the shortest path"
18076        );
18077    }
18078
18079    /// merge_traversal_paths_by_root: `total_weight` must describe the nodes
18080    /// the caller is actually handed. The heaviest node here sits deepest, so
18081    /// re-applying `limit` to the merged union drops it — and the reported
18082    /// weight has to drop with it rather than keep quoting a node that was
18083    /// screened out.
18084    #[test]
18085    fn merge_traversal_paths_total_weight_drops_with_the_node_it_described() {
18086        let root = Uuid::new_v4();
18087        let weighted = |depth: usize, weight: f64| PathNode {
18088            node_id: Uuid::new_v4(),
18089            via_edge: None,
18090            depth,
18091            name: None,
18092            kind: None,
18093            properties: None,
18094            weight,
18095        };
18096
18097        let path = GraphPath {
18098            root_id: root,
18099            nodes: vec![weighted(1, 0.5), weighted(1, 0.4), weighted(2, 9.0)],
18100            total_weight: 9.0,
18101        };
18102
18103        let merged = merge_traversal_paths_by_root(vec![path], Some(2));
18104
18105        assert_eq!(merged.len(), 1);
18106        assert_eq!(
18107            merged[0].nodes.len(),
18108            2,
18109            "limit=2 must drop the depth-2 node"
18110        );
18111        assert_eq!(
18112            merged[0].total_weight, 0.5,
18113            "total_weight must be the max over surviving nodes, not the 9.0 \
18114             carried by the node the limit removed"
18115        );
18116    }
18117
18118    /// enrich_neighbor_hits: entity hit resolved, note hit resolved with
18119    /// name-fallback to "[kind]", bogus UUID left as None.  Order preserved.
18120    #[tokio::test]
18121    async fn enrich_neighbor_hits_batch_entity_note_and_bogus() {
18122        let rt = rt();
18123        let tok = NamespaceToken::local();
18124
18125        // Create an entity neighbor.
18126        let entity = rt
18127            .create_entity(&tok, "concept", None, "MyEntity", None, None, vec![])
18128            .await
18129            .unwrap();
18130
18131        // Nameless note — name falls back to "[observation]".
18132        let note = rt
18133            .create_note(&tok, "observation", None, "body", Some(0.5), None, vec![])
18134            .await
18135            .unwrap();
18136
18137        let bogus_id = Uuid::new_v4();
18138
18139        let mut hits = vec![
18140            neighbor_hit(entity.id),
18141            neighbor_hit(note.id),
18142            neighbor_hit(bogus_id),
18143        ];
18144
18145        rt.enrich_neighbor_hits(&tok, &mut hits).await;
18146
18147        assert_eq!(hits[0].name.as_deref(), Some("MyEntity"));
18148        assert_eq!(hits[0].kind.as_deref(), Some("concept"));
18149
18150        assert_eq!(hits[1].name.as_deref(), Some("[observation]"));
18151        assert_eq!(hits[1].kind.as_deref(), Some("observation"));
18152
18153        assert!(hits[2].name.is_none());
18154        assert!(hits[2].kind.is_none());
18155    }
18156
18157    /// enrich_neighbor_hits: note with a non-empty name uses the actual name.
18158    #[tokio::test]
18159    async fn enrich_neighbor_hits_note_with_name_uses_name() {
18160        let rt = rt();
18161        let tok = NamespaceToken::local();
18162
18163        let note = rt
18164            .create_note(
18165                &tok,
18166                "insight",
18167                Some("NoteTitle"),
18168                "body",
18169                Some(0.5),
18170                None,
18171                vec![],
18172            )
18173            .await
18174            .unwrap();
18175
18176        let mut hits = vec![neighbor_hit(note.id)];
18177        rt.enrich_neighbor_hits(&tok, &mut hits).await;
18178
18179        assert_eq!(hits[0].name.as_deref(), Some("NoteTitle"));
18180        assert_eq!(hits[0].kind.as_deref(), Some("insight"));
18181    }
18182
18183    /// enrich_path_nodes: two paths sharing a repeated node_id; each node
18184    /// enriched from a single batch; unresolved node stays None.
18185    #[tokio::test]
18186    async fn enrich_path_nodes_batch_dedup_and_unresolved() {
18187        let rt = rt();
18188        let tok = NamespaceToken::local();
18189
18190        let ea = rt
18191            .create_entity(&tok, "concept", None, "Alpha", None, None, vec![])
18192            .await
18193            .unwrap();
18194        let eb = rt
18195            .create_entity(&tok, "document", None, "Beta", None, None, vec![])
18196            .await
18197            .unwrap();
18198        let bogus_id = Uuid::new_v4();
18199
18200        // Path 1: ea → eb → bogus  |  Path 2: eb → ea  (shared nodes, reversed)
18201        let mut paths = vec![
18202            GraphPath {
18203                root_id: ea.id,
18204                nodes: vec![
18205                    path_node(ea.id, 0),
18206                    path_node(eb.id, 1),
18207                    path_node(bogus_id, 2),
18208                ],
18209                total_weight: 1.0,
18210            },
18211            GraphPath {
18212                root_id: eb.id,
18213                nodes: vec![path_node(eb.id, 0), path_node(ea.id, 1)],
18214                total_weight: 1.0,
18215            },
18216        ];
18217
18218        rt.enrich_path_nodes(&tok, &mut paths, false).await;
18219
18220        assert_eq!(paths[0].nodes[0].name.as_deref(), Some("Alpha"));
18221        assert_eq!(paths[0].nodes[0].kind.as_deref(), Some("concept"));
18222        assert_eq!(paths[0].nodes[1].name.as_deref(), Some("Beta"));
18223        assert_eq!(paths[0].nodes[1].kind.as_deref(), Some("document"));
18224        assert!(paths[0].nodes[2].name.is_none());
18225        assert!(paths[0].nodes[2].kind.is_none());
18226
18227        // Shared nodes resolve from the same HashMap — order within each path is preserved.
18228        assert_eq!(paths[1].nodes[0].name.as_deref(), Some("Beta"));
18229        assert_eq!(paths[1].nodes[0].kind.as_deref(), Some("document"));
18230        assert_eq!(paths[1].nodes[1].name.as_deref(), Some("Alpha"));
18231        assert_eq!(paths[1].nodes[1].kind.as_deref(), Some("concept"));
18232    }
18233
18234    /// enrich_neighbor_hits and enrich_path_nodes must resolve entities whose
18235    /// namespace is in the token's extra-visible set (not only the primary).
18236    ///
18237    /// Regression: the old `get_entities_by_ids`
18238    /// call left `filter.namespaces` unset, which collapses to
18239    /// `namespace = primary` in `build_entity_where`.  Graph expansion already
18240    /// crosses visible namespaces, so enrichment must match that scope.
18241    #[tokio::test]
18242    async fn enrich_resolves_entities_in_extra_visible_namespace() {
18243        let rt = KhiveRuntime::memory().unwrap();
18244
18245        let ns_a = Namespace::parse("enrich-ns-a").unwrap();
18246        let ns_b = Namespace::parse("enrich-ns-b").unwrap();
18247
18248        let tok_b = rt.authorize(ns_b.clone()).unwrap();
18249
18250        // Entity lives in ns-b.
18251        let entity_b = rt
18252            .create_entity(&tok_b, "concept", None, "EntityInB", None, None, vec![])
18253            .await
18254            .unwrap();
18255        assert_eq!(entity_b.namespace, "enrich-ns-b");
18256
18257        // Token whose primary is ns-a but ns-b is in the visible set.
18258        let vis_tok = rt
18259            .authorize_with_visibility(ns_a.clone(), vec![ns_b.clone()])
18260            .unwrap();
18261
18262        // ── neighbor hits ──────────────────────────────────────────────────
18263        let mut hits = vec![neighbor_hit(entity_b.id)];
18264        rt.enrich_neighbor_hits(&vis_tok, &mut hits).await;
18265
18266        assert_eq!(
18267            hits[0].name.as_deref(),
18268            Some("EntityInB"),
18269            "entity in extra-visible ns must be enriched by enrich_neighbor_hits"
18270        );
18271        assert_eq!(hits[0].kind.as_deref(), Some("concept"));
18272
18273        // ── path nodes ─────────────────────────────────────────────────────
18274        let mut paths = vec![GraphPath {
18275            root_id: entity_b.id,
18276            nodes: vec![path_node(entity_b.id, 0)],
18277            total_weight: 1.0,
18278        }];
18279        rt.enrich_path_nodes(&vis_tok, &mut paths, false).await;
18280
18281        assert_eq!(
18282            paths[0].nodes[0].name.as_deref(),
18283            Some("EntityInB"),
18284            "entity in extra-visible ns must be enriched by enrich_path_nodes"
18285        );
18286        assert_eq!(paths[0].nodes[0].kind.as_deref(), Some("concept"));
18287    }
18288
18289    /// enrich_neighbor_hits populates entity_type from the already-fetched entity
18290    /// batch when the entity has a non-null entity_type.  Entities without one and
18291    /// note nodes leave entity_type as None.
18292    #[tokio::test]
18293    async fn enrich_neighbor_hits_populates_entity_type() {
18294        let rt = rt();
18295        let tok = NamespaceToken::local();
18296
18297        let props = serde_json::json!({"domain": "attention"});
18298        let entity = rt
18299            .create_entity(
18300                &tok,
18301                "concept",
18302                Some("algorithm"),
18303                "FlashAttn",
18304                None,
18305                Some(props),
18306                vec![],
18307            )
18308            .await
18309            .unwrap();
18310
18311        let entity_no_type = rt
18312            .create_entity(&tok, "concept", None, "PlainConcept", None, None, vec![])
18313            .await
18314            .unwrap();
18315
18316        let mut hits = vec![neighbor_hit(entity.id), neighbor_hit(entity_no_type.id)];
18317        rt.enrich_neighbor_hits(&tok, &mut hits).await;
18318
18319        assert_eq!(hits[0].entity_type.as_deref(), Some("algorithm"));
18320        assert!(
18321            hits[1].entity_type.is_none(),
18322            "entity without entity_type must leave the field as None"
18323        );
18324    }
18325
18326    /// enrich_path_nodes populates properties from the already-fetched entity
18327    /// batch when the entity has a non-null properties blob.  Entities without
18328    /// properties leave the field as None.
18329    #[tokio::test]
18330    async fn enrich_path_nodes_populates_properties() {
18331        let rt = rt();
18332        let tok = NamespaceToken::local();
18333
18334        let props = serde_json::json!({"year": 2024, "venue": "NeurIPS"});
18335        let entity_with_props = rt
18336            .create_entity(
18337                &tok,
18338                "document",
18339                None,
18340                "AttentionPaper",
18341                None,
18342                Some(props.clone()),
18343                vec![],
18344            )
18345            .await
18346            .unwrap();
18347
18348        let entity_no_props = rt
18349            .create_entity(&tok, "concept", None, "BareConceptNode", None, None, vec![])
18350            .await
18351            .unwrap();
18352
18353        let mut paths = vec![GraphPath {
18354            root_id: entity_with_props.id,
18355            nodes: vec![
18356                path_node(entity_with_props.id, 0),
18357                path_node(entity_no_props.id, 1),
18358            ],
18359            total_weight: 1.0,
18360        }];
18361
18362        rt.enrich_path_nodes(&tok, &mut paths, true).await;
18363
18364        assert_eq!(
18365            paths[0].nodes[0].properties.as_ref(),
18366            Some(&props),
18367            "properties must be filled when entity has a non-null properties blob"
18368        );
18369        assert!(
18370            paths[0].nodes[1].properties.is_none(),
18371            "entity without properties must leave the field as None"
18372        );
18373    }
18374
18375    /// The runtime enforces the public root cap before doing any root-existence
18376    /// lookups or handing work to storage.
18377    #[tokio::test]
18378    async fn traverse_rejects_root_count_above_public_cap_before_lookup() {
18379        use khive_storage::types::{TraversalOptions, MAX_TRAVERSAL_ROOTS};
18380
18381        let rt = rt();
18382        let tok = NamespaceToken::local();
18383        let err = rt
18384            .traverse(
18385                &tok,
18386                TraversalRequest {
18387                    roots: (0..=MAX_TRAVERSAL_ROOTS)
18388                        .map(|_| uuid::Uuid::new_v4())
18389                        .collect(),
18390                    options: TraversalOptions {
18391                        max_depth: 1,
18392                        direction: Direction::Out,
18393                        relations: None,
18394                        min_weight: None,
18395                        limit: None,
18396                    },
18397                    include_roots: false,
18398                    include_properties: false,
18399                    execution_budget: Default::default(),
18400                },
18401            )
18402            .await
18403            .unwrap_err();
18404
18405        assert!(matches!(
18406            err,
18407            RuntimeError::InvalidInput(message)
18408                if message.contains("roots must contain at most 100 entries")
18409        ));
18410    }
18411
18412    // ── Additive EDGE_RULES composition: pack EntityOfType rules must not shadow
18413    // the base EntityOfKind contract for the same relation. ──────────────────────
18414    //
18415    // When a pack contributes EntityOfType rules for a relation (e.g. variant_of:
18416    // goal -> theorem and goal -> definition), the base contract's EntityOfKind
18417    // rule for the same relation (concept -> concept) must still fire for entities
18418    // whose base kind is "concept" but whose EntityOfType pair is not in any pack rule.
18419    //
18420    // Specifically: a goal entity resolves to base kind "concept". A goal -> goal
18421    // variant_of edge has no matching pack rule (goal -> goal is not declared), so
18422    // pack_rule_allows returns false. The validator then extracts the base kind
18423    // ("concept") and checks base_entity_rule_allows, which returns true.
18424    // The edge is therefore allowed: additive composition holds.
18425
18426    #[test]
18427    fn pack_entity_of_type_rules_do_not_shadow_base_entity_of_kind_rule() {
18428        // Formal-style EntityOfType rules for variant_of: goal -> theorem, goal -> definition.
18429        // goal -> goal is deliberately absent — that case must fall through to the base rule.
18430        let pack_rules: Vec<EdgeEndpointRule> = vec![
18431            EdgeEndpointRule {
18432                relation: EdgeRelation::VariantOf,
18433                source: EndpointKind::EntityOfType {
18434                    kind: "concept",
18435                    entity_type: "goal",
18436                },
18437                target: EndpointKind::EntityOfType {
18438                    kind: "concept",
18439                    entity_type: "theorem",
18440                },
18441            },
18442            EdgeEndpointRule {
18443                relation: EdgeRelation::VariantOf,
18444                source: EndpointKind::EntityOfType {
18445                    kind: "concept",
18446                    entity_type: "goal",
18447                },
18448                target: EndpointKind::EntityOfType {
18449                    kind: "concept",
18450                    entity_type: "definition",
18451                },
18452            },
18453        ];
18454
18455        let goal_a =
18456            Resolved::Entity(Entity::new("local", "concept", "G-a").with_entity_type(Some("goal")));
18457        let goal_b =
18458            Resolved::Entity(Entity::new("local", "concept", "G-b").with_entity_type(Some("goal")));
18459
18460        // Pack rules do not cover goal -> goal, so pack_rule_allows must return false.
18461        assert!(
18462            !pack_rule_allows(
18463                &pack_rules,
18464                EdgeRelation::VariantOf,
18465                Some(&goal_a),
18466                Some(&goal_b)
18467            ),
18468            "pack rules must not cover goal->goal variant_of (no such rule declared)"
18469        );
18470
18471        // The base contract allows concept -> concept for variant_of.
18472        // A goal entity's base kind is "concept", so this must return true.
18473        assert!(
18474            base_entity_rule_allows("concept", EdgeRelation::VariantOf, "concept"),
18475            "base contract must allow concept->concept variant_of regardless of pack EntityOfType rules"
18476        );
18477    }
18478
18479    // Integration path: pack EntityOfType rules installed on the runtime must not
18480    // block a goal->goal variant_of link that the base contract already permits.
18481    // Exercises validate_edge_relation_endpoints lines 1173-1223:
18482    //   pack miss -> extract e.kind ("concept") -> base_entity_rule_allows -> Ok.
18483    #[tokio::test]
18484    async fn link_variant_of_goal_to_goal_allowed_when_pack_has_entity_of_type_rules() {
18485        let rt = rt();
18486        let tok = NamespaceToken::local();
18487
18488        // Install formal-style EntityOfType rules for variant_of (goal -> theorem/definition).
18489        // goal -> goal is absent so the base concept->concept rule must carry this case.
18490        rt.install_edge_rules(vec![
18491            EdgeEndpointRule {
18492                relation: EdgeRelation::VariantOf,
18493                source: EndpointKind::EntityOfType {
18494                    kind: "concept",
18495                    entity_type: "goal",
18496                },
18497                target: EndpointKind::EntityOfType {
18498                    kind: "concept",
18499                    entity_type: "theorem",
18500                },
18501            },
18502            EdgeEndpointRule {
18503                relation: EdgeRelation::VariantOf,
18504                source: EndpointKind::EntityOfType {
18505                    kind: "concept",
18506                    entity_type: "goal",
18507                },
18508                target: EndpointKind::EntityOfType {
18509                    kind: "concept",
18510                    entity_type: "definition",
18511                },
18512            },
18513        ]);
18514
18515        let a = rt
18516            .create_entity(
18517                &tok,
18518                "concept",
18519                Some("goal"),
18520                "Goal Alpha",
18521                None,
18522                None,
18523                vec![],
18524            )
18525            .await
18526            .unwrap();
18527        let b = rt
18528            .create_entity(
18529                &tok,
18530                "concept",
18531                Some("goal"),
18532                "Goal Beta",
18533                None,
18534                None,
18535                vec![],
18536            )
18537            .await
18538            .unwrap();
18539
18540        // Neither endpoint matches any pack rule (no goal->goal rule).
18541        // The base concept->concept rule must fire and allow the edge.
18542        let result = rt
18543            .link(&tok, a.id, b.id, EdgeRelation::VariantOf, 1.0, None)
18544            .await;
18545        assert!(
18546            result.is_ok(),
18547            "goal->goal variant_of must be allowed via the base concept->concept rule \
18548             even when EntityOfType rules for variant_of are installed; got {result:?}"
18549        );
18550
18551        // Fail-closed check: additive rules must not make validation fail-open.
18552        // A goal(concept) -> project variant_of edge has no matching pack rule
18553        // (project is not in the installed variant_of rules) and no matching base
18554        // rule (no (concept, VariantOf, project) row). It must be rejected.
18555        let p = rt
18556            .create_entity(&tok, "project", None, "Proj", None, None, vec![])
18557            .await
18558            .unwrap();
18559        let bad = rt
18560            .link(&tok, a.id, p.id, EdgeRelation::VariantOf, 1.0, None)
18561            .await;
18562        assert!(
18563            bad.is_err(),
18564            "additive pack rules must not make validation fail-open; \
18565             goal(concept)->project variant_of must be rejected (pack miss + base miss); \
18566             got {bad:?}"
18567        );
18568    }
18569
18570    // Load-bearing positive: a pack EntityOfType rule adds an endpoint the base contract
18571    // does not cover. The base contract has no (concept, DependsOn, concept) row (the
18572    // DependsOn rows are project/service/artifact only). A theorem->definition DependsOn
18573    // edge can therefore ONLY pass through the pack rule, proving the union is load-bearing.
18574    #[tokio::test]
18575    async fn link_depends_on_theorem_to_definition_allowed_only_via_pack_rule() {
18576        let rt = rt();
18577        let tok = NamespaceToken::local();
18578
18579        // Confirm the base contract does NOT allow concept->concept DependsOn.
18580        // (Documented here so the assertion below is not a tautology.)
18581        assert!(
18582            !base_entity_rule_allows("concept", EdgeRelation::DependsOn, "concept"),
18583            "base contract must not allow concept->concept DependsOn; \
18584             test would be vacuous if this precondition fails"
18585        );
18586
18587        // Install a single EntityOfType rule: theorem depends_on definition.
18588        // With no rules, the link would be rejected by the base contract.
18589        // With this rule, it must be accepted via the pack path (lines 1173-1179).
18590        rt.install_edge_rules(vec![EdgeEndpointRule {
18591            relation: EdgeRelation::DependsOn,
18592            source: EndpointKind::EntityOfType {
18593                kind: "concept",
18594                entity_type: "theorem",
18595            },
18596            target: EndpointKind::EntityOfType {
18597                kind: "concept",
18598                entity_type: "definition",
18599            },
18600        }]);
18601
18602        let thm = rt
18603            .create_entity(&tok, "concept", Some("theorem"), "T1", None, None, vec![])
18604            .await
18605            .unwrap();
18606        let def = rt
18607            .create_entity(
18608                &tok,
18609                "concept",
18610                Some("definition"),
18611                "D1",
18612                None,
18613                None,
18614                vec![],
18615            )
18616            .await
18617            .unwrap();
18618
18619        // This can only pass through the pack rule — the base contract rejects it.
18620        let result = rt
18621            .link(&tok, thm.id, def.id, EdgeRelation::DependsOn, 1.0, None)
18622            .await;
18623        assert!(
18624            result.is_ok(),
18625            "theorem->definition DependsOn must be allowed by the installed pack rule; \
18626             the base contract has no concept->concept DependsOn row; got {result:?}"
18627        );
18628    }
18629
18630    // ── Provenance endpoint pairs ────────────────────────────────────────────
18631    // Four base endpoint pairs: document->person and document->org (document
18632    // authorship), concept->org (concept origination by an org), and
18633    // document->document (normative document dependency). Positive links for
18634    // each pair, plus a direction-matters negative guard.
18635
18636    #[tokio::test]
18637    async fn link_document_introduced_by_person_allowed() {
18638        let rt = rt();
18639        let tok = NamespaceToken::local();
18640
18641        let doc = rt
18642            .create_entity(&tok, "document", None, "Paper", None, None, vec![])
18643            .await
18644            .unwrap();
18645        let author = rt
18646            .create_entity(&tok, "person", None, "Author", None, None, vec![])
18647            .await
18648            .unwrap();
18649
18650        let result = rt
18651            .link(
18652                &tok,
18653                doc.id,
18654                author.id,
18655                EdgeRelation::IntroducedBy,
18656                1.0,
18657                None,
18658            )
18659            .await;
18660        assert!(
18661            result.is_ok(),
18662            "document->person introduced_by must be allowed by the ADR-002 \
18663             endpoint amendment; got {result:?}"
18664        );
18665    }
18666
18667    #[tokio::test]
18668    async fn link_document_introduced_by_org_allowed() {
18669        let rt = rt();
18670        let tok = NamespaceToken::local();
18671
18672        let doc = rt
18673            .create_entity(&tok, "document", None, "Whitepaper", None, None, vec![])
18674            .await
18675            .unwrap();
18676        let org = rt
18677            .create_entity(&tok, "org", None, "Publisher", None, None, vec![])
18678            .await
18679            .unwrap();
18680
18681        let result = rt
18682            .link(&tok, doc.id, org.id, EdgeRelation::IntroducedBy, 1.0, None)
18683            .await;
18684        assert!(
18685            result.is_ok(),
18686            "document->org introduced_by must be allowed by the ADR-002 \
18687             endpoint amendment; got {result:?}"
18688        );
18689    }
18690
18691    #[tokio::test]
18692    async fn link_concept_introduced_by_org_allowed() {
18693        let rt = rt();
18694        let tok = NamespaceToken::local();
18695
18696        let concept = rt
18697            .create_entity(&tok, "concept", None, "Architecture", None, None, vec![])
18698            .await
18699            .unwrap();
18700        let org = rt
18701            .create_entity(&tok, "org", None, "Originator", None, None, vec![])
18702            .await
18703            .unwrap();
18704
18705        let result = rt
18706            .link(
18707                &tok,
18708                concept.id,
18709                org.id,
18710                EdgeRelation::IntroducedBy,
18711                1.0,
18712                None,
18713            )
18714            .await;
18715        assert!(
18716            result.is_ok(),
18717            "concept->org introduced_by must be allowed by the ADR-002 \
18718             endpoint amendment; got {result:?}"
18719        );
18720    }
18721
18722    #[tokio::test]
18723    async fn link_document_depends_on_document_allowed() {
18724        let rt = rt();
18725        let tok = NamespaceToken::local();
18726
18727        let doc_a = rt
18728            .create_entity(&tok, "document", None, "Spec A", None, None, vec![])
18729            .await
18730            .unwrap();
18731        let doc_b = rt
18732            .create_entity(&tok, "document", None, "Spec B", None, None, vec![])
18733            .await
18734            .unwrap();
18735
18736        let result = rt
18737            .link(&tok, doc_a.id, doc_b.id, EdgeRelation::DependsOn, 1.0, None)
18738            .await;
18739        assert!(
18740            result.is_ok(),
18741            "document->document depends_on must be allowed by the ADR-002 \
18742             endpoint amendment; got {result:?}"
18743        );
18744        let edge = result.unwrap();
18745        let dk = edge
18746            .metadata
18747            .as_ref()
18748            .and_then(|m| m.get("dependency_kind"))
18749            .and_then(|v| v.as_str());
18750        assert_eq!(
18751            dk,
18752            Some("normative"),
18753            "document->document depends_on must infer dependency_kind=normative"
18754        );
18755    }
18756
18757    // ── Web hyperlink endpoint pair (ADR-191) ────────────────────────────────
18758    // document->document is the only links_to pair: a hyperlink's target is a
18759    // URL, which resolves to a document, never to the service that hosts it.
18760
18761    #[tokio::test]
18762    async fn link_document_links_to_document_allowed_service_and_concept_targets_rejected() {
18763        let rt = rt();
18764        let tok = NamespaceToken::local();
18765
18766        let page_a = rt
18767            .create_entity(&tok, "document", None, "Page A", None, None, vec![])
18768            .await
18769            .unwrap();
18770        let page_b = rt
18771            .create_entity(&tok, "document", None, "Page B", None, None, vec![])
18772            .await
18773            .unwrap();
18774
18775        let result = rt
18776            .link(&tok, page_a.id, page_b.id, EdgeRelation::LinksTo, 1.0, None)
18777            .await;
18778        assert!(
18779            result.is_ok(),
18780            "document->document links_to must be allowed by the ADR-191 \
18781             endpoint amendment; got {result:?}"
18782        );
18783        let edge = result.unwrap();
18784        assert!(
18785            edge.metadata.is_none(),
18786            "links_to carries no governed metadata and infers none, unlike \
18787             depends_on; got {:?}",
18788            edge.metadata
18789        );
18790
18791        let svc = rt
18792            .create_entity(&tok, "service", None, "Some Site", None, None, vec![])
18793            .await
18794            .unwrap();
18795        let concept = rt
18796            .create_entity(&tok, "concept", None, "Some Concept", None, None, vec![])
18797            .await
18798            .unwrap();
18799
18800        let doc_to_service = rt
18801            .link(&tok, page_a.id, svc.id, EdgeRelation::LinksTo, 1.0, None)
18802            .await
18803            .unwrap_err();
18804        assert!(
18805            doc_to_service
18806                .to_string()
18807                .contains("base endpoint allowlist"),
18808            "document->service links_to must be refused with the \
18809             endpoint-contract error; got {doc_to_service}"
18810        );
18811
18812        let concept_to_doc = rt
18813            .link(
18814                &tok,
18815                concept.id,
18816                page_a.id,
18817                EdgeRelation::LinksTo,
18818                1.0,
18819                None,
18820            )
18821            .await
18822            .unwrap_err();
18823        assert!(
18824            concept_to_doc
18825                .to_string()
18826                .contains("base endpoint allowlist"),
18827            "concept->document links_to must be refused with the \
18828             endpoint-contract error; got {concept_to_doc}"
18829        );
18830    }
18831
18832    #[tokio::test]
18833    async fn link_org_introduced_by_document_rejected_direction_matters() {
18834        let rt = rt();
18835        let tok = NamespaceToken::local();
18836
18837        let org = rt
18838            .create_entity(&tok, "org", None, "Publisher", None, None, vec![])
18839            .await
18840            .unwrap();
18841        let doc = rt
18842            .create_entity(&tok, "document", None, "Paper", None, None, vec![])
18843            .await
18844            .unwrap();
18845
18846        // The amendment adds document->org, not org->document. Direction matters:
18847        // an org is not "introduced by" a document it published.
18848        let result = rt
18849            .link(&tok, org.id, doc.id, EdgeRelation::IntroducedBy, 1.0, None)
18850            .await;
18851        assert!(
18852            result.is_err(),
18853            "org->document introduced_by must remain rejected; only \
18854             document->org is permitted, not the reverse; got {result:?}"
18855        );
18856    }
18857
18858    // ── Service provenance endpoint pair (ADR-167) ──────────────────────────
18859    // service->document is the only introduced_by pair with a service
18860    // endpoint: a service's provenance is stated by the document that
18861    // introduced it. Neighboring pairs and the reverse stay rejected.
18862
18863    #[tokio::test]
18864    async fn link_service_introduced_by_document_allowed() {
18865        let rt = rt();
18866        let tok = NamespaceToken::local();
18867
18868        let svc = rt
18869            .create_entity(&tok, "service", None, "Search API", None, None, vec![])
18870            .await
18871            .unwrap();
18872        let doc = rt
18873            .create_entity(&tok, "document", None, "Design doc", None, None, vec![])
18874            .await
18875            .unwrap();
18876
18877        let result = rt
18878            .link(&tok, svc.id, doc.id, EdgeRelation::IntroducedBy, 1.0, None)
18879            .await;
18880        assert!(
18881            result.is_ok(),
18882            "service->document introduced_by must be allowed by the ADR-167 \
18883             endpoint amendment; got {result:?}"
18884        );
18885    }
18886
18887    #[tokio::test]
18888    async fn link_service_introduced_by_person_rejected() {
18889        let rt = rt();
18890        let tok = NamespaceToken::local();
18891
18892        let svc = rt
18893            .create_entity(&tok, "service", None, "Search API", None, None, vec![])
18894            .await
18895            .unwrap();
18896        let person = rt
18897            .create_entity(&tok, "person", None, "Operator", None, None, vec![])
18898            .await
18899            .unwrap();
18900
18901        let result = rt
18902            .link(
18903                &tok,
18904                svc.id,
18905                person.id,
18906                EdgeRelation::IntroducedBy,
18907                1.0,
18908                None,
18909            )
18910            .await;
18911        assert!(
18912            result.is_err(),
18913            "service->person introduced_by is not in the endpoint table and \
18914             must be rejected; got {result:?}"
18915        );
18916    }
18917
18918    #[tokio::test]
18919    async fn link_service_introduced_by_org_rejected() {
18920        let rt = rt();
18921        let tok = NamespaceToken::local();
18922
18923        let svc = rt
18924            .create_entity(&tok, "service", None, "Search API", None, None, vec![])
18925            .await
18926            .unwrap();
18927        let org = rt
18928            .create_entity(&tok, "org", None, "Vendor", None, None, vec![])
18929            .await
18930            .unwrap();
18931
18932        let result = rt
18933            .link(&tok, svc.id, org.id, EdgeRelation::IntroducedBy, 1.0, None)
18934            .await;
18935        assert!(
18936            result.is_err(),
18937            "service->org introduced_by is not in the endpoint table and \
18938             must be rejected; got {result:?}"
18939        );
18940    }
18941
18942    #[tokio::test]
18943    async fn link_document_introduced_by_service_rejected_direction_matters() {
18944        let rt = rt();
18945        let tok = NamespaceToken::local();
18946
18947        let doc = rt
18948            .create_entity(&tok, "document", None, "Design doc", None, None, vec![])
18949            .await
18950            .unwrap();
18951        let svc = rt
18952            .create_entity(&tok, "service", None, "Search API", None, None, vec![])
18953            .await
18954            .unwrap();
18955
18956        let result = rt
18957            .link(&tok, doc.id, svc.id, EdgeRelation::IntroducedBy, 1.0, None)
18958            .await;
18959        assert!(
18960            result.is_err(),
18961            "document->service introduced_by must remain rejected; only \
18962             service->document is permitted, not the reverse; got {result:?}"
18963        );
18964    }
18965
18966    #[tokio::test]
18967    async fn link_service_derived_from_document_rejected() {
18968        let rt = rt();
18969        let tok = NamespaceToken::local();
18970
18971        let svc = rt
18972            .create_entity(&tok, "service", None, "Search API", None, None, vec![])
18973            .await
18974            .unwrap();
18975        let doc = rt
18976            .create_entity(&tok, "document", None, "Design doc", None, None, vec![])
18977            .await
18978            .unwrap();
18979
18980        let result = rt
18981            .link(&tok, svc.id, doc.id, EdgeRelation::DerivedFrom, 1.0, None)
18982            .await;
18983        assert!(
18984            result.is_err(),
18985            "service->document is permitted only for introduced_by; the same \
18986             endpoints under derived_from must be rejected; got {result:?}"
18987        );
18988    }
18989
18990    // ── create_note_with_embedding_content ──────────────────────────────────
18991
18992    /// Like `ConstVecService`/`ConstVecProvider` above, but records every text
18993    /// it is asked to embed so a test can assert exactly what reached the
18994    /// "provider" — used to verify the effective embed text is the capped
18995    /// override, not the full note content.
18996    struct CapturingVecService {
18997        dims: usize,
18998        captured: Arc<std::sync::Mutex<Vec<String>>>,
18999    }
19000
19001    #[async_trait]
19002    impl EmbeddingService for CapturingVecService {
19003        async fn embed(
19004            &self,
19005            texts: &[String],
19006            _model: EmbeddingModel,
19007        ) -> std::result::Result<Vec<Vec<f32>>, EmbedError> {
19008            for text in texts {
19009                if text.len() > MAX_TEXT_BYTES {
19010                    return Err(EmbedError::TextTooLong {
19011                        length: text.len(),
19012                        max: MAX_TEXT_BYTES,
19013                    });
19014                }
19015            }
19016            self.captured.lock().unwrap().extend(texts.iter().cloned());
19017            Ok(texts.iter().map(|_| vec![1.0_f32; self.dims]).collect())
19018        }
19019
19020        fn supports_model(&self, _model: EmbeddingModel) -> bool {
19021            true
19022        }
19023
19024        fn name(&self) -> &'static str {
19025            "capturing-vec"
19026        }
19027    }
19028
19029    struct CapturingVecProvider {
19030        provider_name: String,
19031        dims: usize,
19032        captured: Arc<std::sync::Mutex<Vec<String>>>,
19033    }
19034
19035    #[async_trait]
19036    impl EmbedderProvider for CapturingVecProvider {
19037        fn name(&self) -> &str {
19038            &self.provider_name
19039        }
19040
19041        fn dimensions(&self) -> usize {
19042            self.dims
19043        }
19044
19045        async fn build(&self) -> crate::error::RuntimeResult<Arc<dyn EmbeddingService>> {
19046            Ok(Arc::new(CapturingVecService {
19047                dims: self.dims,
19048                captured: Arc::clone(&self.captured),
19049            }))
19050        }
19051    }
19052
19053    #[tokio::test]
19054    async fn create_bounds_embedding_input_without_truncating_stored_content() {
19055        let rt = rt();
19056        let tok = NamespaceToken::local();
19057        let captured = Arc::new(std::sync::Mutex::new(Vec::new()));
19058        rt.register_embedder(CapturingVecProvider {
19059            provider_name: "strict-length-test".into(),
19060            dims: 4,
19061            captured: Arc::clone(&captured),
19062        });
19063
19064        let content = format!("{}\u{1f980}tail", "a".repeat(MAX_TEXT_BYTES - 1));
19065        let note = rt
19066            .create_note(&tok, "observation", None, &content, None, None, vec![])
19067            .await
19068            .expect("over-length note create must succeed");
19069        let fetched = rt
19070            .notes(&tok)
19071            .unwrap()
19072            .get_note(note.id)
19073            .await
19074            .unwrap()
19075            .expect("created note must be retrievable");
19076        assert_eq!(fetched.content, content, "stored content must remain full");
19077
19078        let embedded = captured.lock().unwrap().clone();
19079        assert_eq!(embedded.len(), 1);
19080        assert_eq!(embedded[0].len(), MAX_TEXT_BYTES - 1);
19081        assert!(embedded[0].is_char_boundary(embedded[0].len()));
19082        assert!(!embedded[0].contains('\u{1f980}'));
19083
19084        let vector_info = rt
19085            .vectors_for_model(&tok, "strict-length-test")
19086            .expect("vector store")
19087            .info()
19088            .await
19089            .expect("vector info");
19090        assert_eq!(vector_info.dimensions, 4);
19091        assert_eq!(vector_info.entry_count, 1);
19092
19093        captured.lock().unwrap().clear();
19094        rt.reindex_note(&tok, &fetched)
19095            .await
19096            .expect("reindex must bound the same stored content");
19097        assert_eq!(captured.lock().unwrap()[0].len(), MAX_TEXT_BYTES - 1);
19098
19099        captured.lock().unwrap().clear();
19100        rt.embed_document_batch_with_model("strict-length-test", std::slice::from_ref(&content))
19101            .await
19102            .expect("batch reindex seam must bound stored content");
19103        assert_eq!(captured.lock().unwrap()[0].len(), MAX_TEXT_BYTES - 1);
19104
19105        let normal = "normal byte-identical embedding input";
19106        rt.create_note(&tok, "observation", None, normal, None, None, vec![])
19107            .await
19108            .expect("normal note create must succeed");
19109        assert_eq!(captured.lock().unwrap().last().unwrap(), normal);
19110
19111        let long_description = format!("{}\u{1f980}tail", "b".repeat(MAX_TEXT_BYTES));
19112        rt.create_entity(
19113            &tok,
19114            "concept",
19115            None,
19116            "entity",
19117            Some(&long_description),
19118            None,
19119            vec![],
19120        )
19121        .await
19122        .expect("over-length entity create must succeed");
19123    }
19124
19125    #[tokio::test]
19126    async fn create_note_with_embedding_content_none_matches_create_note() {
19127        let rt = rt();
19128        let tok = NamespaceToken::local();
19129        let captured = Arc::new(std::sync::Mutex::new(Vec::new()));
19130        rt.register_embedder(CapturingVecProvider {
19131            provider_name: "capturing-vec".into(),
19132            dims: 4,
19133            captured: Arc::clone(&captured),
19134        });
19135
19136        let note = rt
19137            .create_note_with_embedding_content(
19138                &tok,
19139                "observation",
19140                None,
19141                "full content, no override",
19142                None,
19143                None,
19144                None,
19145                vec![],
19146            )
19147            .await
19148            .expect("create with None override must behave like create_note");
19149        assert_eq!(note.content, "full content, no override");
19150        let seen = captured.lock().unwrap().clone();
19151        assert_eq!(
19152            seen,
19153            vec!["full content, no override".to_string()],
19154            "with no override the embedder must see the full content"
19155        );
19156    }
19157
19158    #[tokio::test]
19159    async fn create_note_with_embedding_content_embeds_capped_override_and_stores_full_content() {
19160        let rt = rt();
19161        let tok = NamespaceToken::local();
19162        let captured = Arc::new(std::sync::Mutex::new(Vec::new()));
19163        rt.register_embedder(CapturingVecProvider {
19164            provider_name: "capturing-vec".into(),
19165            dims: 4,
19166            captured: Arc::clone(&captured),
19167        });
19168
19169        let full = "head-term and then a very long tail-term that exceeds any cap";
19170        let head = &full[.."head-term and then a very long".len()];
19171
19172        let note = rt
19173            .create_note_with_embedding_content(
19174                &tok,
19175                "observation",
19176                None,
19177                full,
19178                Some(head),
19179                None,
19180                None,
19181                vec![],
19182            )
19183            .await
19184            .expect("proper-prefix override must be accepted");
19185        assert_eq!(note.content, full, "stored content must be the full text");
19186
19187        let seen = captured.lock().unwrap().clone();
19188        assert_eq!(
19189            seen,
19190            vec![head.to_string()],
19191            "embedder must see only the capped override"
19192        );
19193    }
19194
19195    #[tokio::test]
19196    async fn create_note_with_embedding_content_fans_out_identical_override_to_multiple_models() {
19197        let rt = rt();
19198        let tok = NamespaceToken::local();
19199        let captured_a = Arc::new(std::sync::Mutex::new(Vec::new()));
19200        let captured_b = Arc::new(std::sync::Mutex::new(Vec::new()));
19201        rt.register_embedder(CapturingVecProvider {
19202            provider_name: "capturing-vec-a".into(),
19203            dims: 4,
19204            captured: Arc::clone(&captured_a),
19205        });
19206        rt.register_embedder(CapturingVecProvider {
19207            provider_name: "capturing-vec-b".into(),
19208            dims: 4,
19209            captured: Arc::clone(&captured_b),
19210        });
19211
19212        let full = "head-only-embedded plus a long discarded tail";
19213        let head = &full[.."head-only-embedded".len()];
19214
19215        rt.create_note_with_embedding_content(
19216            &tok,
19217            "observation",
19218            None,
19219            full,
19220            Some(head),
19221            None,
19222            None,
19223            vec![],
19224        )
19225        .await
19226        .expect("create ok");
19227
19228        assert_eq!(
19229            captured_a.lock().unwrap().clone(),
19230            vec![head.to_string()],
19231            "model A must receive the identical capped override"
19232        );
19233        assert_eq!(
19234            captured_b.lock().unwrap().clone(),
19235            vec![head.to_string()],
19236            "model B must receive the identical capped override"
19237        );
19238
19239        // Both models actually persisted a vector row for the note (not just
19240        // an embed call that was discarded before insertion).
19241        let vs_a = rt
19242            .vectors_for_model(&tok, "capturing-vec-a")
19243            .expect("vector store for model A");
19244        assert_eq!(
19245            vs_a.count().await.expect("vector count A"),
19246            1,
19247            "model A must have exactly one vector row for the note"
19248        );
19249        let vs_b = rt
19250            .vectors_for_model(&tok, "capturing-vec-b")
19251            .expect("vector store for model B");
19252        assert_eq!(
19253            vs_b.count().await.expect("vector count B"),
19254            1,
19255            "model B must have exactly one vector row for the note"
19256        );
19257    }
19258
19259    #[tokio::test]
19260    async fn create_note_with_embedding_content_rejects_empty_override() {
19261        let rt = rt();
19262        let tok = NamespaceToken::local();
19263
19264        let err = rt
19265            .create_note_with_embedding_content(
19266                &tok,
19267                "observation",
19268                None,
19269                "some content",
19270                Some(""),
19271                None,
19272                None,
19273                vec![],
19274            )
19275            .await
19276            .expect_err("empty override must be rejected");
19277        assert!(matches!(err, RuntimeError::InvalidInput(_)));
19278    }
19279
19280    #[tokio::test]
19281    async fn create_note_with_embedding_content_rejects_non_prefix_override() {
19282        let rt = rt();
19283        let tok = NamespaceToken::local();
19284
19285        let err = rt
19286            .create_note_with_embedding_content(
19287                &tok,
19288                "observation",
19289                None,
19290                "the actual content",
19291                Some("an unrelated string"),
19292                None,
19293                None,
19294                vec![],
19295            )
19296            .await
19297            .expect_err("non-prefix override must be rejected");
19298        assert!(matches!(err, RuntimeError::InvalidInput(ref m) if m.contains("prefix")));
19299    }
19300
19301    #[tokio::test]
19302    async fn create_note_with_embedding_content_rejects_equal_length_override() {
19303        let rt = rt();
19304        let tok = NamespaceToken::local();
19305
19306        // Same length and same text as `content` is not a *proper* prefix.
19307        let err = rt
19308            .create_note_with_embedding_content(
19309                &tok,
19310                "observation",
19311                None,
19312                "identical text",
19313                Some("identical text"),
19314                None,
19315                None,
19316                vec![],
19317            )
19318            .await
19319            .expect_err("an equal-length override must be rejected as not a proper prefix");
19320        assert!(matches!(err, RuntimeError::InvalidInput(ref m) if m.contains("prefix")));
19321    }
19322
19323    #[tokio::test]
19324    async fn create_note_with_embedding_content_rejects_secret_bearing_override() {
19325        let rt = rt();
19326        let tok = NamespaceToken::local();
19327
19328        let token_span = "ghp_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
19329        let content = format!("{token_span} plus extra trailing content beyond the override");
19330        let embedding_content = format!("{token_span} plus extra");
19331
19332        let err = rt
19333            .create_note_with_embedding_content(
19334                &tok,
19335                "observation",
19336                None,
19337                &content,
19338                Some(&embedding_content),
19339                None,
19340                None,
19341                vec![],
19342            )
19343            .await
19344            .expect_err("a credential-shaped override must fail the secret gate");
19345        assert!(
19346            matches!(err, RuntimeError::SecretDetected(_)),
19347            "expected SecretDetected, got {err:?}"
19348        );
19349
19350        // Fail-closed: no note survives the rejected create.
19351        let count = rt
19352            .notes(&tok)
19353            .unwrap()
19354            .count_notes(tok.namespace().as_str(), None)
19355            .await
19356            .unwrap();
19357        assert_eq!(count, 0, "a rejected create must leave no note behind");
19358    }
19359
19360    // ── ADR-002 base endpoint contract conformance (issue #1715) ────────────
19361    // Parses the "### Base endpoint contract" tables straight out of
19362    // docs/adr/ADR-002-edge-ontology.md and asserts set-equality against
19363    // `BASE_ENTITY_ENDPOINT_RULES`. The ADR and the runtime allowlist are
19364    // maintained by hand in two places with nothing else comparing them; this
19365    // is the comparator, so a future amendment landing in only one of the two
19366    // fails CI instead of shipping a silent false refusal (or a silent
19367    // over-grant).
19368
19369    /// Subsections of the "Base endpoint contract" that are intentionally
19370    /// excluded from `BASE_ENTITY_ENDPOINT_RULES`:
19371    /// - "Annotation relation": `Note -> any substrate UUID` is a substrate-level
19372    ///   rule (source is always a note), not an entity-kind pair.
19373    /// - "KG pack extensions": additive rows declared via the KG pack's
19374    ///   `EDGE_RULES`, not part of the runtime's base allowlist.
19375    const ADR002_EXCLUDED_SUBSECTIONS: &[&str] = &["Annotation relation", "KG pack extensions"];
19376
19377    /// Parse the ADR-002 "Base endpoint contract" markdown tables into the same
19378    /// `(source_kind, relation, target_kind)` shape as `BASE_ENTITY_ENDPOINT_RULES`.
19379    fn parse_adr002_base_entity_endpoint_matrix(
19380    ) -> std::collections::HashSet<(String, EdgeRelation, String)> {
19381        let adr_path = format!(
19382            "{}/../../docs/adr/ADR-002-edge-ontology.md",
19383            env!("CARGO_MANIFEST_DIR")
19384        );
19385        let text = std::fs::read_to_string(&adr_path)
19386            .unwrap_or_else(|e| panic!("failed to read {adr_path}: {e}"));
19387        let lines: Vec<&str> = text.lines().collect();
19388
19389        let start = lines
19390            .iter()
19391            .position(|l| l.trim() == "### Base endpoint contract")
19392            .expect("ADR-002 must contain a '### Base endpoint contract' heading");
19393        let end = lines[start + 1..]
19394            .iter()
19395            .position(|l| l.starts_with("## "))
19396            .map(|i| start + 1 + i)
19397            .unwrap_or(lines.len());
19398        let section = &lines[start..end];
19399
19400        let mut triples = std::collections::HashSet::new();
19401        let mut excluded = false;
19402
19403        for line in section {
19404            let trimmed = line.trim();
19405            if let Some(title) = trimmed.strip_prefix("#### ") {
19406                excluded = ADR002_EXCLUDED_SUBSECTIONS
19407                    .iter()
19408                    .any(|ex| title.starts_with(ex));
19409                continue;
19410            }
19411            if excluded || !trimmed.starts_with('|') {
19412                continue;
19413            }
19414
19415            let cells: Vec<&str> = trimmed
19416                .trim_matches('|')
19417                .split('|')
19418                .map(|c| c.trim())
19419                .collect();
19420            if cells.len() != 3 {
19421                continue;
19422            }
19423            let is_header = cells[0].eq_ignore_ascii_case("Source");
19424            let is_separator = cells
19425                .iter()
19426                .all(|c| !c.is_empty() && c.chars().all(|ch| ch == '-'));
19427            if is_header || is_separator {
19428                continue;
19429            }
19430
19431            let src_raw = cells[0].trim_matches('`');
19432            let rel_raw = cells[1].trim_matches('`');
19433            let tgt_raw = cells[2].trim_matches('`');
19434
19435            let relation: EdgeRelation = rel_raw.parse().unwrap_or_else(|_| {
19436                panic!("ADR-002 base endpoint contract row has unparseable relation: {trimmed:?}")
19437            });
19438
19439            let src = if src_raw.eq_ignore_ascii_case("any entity") {
19440                "*".to_string()
19441            } else {
19442                src_raw.to_ascii_lowercase()
19443            };
19444            let tgt = tgt_raw.to_ascii_lowercase();
19445
19446            triples.insert((src, relation, tgt));
19447        }
19448
19449        triples
19450    }
19451
19452    #[test]
19453    fn base_entity_endpoint_rules_match_adr002_base_endpoint_contract() {
19454        // ADR-002 lives at the repository root, outside this crate's package.
19455        // In the repository the workspace manifest sits two levels up and the
19456        // ADR must be readable — a missing file there is a hard failure so a
19457        // rename cannot silently disarm this gate. From a published package
19458        // tarball neither exists; skip with disclosure instead of failing an
19459        // environment that cannot carry the canonical document.
19460        let workspace_manifest = format!("{}/../Cargo.toml", env!("CARGO_MANIFEST_DIR"));
19461        if !std::path::Path::new(&workspace_manifest).exists() {
19462            eprintln!(
19463                "skipping ADR-002 conformance: workspace manifest not present \
19464                 (published-package context); the check runs in the repository"
19465            );
19466            return;
19467        }
19468        let adr_matrix = parse_adr002_base_entity_endpoint_matrix();
19469        assert!(
19470            !adr_matrix.is_empty(),
19471            "parsed zero rows from ADR-002's base endpoint contract — parser or heading drift"
19472        );
19473
19474        let runtime_rules: std::collections::HashSet<(String, EdgeRelation, String)> =
19475            base_entity_endpoint_rules()
19476                .iter()
19477                .map(|(src, rel, tgt)| (src.to_string(), *rel, tgt.to_string()))
19478                .collect();
19479
19480        let missing_from_runtime: Vec<_> = adr_matrix.difference(&runtime_rules).collect();
19481        let extra_in_runtime: Vec<_> = runtime_rules.difference(&adr_matrix).collect();
19482
19483        assert!(
19484            missing_from_runtime.is_empty() && extra_in_runtime.is_empty(),
19485            "BASE_ENTITY_ENDPOINT_RULES has drifted from ADR-002's base endpoint contract.\n\
19486             In the ADR but not enforced by the runtime: {missing_from_runtime:#?}\n\
19487             Enforced by the runtime but not in the ADR: {extra_in_runtime:#?}"
19488        );
19489    }
19490
19491    // ── Universal reserved-key reservation (ADR-115 Amendment 1, first rung) ──
19492
19493    fn reserved_key_props() -> serde_json::Value {
19494        serde_json::json!({"khive:secret_gate": "exempted:content-sha256-manifest-v1"})
19495    }
19496
19497    #[tokio::test]
19498    async fn create_entity_rejects_reserved_secret_gate_key() {
19499        let rt = rt();
19500        let tok = NamespaceToken::local();
19501        let err = rt
19502            .create_entity(
19503                &tok,
19504                "concept",
19505                None,
19506                "reserved-key-entity",
19507                None,
19508                Some(reserved_key_props()),
19509                vec![],
19510            )
19511            .await
19512            .expect_err("caller-supplied reserved key must be rejected");
19513        assert!(
19514            matches!(err, RuntimeError::InvalidInput(ref msg) if msg.contains("khive:secret_gate")),
19515            "unexpected error: {err:?}"
19516        );
19517        // No partial mutation: the record must not exist under any name/search.
19518        let found = rt
19519            .list_entities(&tok, Some("concept"), None, 10, 0)
19520            .await
19521            .expect("list must succeed");
19522        assert!(
19523            found.iter().all(|e| e.name != "reserved-key-entity"),
19524            "rejected create must leave no row behind"
19525        );
19526    }
19527
19528    #[tokio::test]
19529    async fn create_note_rejects_reserved_secret_gate_key() {
19530        let rt = rt();
19531        let tok = NamespaceToken::local();
19532        let err = rt
19533            .create_note(
19534                &tok,
19535                "observation",
19536                None,
19537                "reserved-key note content",
19538                None,
19539                Some(reserved_key_props()),
19540                vec![],
19541            )
19542            .await
19543            .expect_err("caller-supplied reserved key must be rejected");
19544        assert!(
19545            matches!(err, RuntimeError::InvalidInput(ref msg) if msg.contains("khive:secret_gate")),
19546            "unexpected error: {err:?}"
19547        );
19548    }
19549
19550    #[tokio::test]
19551    async fn create_many_rejects_reserved_secret_gate_key_atomically() {
19552        let rt = rt();
19553        let tok = NamespaceToken::local();
19554        let specs = vec![
19555            EntityCreateSpec {
19556                kind: "concept".to_string(),
19557                entity_type: None,
19558                name: "clean-entity".to_string(),
19559                description: None,
19560                properties: None,
19561                tags: vec![],
19562            },
19563            EntityCreateSpec {
19564                kind: "concept".to_string(),
19565                entity_type: None,
19566                name: "reserved-key-entity".to_string(),
19567                description: None,
19568                properties: Some(reserved_key_props()),
19569                tags: vec![],
19570            },
19571        ];
19572        let err = rt
19573            .create_many(&tok, specs)
19574            .await
19575            .expect_err("a reserved key anywhere in the batch must reject the whole batch");
19576        assert!(
19577            matches!(err, RuntimeError::InvalidInput(ref msg) if msg.contains("khive:secret_gate")),
19578            "unexpected error: {err:?}"
19579        );
19580        // No partial mutation: the leading clean spec must not have been written either.
19581        let found = rt
19582            .list_entities(&tok, Some("concept"), None, 10, 0)
19583            .await
19584            .expect("list must succeed");
19585        assert!(
19586            found.is_empty(),
19587            "batch rejection must leave zero rows behind, found: {found:?}"
19588        );
19589    }
19590
19591    #[tokio::test]
19592    async fn create_entity_with_attachments_commits_roles_and_projects_content() {
19593        use khive_db::stores::blob::FsBlobStore;
19594        use khive_storage::BlobStore as _;
19595
19596        let runtime = rt();
19597        let token = NamespaceToken::local();
19598        let blob_dir = tempfile::tempdir().expect("blob tempdir");
19599        let blob_store =
19600            Arc::new(FsBlobStore::new(blob_dir.path().to_path_buf(), 0).expect("blob store"));
19601        let content_ref = blob_store
19602            .put(b"bundle".to_vec())
19603            .await
19604            .expect("publish bundle");
19605        let network_ref = blob_store
19606            .put(b"network".to_vec())
19607            .await
19608            .expect("publish network");
19609        runtime
19610            .install_blob_store(blob_store)
19611            .expect("install blob store");
19612
19613        let created = runtime
19614            .create_entity_with_attachments(
19615                &token,
19616                "artifact",
19617                None,
19618                "role-keyed artifact",
19619                None,
19620                None,
19621                vec![],
19622                vec![
19623                    NewAttachment {
19624                        role: "content".to_string(),
19625                        content_ref: content_ref.clone(),
19626                        media_type: Some("application/json".to_string()),
19627                        size_bytes: Some(6),
19628                    },
19629                    NewAttachment {
19630                        role: "fann-network".to_string(),
19631                        content_ref: network_ref.clone(),
19632                        media_type: Some("application/octet-stream".to_string()),
19633                        size_bytes: Some(7),
19634                    },
19635                ],
19636            )
19637            .await
19638            .expect("atomic record + attachments publication");
19639
19640        assert_eq!(created.content_ref.as_deref(), Some(content_ref.as_str()));
19641        let reloaded = runtime
19642            .get_entity(&token, created.id)
19643            .await
19644            .expect("reload entity");
19645        assert_eq!(reloaded.content_ref.as_deref(), Some(content_ref.as_str()));
19646        let attachments = runtime
19647            .attachments()
19648            .expect("main attachment store")
19649            .list_attachments(created.id)
19650            .await
19651            .expect("list attachments");
19652        assert_eq!(attachments.len(), 2);
19653        assert_eq!(attachments[0].role, "content");
19654        assert_eq!(attachments[0].content_ref, content_ref);
19655        assert_eq!(attachments[1].role, "fann-network");
19656        assert_eq!(attachments[1].content_ref, network_ref);
19657    }
19658}