Skip to main content

khive_runtime/
kg_read.rs

1//! Shared KG handle resolution and entity-delete routing for pack-scoped backends.
2
3use std::collections::{BTreeSet, HashMap, HashSet};
4
5use khive_storage::types::{Direction, NeighborCursor, NeighborHit, NeighborQuery};
6use uuid::Uuid;
7
8use crate::{KhiveRuntime, Namespace, NamespaceToken, Resolved, RuntimeError, VerbRegistry};
9
10/// Query options for a KG neighbor read whose origin may live on a pack backend.
11pub struct KgNeighborRead {
12    pub query: NeighborQuery,
13    pub after: Option<NeighborCursor>,
14    pub neighbor_kinds: Option<Vec<String>>,
15    pub enrich: bool,
16    /// Narrow graph selection to an already visible namespace while retaining
17    /// the caller token's identity and originating request metadata.
18    pub namespace: Option<Namespace>,
19}
20
21pub(crate) fn neighbor_read_namespaces<'a>(
22    token: &'a NamespaceToken,
23    namespace: Option<&'a Namespace>,
24) -> Result<&'a [Namespace], RuntimeError> {
25    match namespace {
26        Some(namespace) if token.visible_namespaces().contains(namespace) => {
27            Ok(std::slice::from_ref(namespace))
28        }
29        Some(_) => Err(RuntimeError::InvalidInput(
30            "KG neighbor namespace must already be visible to the caller".into(),
31        )),
32        None => Ok(token.visible_namespaces()),
33    }
34}
35
36impl VerbRegistry {
37    /// Resolve a live KG origin across configured backends before expanding
38    /// adjacency on the graph runtime with the original caller token.
39    pub async fn neighbors_for_kg_read(
40        &self,
41        runtime: &KhiveRuntime,
42        token: &NamespaceToken,
43        node_id: Uuid,
44        options: KgNeighborRead,
45    ) -> Result<Vec<NeighborHit>, RuntimeError> {
46        self.neighbors_for_kg_read_inner(runtime, token, node_id, options, false)
47            .await
48            .map(|(hits, _)| hits)
49    }
50
51    /// Resolve a live origin and share entity-kind hints from its graph's
52    /// deletion screen with mailbox filtering, retaining the original token.
53    pub async fn neighbors_for_kg_read_with_entity_kinds(
54        &self,
55        runtime: &KhiveRuntime,
56        token: &NamespaceToken,
57        node_id: Uuid,
58        options: KgNeighborRead,
59    ) -> Result<(Vec<NeighborHit>, HashMap<Uuid, String>), RuntimeError> {
60        self.neighbors_for_kg_read_inner(runtime, token, node_id, options, true)
61            .await
62    }
63
64    async fn neighbors_for_kg_read_inner(
65        &self,
66        runtime: &KhiveRuntime,
67        token: &NamespaceToken,
68        node_id: Uuid,
69        options: KgNeighborRead,
70        with_entity_kinds: bool,
71    ) -> Result<(Vec<NeighborHit>, HashMap<Uuid, String>), RuntimeError> {
72        neighbor_read_namespaces(token, options.namespace.as_ref())?;
73        if self
74            .resolve_kg_read_by_id(runtime, token, node_id, false)
75            .await?
76            .is_none()
77            && !runtime.substrate_exists_by_id(token, node_id).await?
78        {
79            return Err(RuntimeError::NotFound(format!(
80                "neighbor anchor {node_id} not found"
81            )));
82        }
83        if with_entity_kinds {
84            runtime
85                .neighbors_for_resolved_kg_read_with_entity_kinds(token, node_id, options)
86                .await
87        } else {
88            runtime
89                .neighbors_for_resolved_kg_read(token, node_id, options)
90                .await
91                .map(|hits| (hits, HashMap::new()))
92        }
93    }
94
95    /// The directed form of [`Self::neighbors_for_kg_read`], retaining stored
96    /// edge direction and the existing graph namespace selection.
97    pub async fn directed_neighbors_for_kg_read(
98        &self,
99        runtime: &KhiveRuntime,
100        token: &NamespaceToken,
101        node_id: Uuid,
102        query: NeighborQuery,
103        namespace: Option<&Namespace>,
104    ) -> Result<Vec<(NeighborHit, Direction)>, RuntimeError> {
105        neighbor_read_namespaces(token, namespace)?;
106        if self
107            .resolve_kg_read_by_id(runtime, token, node_id, false)
108            .await?
109            .is_none()
110            && !runtime.substrate_exists_by_id(token, node_id).await?
111        {
112            return Err(RuntimeError::NotFound(format!(
113                "neighbor anchor {node_id} not found"
114            )));
115        }
116        runtime
117            .directed_neighbors_for_resolved_kg_read(token, node_id, query, namespace)
118            .await
119    }
120}
121
122pub(crate) struct KgReadResolver {
123    runtimes: Vec<KhiveRuntime>,
124    primary: KhiveRuntime,
125    by_pack: HashMap<String, KhiveRuntime>,
126}
127
128impl KgReadResolver {
129    pub(crate) fn new(primary: &KhiveRuntime, runtimes: &HashMap<String, KhiveRuntime>) -> Self {
130        // Keep primary precedence deterministic and query each assigned backend
131        // once, even when several packs share it. These handles own no registry,
132        // so the registry-held topology creates no reference cycle.
133        let mut seen = HashSet::new();
134        let mut unique = Vec::new();
135        let mut packs: Vec<_> = runtimes.iter().collect();
136        packs.sort_by_key(|(name, _)| *name);
137        for runtime in std::iter::once(primary).chain(packs.into_iter().map(|(_, rt)| rt)) {
138            if seen.insert(runtime.backend_id().clone()) {
139                unique.push(runtime.clone());
140            }
141        }
142        Self {
143            runtimes: unique,
144            primary: primary.clone(),
145            by_pack: runtimes.clone(),
146        }
147    }
148
149    /// Every configured graph owner, before lint's physical-database deduplication.
150    pub(crate) fn runtimes_for_lint(&self) -> Vec<KhiveRuntime> {
151        let mut by_pack: Vec<_> = self.by_pack.iter().collect();
152        by_pack.sort_by_key(|(pack, _)| *pack);
153        std::iter::once(self.primary.clone())
154            .chain(by_pack.into_iter().map(|(_, runtime)| runtime.clone()))
155            .collect()
156    }
157
158    pub(crate) fn runtime_for_pack(&self, pack: &str) -> &KhiveRuntime {
159        self.by_pack.get(pack).unwrap_or(&self.primary)
160    }
161
162    pub(crate) async fn by_id(
163        &self,
164        token: &NamespaceToken,
165        id: Uuid,
166        include_deleted: bool,
167    ) -> Result<Option<Resolved>, RuntimeError> {
168        let mut found = None;
169        for runtime in &self.runtimes {
170            let candidate = if include_deleted {
171                runtime.resolve_by_id_including_deleted(token, id).await?
172            } else {
173                runtime.resolve_by_id(token, id).await?
174            };
175            // Preserve entity-before-note substrate precedence. A success must
176            // not hide a failure from a later configured backend.
177            if found.is_none()
178                || (matches!(&found, Some(Resolved::Note(_)))
179                    && matches!(&candidate, Some(Resolved::Entity(_))))
180            {
181                found = candidate;
182            }
183        }
184        Ok(found)
185    }
186
187    pub(crate) async fn entity_runtime(
188        &self,
189        token: &NamespaceToken,
190        id: Uuid,
191    ) -> Result<Option<KhiveRuntime>, RuntimeError> {
192        let mut owner = None;
193        for runtime in &self.runtimes {
194            let store = runtime.entities(token)?;
195            let entity = store.get_entity_including_deleted(id).await?;
196            if entity.is_some() {
197                if owner.is_some() {
198                    return Err(RuntimeError::InvalidInput(format!(
199                        "entity {id} exists on multiple backends; deletion is ambiguous"
200                    )));
201                }
202                owner = Some(runtime.clone());
203            }
204        }
205        Ok(owner)
206    }
207
208    pub(crate) async fn prefix(
209        &self,
210        prefix: &str,
211        include_deleted: bool,
212    ) -> Result<Option<Uuid>, RuntimeError> {
213        let mut matches = BTreeSet::new();
214        for runtime in &self.runtimes {
215            let candidate = runtime
216                .resolve_prefix_for_kg_read(prefix, include_deleted)
217                .await;
218            match candidate {
219                Ok(Some(id)) => {
220                    matches.insert(id);
221                }
222                Ok(None) => {}
223                Err(RuntimeError::AmbiguousPrefix { matches: ids, .. }) => matches.extend(ids),
224                Err(error) => return Err(error),
225            }
226        }
227        match matches.len() {
228            0 => Ok(None),
229            1 => Ok(matches.into_iter().next()),
230            _ => Err(RuntimeError::AmbiguousPrefix {
231                prefix: prefix.into(),
232                matches: matches.into_iter().collect(),
233            }),
234        }
235    }
236}
237
238#[cfg(test)]
239mod tests {
240    use super::*;
241    use crate::{BackendId, Namespace, RuntimeConfig};
242    use chrono::Utc;
243    use khive_storage::types::{Edge, LinkId};
244    use khive_storage::{Entity, Note};
245
246    #[tokio::test]
247    async fn kg_neighbor_namespace_selection_is_narrowing_and_keeps_directed_self_loops() {
248        let project = Namespace::parse("project").unwrap();
249        let main = KhiveRuntime::new(RuntimeConfig {
250            db_path: None,
251            backend_id: BackendId::main(),
252            actor_id: Some("reader".into()),
253            visible_namespaces: vec![project.clone()],
254            ..RuntimeConfig::no_embeddings()
255        })
256        .unwrap();
257        let token = main
258            .authorize_with_visibility(Namespace::local(), vec![project.clone()])
259            .unwrap();
260        let anchor = Entity::new("local", "concept", "namespace selector anchor");
261        let neighbor = Entity::new("project", "concept", "namespace selector neighbor");
262        for entity in [&anchor, &neighbor] {
263            main.entities(&token)
264                .unwrap()
265                .upsert_entity(entity.clone())
266                .await
267                .unwrap();
268        }
269        let self_loop = Uuid::new_v4();
270        for (namespace, id, source) in [
271            (Namespace::local(), self_loop, anchor.id),
272            (project.clone(), Uuid::new_v4(), neighbor.id),
273        ] {
274            let edge_token = main.authorize(namespace.clone()).unwrap();
275            let now = Utc::now();
276            main.graph(&edge_token)
277                .unwrap()
278                .upsert_edge(Edge {
279                    id: LinkId(id),
280                    namespace: namespace.to_string(),
281                    source_id: source,
282                    target_id: anchor.id,
283                    relation: khive_storage::EdgeRelation::Extends,
284                    weight: 1.0,
285                    created_at: now,
286                    updated_at: now,
287                    deleted_at: None,
288                    metadata: None,
289                    target_backend: None,
290                })
291                .await
292                .unwrap();
293        }
294        let registry = crate::VerbRegistryBuilder::new().build().unwrap();
295        let query = NeighborQuery {
296            direction: Direction::Both,
297            relations: None,
298            limit: Some(10),
299            min_weight: None,
300        };
301        let baseline = main
302            .neighbors_with_query_directed(&token, anchor.id, query.clone())
303            .await
304            .unwrap();
305        let full = registry
306            .directed_neighbors_for_kg_read(&main, &token, anchor.id, query.clone(), None)
307            .await
308            .unwrap();
309        let keys = |hits: &[(NeighborHit, Direction)]| {
310            hits.iter()
311                .map(|(hit, direction)| (hit.node_id, hit.edge_id, direction.clone()))
312                .collect::<Vec<_>>()
313        };
314        assert_eq!(keys(&full), keys(&baseline));
315        let local = registry
316            .directed_neighbors_for_kg_read(
317                &main,
318                &token,
319                anchor.id,
320                query.clone(),
321                Some(&Namespace::local()),
322            )
323            .await
324            .unwrap();
325        assert_eq!(local.len(), 2);
326        assert_eq!(local[0].0.edge_id, self_loop);
327        assert_eq!(local[0].1, Direction::Out);
328        assert_eq!(local[1].1, Direction::In);
329        let project_hits = registry
330            .neighbors_for_kg_read(
331                &main,
332                &token,
333                anchor.id,
334                KgNeighborRead {
335                    query: query.clone(),
336                    after: None,
337                    neighbor_kinds: None,
338                    enrich: true,
339                    namespace: Some(project),
340                },
341            )
342            .await
343            .unwrap();
344        assert_eq!(project_hits.len(), 1);
345        assert_eq!(project_hits[0].node_id, neighbor.id);
346        let outside = Namespace::parse("outside").unwrap();
347        assert!(matches!(
348            registry
349                .directed_neighbors_for_kg_read(
350                    &main,
351                    &token,
352                    anchor.id,
353                    query.clone(),
354                    Some(&outside)
355                )
356                .await,
357            Err(RuntimeError::InvalidInput(_))
358        ));
359        assert!(matches!(
360            registry
361                .neighbors_for_kg_read(
362                    &main,
363                    &token,
364                    anchor.id,
365                    KgNeighborRead {
366                        query,
367                        after: None,
368                        neighbor_kinds: None,
369                        enrich: true,
370                        namespace: Some(outside),
371                    },
372                )
373                .await,
374            Err(RuntimeError::InvalidInput(_))
375        ));
376    }
377
378    fn runtime(name: &str) -> KhiveRuntime {
379        KhiveRuntime::new(RuntimeConfig {
380            db_path: None,
381            backend_id: BackendId::parse(name).unwrap(),
382            ..RuntimeConfig::no_embeddings()
383        })
384        .unwrap()
385    }
386
387    async fn note(runtime: &KhiveRuntime, id: Uuid, namespace: &str) -> Note {
388        let token = runtime
389            .authorize(Namespace::parse(namespace).unwrap())
390            .unwrap();
391        let mut note = Note::new(namespace, "observation", "read routing fixture");
392        note.id = id;
393        runtime
394            .notes(&token)
395            .unwrap()
396            .upsert_note(note.clone())
397            .await
398            .unwrap();
399        note
400    }
401
402    #[tokio::test]
403    async fn issue2992_shared_reads_deduplicate_backends_and_preserve_global_prefix_ambiguity() {
404        let main = runtime("main");
405        let secondary = runtime("comm");
406        let resolver = KgReadResolver::new(
407            &main,
408            &HashMap::from([
409                ("kg".into(), main.clone()),
410                ("comm".into(), secondary.clone()),
411                ("another-pack".into(), secondary.clone()),
412            ]),
413        );
414        assert_eq!(resolver.runtimes.len(), 2);
415        let first = Uuid::parse_str("cafe1234-0000-4000-8000-000000000001").unwrap();
416        let second = Uuid::parse_str("cafe1234-0000-4000-8000-000000000002").unwrap();
417        let remote = note(&secondary, first, "elsewhere").await;
418        let token = main.authorize(Namespace::local()).unwrap();
419        assert!(
420            matches!(resolver.by_id(&token, first, false).await.unwrap(), Some(Resolved::Note(found)) if found == remote)
421        );
422        assert_eq!(
423            resolver.prefix("cafe1234", false).await.unwrap(),
424            Some(first)
425        );
426        // The same global UUID in another backend is not a distinct prefix candidate.
427        note(&main, first, "local").await;
428        assert_eq!(
429            resolver.prefix("cafe1234", false).await.unwrap(),
430            Some(first)
431        );
432        note(&main, second, "local").await;
433        let error = resolver.prefix("cafe1234", false).await.unwrap_err();
434        assert!(
435            matches!(error, RuntimeError::AmbiguousPrefix { matches, .. } if matches == vec![first, second])
436        );
437        assert!(resolver.prefix("deadbeef", false).await.unwrap().is_none());
438        assert!(resolver
439            .by_id(&token, Uuid::new_v4(), false)
440            .await
441            .unwrap()
442            .is_none());
443    }
444
445    #[tokio::test]
446    async fn issue2992_shared_reads_surface_backend_read_failures_instead_of_partial_results() {
447        let main = runtime("main");
448        let secondary = runtime("comm");
449        let resolver =
450            KgReadResolver::new(&main, &HashMap::from([("comm".into(), secondary.clone())]));
451        let id = Uuid::parse_str("bad01234-0000-4000-8000-000000000001").unwrap();
452        note(&main, id, "local").await;
453        let token = main.authorize(Namespace::local()).unwrap();
454        assert!(resolver.by_id(&token, id, false).await.unwrap().is_some());
455        assert_eq!(resolver.prefix("bad01234", false).await.unwrap(), Some(id));
456        // A backend read that cannot be admitted is a storage failure of the
457        // whole lookup, never a quiet miss: a pooled reader checkout refused by
458        // an exhausted request read deadline surfaces through both entry points.
459        // (Schema faults injected through the SQL writer are invisible to the
460        // pooled readers' snapshots, so admission is the fault a test can inject.)
461        let expired = std::time::Duration::ZERO;
462        assert!(matches!(
463            khive_storage::scope_request_read_deadline(expired, resolver.by_id(&token, id, false))
464                .await,
465            Err(RuntimeError::Storage(_))
466        ));
467        assert!(matches!(
468            khive_storage::scope_request_read_deadline(expired, resolver.prefix("bad01234", false))
469                .await,
470            Err(RuntimeError::Storage(_))
471        ));
472        assert!(matches!(
473            khive_storage::scope_request_read_deadline(expired, resolver.prefix("deadbeef", false))
474                .await,
475            Err(RuntimeError::Storage(_))
476        ));
477    }
478
479    #[tokio::test]
480    async fn issue2992_shared_reads_include_live_entities_and_explicit_deleted_notes() {
481        let main = runtime("main");
482        let secondary = runtime("archive");
483        let resolver = KgReadResolver::new(
484            &main,
485            &HashMap::from([("archive".into(), secondary.clone())]),
486        );
487        let token = main.authorize(Namespace::local()).unwrap();
488        let entity = Entity::new("elsewhere", "concept", "remote entity");
489        let entity_id = entity.id;
490        secondary
491            .entities(&token)
492            .unwrap()
493            .upsert_entity(entity)
494            .await
495            .unwrap();
496        assert!(
497            matches!(resolver.by_id(&token, entity_id, false).await.unwrap(), Some(Resolved::Entity(found)) if found.id == entity_id)
498        );
499        let mut deleted = Note::new("local", "observation", "deleted remote note");
500        deleted.deleted_at = Some(deleted.updated_at);
501        secondary
502            .notes(&token)
503            .unwrap()
504            .upsert_note(deleted.clone())
505            .await
506            .unwrap();
507        assert!(resolver
508            .by_id(&token, deleted.id, false)
509            .await
510            .unwrap()
511            .is_none());
512        assert!(
513            matches!(resolver.by_id(&token, deleted.id, true).await.unwrap(), Some(Resolved::Note(found)) if found == deleted)
514        );
515        let prefix = deleted.id.simple().to_string();
516        assert_eq!(
517            resolver.prefix(&prefix, true).await.unwrap(),
518            Some(deleted.id)
519        );
520    }
521}