Skip to main content

khive_runtime/operations/
edge_query.rs

1use std::collections::HashMap;
2
3use serde::Serialize;
4use uuid::Uuid;
5
6use khive_db::stores::graph::edge_hard_delete_statement;
7use khive_storage::graph::{SymmetricEdgeUpdateOutcome, SymmetricEdgeUpdateRequest};
8use khive_storage::types::{
9    DeleteMode, EdgeSortField, LinkId, PageRequest, SeekCursor, SortOrder, SqlRow, SqlStatement,
10    SqlValue,
11};
12use khive_storage::{Edge, EdgeRelation};
13use khive_types::{EventKind, SubstrateKind};
14
15use crate::error::{RuntimeError, RuntimeResult};
16use crate::runtime::{KhiveRuntime, NamespaceToken};
17
18use super::{canonical_edge_endpoints, static_impossible_edge_pattern_warnings, EdgeReadWindow};
19
20/// Result of a GQL/SPARQL query with optional validation warnings.
21#[derive(Clone, Debug, Serialize)]
22pub struct QueryResult {
23    pub rows: Vec<SqlRow>,
24    #[serde(skip_serializing_if = "Vec::is_empty")]
25    pub warnings: Vec<String>,
26    /// Zero-based offset of this deterministic result page.
27    pub offset: usize,
28    /// Effective payload bound after composing query `LIMIT` and server page size.
29    pub page_size: usize,
30    /// `true` when at least one additional match exists after this page.
31    pub has_more: bool,
32    /// GQL continuation offset. Present exactly when GQL has another page.
33    #[serde(skip_serializing_if = "Option::is_none")]
34    pub next_offset: Option<usize>,
35    /// Backward-compatible alias for `has_more` (#1168, #1247, #1601).
36    pub truncated: bool,
37}
38
39impl KhiveRuntime {
40    // ---- Query operations ----
41
42    /// Execute a GQL or SPARQL query string, returning raw SQL rows.
43    ///
44    /// The query is compiled to SQL with the namespace scope applied.
45    /// GQL syntax: `MATCH (a:concept)-[e:extends]->(b) RETURN a, b LIMIT 10`
46    /// SPARQL syntax: `SELECT ?a WHERE { ?a :kind "concept" . }`
47    pub async fn query(&self, token: &NamespaceToken, query: &str) -> RuntimeResult<Vec<SqlRow>> {
48        Ok(self
49            .query_with_metadata(token, query, khive_query::CompileOptions::default())
50            .await?
51            .rows)
52    }
53
54    /// Execute a GQL/SPARQL query, returning rows and any validation warnings.
55    pub async fn query_with_metadata(
56        &self,
57        token: &NamespaceToken,
58        query: &str,
59        mut opts: khive_query::CompileOptions,
60    ) -> RuntimeResult<QueryResult> {
61        use khive_query::QueryValue;
62        use khive_storage::types::SqlValue;
63
64        let (language, ast) = khive_query::language::parse_auto_with_language(query)?;
65        if opts.max_limit == 0 {
66            return Err(RuntimeError::InvalidInput(
67                "query page size must be at least 1".into(),
68            ));
69        }
70        let offset = ast.offset;
71        let page_size = ast.limit.unwrap_or(opts.max_limit).min(opts.max_limit);
72        opts.scopes = token
73            .visible_namespaces()
74            .iter()
75            .map(|ns| ns.as_str().to_string())
76            .collect();
77        let compiled = khive_query::compile(&ast, &opts)?;
78        let mut warnings = compiled.warnings;
79        let truncation_check = compiled.truncation_check;
80
81        warnings.extend(self.with_pack_edge_rules(|pack_rules| {
82            static_impossible_edge_pattern_warnings(language, &ast.pattern, pack_rules)
83        }));
84
85        // Convert QueryValue params (query-layer type) to SqlValue (storage-layer type)
86        // at the query–storage boundary.
87        let params: Vec<SqlValue> = compiled
88            .params
89            .into_iter()
90            .map(|qv| match qv {
91                QueryValue::Null => SqlValue::Null,
92                QueryValue::Integer(n) => SqlValue::Integer(n),
93                QueryValue::Float(f) => SqlValue::Float(f),
94                QueryValue::Text(s) => SqlValue::Text(s),
95                QueryValue::Blob(b) => SqlValue::Blob(b),
96            })
97            .collect();
98
99        let mut reader = self.sql().reader().await?;
100        let stmt = SqlStatement {
101            sql: compiled.sql,
102            params,
103            label: None,
104        };
105        let mut rows = reader.query_all(stmt).await?;
106
107        // When the effective page size was the binding constraint, the compiled
108        // SQL asked for one extra (sentinel) row. Its presence in the actual
109        // result set — not the requested LIMIT — is the continuation signal.
110        let mut truncated = false;
111        if let Some(check) = truncation_check {
112            if rows.len() > check.max_limit {
113                rows.truncate(check.max_limit);
114                truncated = true;
115            }
116        }
117
118        let next_offset = if truncated && language == khive_query::QueryLanguage::Gql {
119            let next = offset.checked_add(rows.len()).ok_or_else(|| {
120                RuntimeError::InvalidInput("GQL next_offset exceeds usize::MAX".into())
121            })?;
122            if next == offset {
123                return Err(RuntimeError::InvalidInput(
124                    "query page did not advance; page size must be at least 1".into(),
125                ));
126            }
127            i64::try_from(next).map_err(|_| {
128                RuntimeError::InvalidInput("GQL next_offset exceeds i64::MAX".into())
129            })?;
130            Some(next)
131        } else {
132            None
133        };
134
135        if truncated {
136            let Some(check) = truncation_check else {
137                return Err(RuntimeError::Internal(
138                    "truncated query result is missing sentinel metadata".into(),
139                ));
140            };
141            let bound = match check.requested_limit {
142                Some(requested) => {
143                    format!("requested query LIMIT {requested} exceeds the effective page size")
144                }
145                None => "the query has no explicit LIMIT".to_string(),
146            };
147            let warning = match language {
148                khive_query::QueryLanguage::Gql => {
149                    let Some(next) = next_offset else {
150                        return Err(RuntimeError::Internal(
151                            "truncated GQL result is missing its continuation offset".into(),
152                        ));
153                    };
154                    format!(
155                        "result page capped at {} rows because {bound}; more matches exist. \
156                         Continue the same GQL query with `SKIP {next}` (the machine-readable \
157                         `next_offset`) and keep the same page size.",
158                        check.max_limit
159                    )
160                }
161                khive_query::QueryLanguage::Sparql => format!(
162                    "result page capped at {} rows because {bound}; more matches exist. \
163                     SPARQL OFFSET paging is not part of the supported dialect.",
164                    check.max_limit
165                ),
166            };
167            warnings.push(warning);
168        }
169
170        Ok(QueryResult {
171            rows,
172            warnings,
173            offset,
174            page_size,
175            has_more: truncated,
176            next_offset,
177            truncated,
178        })
179    }
180
181    // ---- Edge CRUD operations ----
182
183    /// Fetch a single edge by id.
184    ///
185    /// UUID v4 is globally unique: returns the edge regardless of which
186    /// namespace the token carries. `Ok(None)` means the edge does not exist at all.
187    pub async fn get_edge(
188        &self,
189        _token: &NamespaceToken,
190        edge_id: Uuid,
191    ) -> RuntimeResult<Option<Edge>> {
192        let mut reader = self.sql().reader().await?;
193        let record_ns = reader
194            .query_scalar(SqlStatement {
195                sql: "SELECT namespace FROM graph_edges \
196                      WHERE id = ?1 AND deleted_at IS NULL LIMIT 1"
197                    .into(),
198                params: vec![SqlValue::Text(edge_id.to_string())],
199                label: Some("get_edge_namespace".into()),
200            })
201            .await?;
202
203        let Some(SqlValue::Text(record_ns)) = record_ns else {
204            return Ok(None);
205        };
206        // Route the storage fetch through the record's own namespace — the token is
207        // just the caller context; by-ID ops cross namespace boundaries.
208        let record_tok = NamespaceToken::for_namespace(
209            khive_types::Namespace::parse(&record_ns)
210                .map_err(|e| RuntimeError::Internal(format!("edge namespace invalid: {e}")))?,
211        );
212        Ok(self
213            .graph(&record_tok)?
214            .get_edge(LinkId::from(edge_id))
215            .await?)
216    }
217
218    /// Read live edges by ID in input order, without a visibility predicate.
219    ///
220    /// Stored namespaces are validated before the corresponding edge decode.
221    /// Each namespace group uses its own graph capability; missing rows remain
222    /// `None`. Group failures belong to their first input, and the earliest
223    /// input error wins after all groups in that bounded window are observed.
224    /// Metadata statement failures are fatal batch errors. Windows are separate
225    /// read observations, not a snapshot of the whole request.
226    pub async fn get_edges_by_id(
227        &self,
228        _token: &NamespaceToken,
229        ids: &[Uuid],
230    ) -> RuntimeResult<Vec<Option<Edge>>> {
231        let mut edges = Vec::with_capacity(ids.len());
232        for chunk in ids.chunks(900) {
233            let window = self.prepare_edge_read_window(chunk).await?;
234            edges.extend(
235                Self::hydrate_edge_read_window(chunk, window, |record_token| {
236                    self.graph(record_token)
237                })
238                .await?,
239            );
240        }
241        Ok(edges)
242    }
243
244    pub(super) async fn prepare_edge_read_window(
245        &self,
246        ids: &[Uuid],
247    ) -> RuntimeResult<EdgeReadWindow> {
248        let placeholders = (1..=ids.len())
249            .map(|index| format!("?{index}"))
250            .collect::<Vec<_>>()
251            .join(",");
252        let mut reader = self.sql().reader().await?;
253        let rows = reader
254            .query_all(SqlStatement {
255                sql: format!(
256                    "SELECT id, namespace FROM graph_edges WHERE id IN ({placeholders}) AND deleted_at IS NULL"
257                ),
258                params: ids.iter().map(|id| SqlValue::Text(id.to_string())).collect(),
259                label: Some("get_edge_namespace".into()),
260            })
261            .await?;
262        let mut namespaces = HashMap::with_capacity(rows.len());
263        for row in rows {
264            let Some(SqlValue::Text(id)) = row.columns.first().map(|column| &column.value) else {
265                return Err(RuntimeError::Internal(
266                    "edge namespace lookup returned an invalid id".into(),
267                ));
268            };
269            let id = Uuid::parse_str(id).map_err(|e| {
270                RuntimeError::Internal(format!("edge namespace lookup returned an invalid id: {e}"))
271            })?;
272            let value = row
273                .columns
274                .get(1)
275                .map(|column| column.value.clone())
276                .unwrap_or(SqlValue::Null);
277            namespaces.insert(id, value);
278        }
279        let mut window = EdgeReadWindow {
280            outcomes: (0..ids.len()).map(|_| Some(Ok(None))).collect(),
281            groups: Vec::new(),
282        };
283        let mut group_indices = HashMap::new();
284        for (index, id) in ids.iter().enumerate() {
285            let Some(SqlValue::Text(record_ns)) = namespaces.get(id) else {
286                continue;
287            };
288            match khive_types::Namespace::parse(record_ns) {
289                Ok(namespace) => {
290                    let next_group = window.groups.len();
291                    let group = *group_indices.entry(record_ns.clone()).or_insert(next_group);
292                    if group == next_group {
293                        window.groups.push((namespace, Vec::new()));
294                    }
295                    window.groups[group].1.push(index);
296                    window.outcomes[index] = None;
297                }
298                Err(error) => {
299                    window.outcomes[index] = Some(Err(RuntimeError::Internal(format!(
300                        "edge namespace invalid: {error}"
301                    ))));
302                }
303            }
304        }
305        Ok(window)
306    }
307
308    pub(super) async fn hydrate_edge_read_window<F>(
309        ids: &[Uuid],
310        mut window: EdgeReadWindow,
311        mut graph: F,
312    ) -> RuntimeResult<Vec<Option<Edge>>>
313    where
314        F: FnMut(&NamespaceToken) -> RuntimeResult<std::sync::Arc<dyn khive_storage::GraphStore>>,
315    {
316        for (namespace, indices) in window.groups {
317            let record_token = NamespaceToken::for_namespace(namespace);
318            let group_ids: Vec<LinkId> = indices
319                .iter()
320                .map(|&index| LinkId::from(ids[index]))
321                .collect();
322            let outcomes = match graph(&record_token) {
323                Ok(store) => store
324                    .get_edge_read_outcomes(&group_ids)
325                    .await
326                    .map_err(RuntimeError::from),
327                Err(error) => Err(error),
328            };
329            match outcomes {
330                Ok(outcomes) if outcomes.len() == indices.len() => {
331                    for (index, outcome) in indices.into_iter().zip(outcomes) {
332                        window.outcomes[index] = Some(outcome.map_err(RuntimeError::from));
333                    }
334                }
335                Ok(_) => {
336                    window.outcomes[indices[0]] = Some(Err(RuntimeError::Internal(
337                        "edge batch returned an invalid outcome count".into(),
338                    )));
339                }
340                Err(error) => {
341                    window.outcomes[indices[0]] = Some(Err(error));
342                }
343            }
344        }
345        window
346            .outcomes
347            .into_iter()
348            .map(|outcome| {
349                outcome.unwrap_or_else(|| {
350                    Err(RuntimeError::Internal(
351                        "edge batch omitted an input outcome".into(),
352                    ))
353                })
354            })
355            .collect()
356    }
357
358    /// Fetch a single edge by id.
359    ///
360    /// Delegates to `get_edge`: no visible-set check.  By-ID ops are
361    /// namespace-agnostic; UUID v4 is globally unique.
362    pub async fn get_edge_visible(
363        &self,
364        token: &NamespaceToken,
365        edge_id: Uuid,
366    ) -> RuntimeResult<Option<Edge>> {
367        self.get_edge(token, edge_id).await
368    }
369
370    /// Fetch an edge by UUID including soft-deleted rows.
371    ///
372    /// Returns the edge regardless of which namespace the token carries:
373    /// UUID v4 is globally unique. Used by the hard-delete path so that a
374    /// soft-deleted edge can still be purged via its edge ID.
375    pub async fn get_edge_including_deleted(
376        &self,
377        _token: &NamespaceToken,
378        edge_id: Uuid,
379    ) -> RuntimeResult<Option<Edge>> {
380        let mut reader = self.sql().reader().await?;
381        let record_ns = reader
382            .query_scalar(SqlStatement {
383                sql: "SELECT namespace FROM graph_edges WHERE id = ?1 LIMIT 1".into(),
384                params: vec![SqlValue::Text(edge_id.to_string())],
385                label: Some("get_edge_including_deleted_namespace".into()),
386            })
387            .await?;
388
389        let Some(SqlValue::Text(record_ns)) = record_ns else {
390            return Ok(None);
391        };
392        // Route through the record's own namespace store (no namespace equality check).
393        let record_tok = NamespaceToken::for_namespace(
394            khive_types::Namespace::parse(&record_ns)
395                .map_err(|e| RuntimeError::Internal(format!("edge namespace invalid: {e}")))?,
396        );
397        Ok(self
398            .graph(&record_tok)?
399            .get_edge_including_deleted(LinkId::from(edge_id))
400            .await?)
401    }
402
403    /// Fetch an edge by natural key (namespace, canonical source/target, relation)
404    /// including soft-deleted rows. Unlike [`Self::list_edges`]/[`Self::list_edges_after`],
405    /// which always filter `deleted_at IS NULL`, this can render a tombstoned symmetric-edge
406    /// survivor (ADR-039 DO NOTHING conflict absorption) — used by the atomic-apply
407    /// post-commit result renderer, which otherwise reports "not found" for a committed
408    /// update whose surviving row happens to be soft-deleted.
409    ///
410    /// `token` selects the store instance; `namespace` is the natural key's own
411    /// namespace and is bound into the query explicitly. These can legitimately differ:
412    /// the record namespace is fixed at prepare time (`EdgeNaturalKey::namespace`) and by-ID
413    /// edge updates are namespace-agnostic (ADR-007 Rev 6), so the caller's ambient `token`
414    /// namespace is never a safe substitute for the record's own — the prior implicit
415    /// `self.namespace` scoping is exactly the bug this parameter closes (khive#1213/#1214).
416    pub async fn get_edge_by_natural_key_including_deleted(
417        &self,
418        token: &NamespaceToken,
419        namespace: &str,
420        source_id: Uuid,
421        target_id: Uuid,
422        relation: EdgeRelation,
423    ) -> RuntimeResult<Option<Edge>> {
424        Ok(self
425            .graph(token)?
426            .get_edge_by_natural_key_including_deleted(namespace, source_id, target_id, relation)
427            .await?)
428    }
429
430    /// Maximum rows returned by a single [`Self::list_edges`] /
431    /// [`Self::list_edges_after`] page. A lower bound the docs promise callers
432    /// can rely on; kept as a named constant so tests can exercise pagination
433    /// (page tiling, out-of-range offsets) without needing >1000 real rows.
434    pub const EDGE_LIST_MAX_LIMIT: u32 = 1000;
435
436    /// List edges matching `filter`, paging by `offset`. `limit` is capped at
437    /// [`Self::EDGE_LIST_MAX_LIMIT`]; defaults to 100. For O(1)-at-depth walks
438    /// over large edge populations, prefer [`Self::list_edges_after`] instead
439    /// of paging offset deep.
440    pub async fn list_edges(
441        &self,
442        token: &NamespaceToken,
443        filter: crate::curation::EdgeListFilter,
444        limit: u32,
445        offset: u32,
446    ) -> RuntimeResult<Vec<Edge>> {
447        let limit = limit.min(Self::EDGE_LIST_MAX_LIMIT);
448        let visible = token.visible_namespaces();
449
450        // Common case: a single visible namespace — page directly against the
451        // store so `offset`/`limit` reach SQL unmodified.
452        if let [ns] = visible {
453            let temp = NamespaceToken::for_namespace(ns.clone());
454            let page = self
455                .graph(&temp)?
456                .query_edges(
457                    filter.into(),
458                    vec![SortOrder {
459                        field: EdgeSortField::CreatedAt,
460                        direction: khive_storage::types::SortDirection::Asc,
461                    }],
462                    PageRequest {
463                        offset: offset.into(),
464                        limit,
465                    },
466                )
467                .await?;
468            return Ok(page.items);
469        }
470
471        // Multi-namespace visibility: one deterministic query with
472        // `namespace IN (...)` and real SQL paging, mirroring
473        // `list_entities`. Fetching per-namespace prefixes and slicing a
474        // client-side merge re-sorted by UUID floats the offset window
475        // between calls — pages silently duplicate and skip rows, so
476        // enumeration never covers the set (#2088).
477        let ns_strs: Vec<String> = visible.iter().map(|ns| ns.as_str().to_owned()).collect();
478        let sort = vec![SortOrder {
479            field: EdgeSortField::CreatedAt,
480            direction: khive_storage::types::SortDirection::Asc,
481        }];
482        let graph = self.graph(token)?;
483        match graph
484            .query_edges_in_namespaces(
485                &ns_strs,
486                filter.clone().into(),
487                sort.clone(),
488                PageRequest {
489                    offset: offset.into(),
490                    limit,
491                },
492            )
493            .await
494        {
495            Ok(page) => Ok(page.items),
496            Err(khive_storage::StorageError::Unsupported { operation, .. })
497                if operation == "query_edges_in_namespaces" =>
498            {
499                // Backend exercises the trait default (no batched
500                // namespace query support): fall back to one `query_edges`
501                // call per namespace. Unlike the pre-image fix for #2088,
502                // this fetches an `offset + limit` prefix from every
503                // namespace and merges by the *same* `(created_at, id)` key
504                // each per-namespace fetch already orders by — the
505                // pre-image bug sorted the merged set by UUID alone, a key
506                // unrelated to the order each per-namespace prefix was cut
507                // at, which floated the offset window and silently
508                // duplicated/skipped rows across pages. Sorting by the
509                // fetch's own order key keeps the top `offset + limit` of
510                // the merge exactly equal to the true global prefix.
511                let fetch_limit = offset.saturating_add(limit);
512                let mut namespace_prefixes = Vec::new();
513                for ns in visible {
514                    let temp = NamespaceToken::for_namespace(ns.clone());
515                    let page = self
516                        .graph(&temp)?
517                        .query_edges(
518                            filter.clone().into(),
519                            sort.clone(),
520                            PageRequest {
521                                offset: 0,
522                                limit: fetch_limit,
523                            },
524                        )
525                        .await?;
526                    namespace_prefixes.push(page.items);
527                }
528                Ok(Self::merge_paged_namespace_edges(
529                    namespace_prefixes,
530                    offset,
531                    limit,
532                ))
533            }
534            Err(error) => Err(error.into()),
535        }
536    }
537
538    /// Merge per-namespace `(created_at, id)`-ordered edge prefixes (each
539    /// already fetched up to `offset + limit` from its own namespace, as
540    /// [`Self::list_edges`]'s trait-default fallback does) into one global
541    /// `[offset, offset + limit)` page.
542    ///
543    /// Sorting by the *same key each prefix was already cut at* is what
544    /// keeps this exact: the top `offset + limit` of the merged set is then
545    /// provably equal to the true global prefix (a standard k-way merge
546    /// argument — no element beyond position `offset + limit` in any single
547    /// namespace can appear before that position in the global order). The
548    /// pre-image #2088 bug instead re-sorted the merged set by UUID alone —
549    /// a key unrelated to the order each namespace's prefix was fetched in —
550    /// which floated the offset window and silently duplicated/skipped rows
551    /// across pages.
552    pub(super) fn merge_paged_namespace_edges(
553        namespace_prefixes: Vec<Vec<Edge>>,
554        offset: u32,
555        limit: u32,
556    ) -> Vec<Edge> {
557        let mut results: Vec<Edge> = namespace_prefixes.into_iter().flatten().collect();
558        results.sort_by_key(|e| (e.created_at, Uuid::from(e.id)));
559        let start = (offset as usize).min(results.len());
560        let end = (start + limit as usize).min(results.len());
561        results[start..end].to_vec()
562    }
563
564    /// Keyset (seek) page of edges matching `filter`, ordered by immutable
565    /// database-assigned insertion sequence. `after` is the last edge id from the
566    /// previous page (exclusive); omit to start from the beginning. Returns
567    /// `(items, next_after)` — `next_after` is `Some` when more rows remain
568    /// past this page.
569    ///
570    /// Unlike [`Self::list_edges`], this is O(log n + limit) at any depth and
571    /// genuinely new inserts are appended after already-issued boundaries. The
572    /// cursor row is resolved including tombstones; a hard-deleted or
573    /// out-of-scope cursor fails explicitly rather than hiding an incomplete
574    /// traversal.
575    pub async fn list_edges_after(
576        &self,
577        token: &NamespaceToken,
578        filter: crate::curation::EdgeListFilter,
579        after: Option<Uuid>,
580        limit: u32,
581    ) -> RuntimeResult<(Vec<Edge>, Option<Uuid>)> {
582        let limit = limit.clamp(1, Self::EDGE_LIST_MAX_LIMIT);
583        let visible = token.visible_namespaces();
584        let limit_usize = limit as usize;
585        let cursor_store = self.graph(token)?;
586        let after = match after {
587            Some(id) => {
588                let edge = self
589                    .get_edge_including_deleted(token, id)
590                    .await?
591                    .ok_or_else(|| RuntimeError::NotFound(format!("edge cursor {id}")))?;
592                Self::ensure_namespace_visible(&edge.namespace, token)?;
593                let sequence = cursor_store.edge_sequence(id).await?.ok_or_else(|| {
594                    RuntimeError::Internal(format!(
595                        "edge cursor {id} has no insertion-sequence ledger row"
596                    ))
597                })?;
598                Some(SeekCursor { sequence, id })
599            }
600            None => None,
601        };
602
603        if let [ns] = visible {
604            let temp = NamespaceToken::for_namespace(ns.clone());
605            let page = self
606                .graph(&temp)?
607                .query_edges_sequence_after(filter.into(), after, limit)
608                .await?;
609            return Ok((page.items, page.next_after.map(|cursor| cursor.id)));
610        }
611
612        // Multi-namespace visibility: seek each namespace from the same
613        // immutable boundary, merge in global insertion order, then take the head of
614        // the merged set as this page.
615        let probe_limit = limit.saturating_add(1);
616        let mut results = Vec::new();
617        for ns in visible {
618            let temp = NamespaceToken::for_namespace(ns.clone());
619            let page = self
620                .graph(&temp)?
621                .query_edges_sequence_after(filter.clone().into(), after, probe_limit)
622                .await?;
623            results.extend(page.items);
624        }
625        let ids = results
626            .iter()
627            .map(|edge| Uuid::from(edge.id))
628            .collect::<Vec<_>>();
629        let sequences = cursor_store
630            .edge_sequences(&ids)
631            .await?
632            .into_iter()
633            .collect::<HashMap<_, _>>();
634        if let Some(missing) = ids.iter().find(|id| !sequences.contains_key(id)) {
635            return Err(RuntimeError::Internal(format!(
636                "edge {missing} has no insertion-sequence ledger row"
637            )));
638        }
639        results.sort_by_key(|edge| {
640            let id = Uuid::from(edge.id);
641            (sequences[&id], id)
642        });
643        results.dedup_by_key(|e| Uuid::from(e.id));
644        let has_more = results.len() > limit_usize;
645        if has_more {
646            results.truncate(limit_usize);
647        }
648        let next_after = if has_more {
649            results.last().map(|e| Uuid::from(e.id))
650        } else {
651            None
652        };
653        Ok((results, next_after))
654    }
655
656    /// Count edges by relation, ignoring soft-deleted rows. Used by
657    /// `stats()` to report the true per-relation population so full-graph
658    /// audits know what they're sampling from before they walk it.
659    pub async fn count_edges_by_relation(
660        &self,
661        token: &NamespaceToken,
662    ) -> RuntimeResult<std::collections::HashMap<String, u64>> {
663        let namespaces: Vec<String> = token
664            .visible_namespaces()
665            .iter()
666            .map(|namespace| namespace.as_str().to_owned())
667            .collect();
668        let graph = self.graph(token)?;
669        let counts = match graph
670            .count_edges_by_relation_in_namespaces(&namespaces)
671            .await
672        {
673            Ok(counts) => counts,
674            Err(khive_storage::StorageError::Unsupported { operation, .. })
675                if operation == "count_edges_by_relation_in_namespaces" =>
676            {
677                let mut totals = HashMap::new();
678                for namespace in token.visible_namespaces() {
679                    let scoped = NamespaceToken::for_namespace(namespace.clone());
680                    for (relation, count) in self.graph(&scoped)?.count_edges_by_relation().await? {
681                        *totals.entry(relation).or_insert(0) += count;
682                    }
683                }
684                return Ok(totals
685                    .into_iter()
686                    .map(|(relation, count)| (relation.to_string(), count))
687                    .collect());
688            }
689            Err(error) => return Err(error.into()),
690        };
691        Ok(counts
692            .into_iter()
693            .map(|(relation, count)| (relation.to_string(), count))
694            .collect())
695    }
696
697    /// Count edges by the base each endpoint resolves against. Used by
698    /// `stats()` so a caller can name the denominator of a density figure
699    /// instead of inheriting the flat edge total, which on a real store is
700    /// mostly provenance.
701    ///
702    /// The per-namespace fallback sums the same buckets, so the aggregate and
703    /// the fallback are checkable against each other and against
704    /// `count_edges` by the invariant that the buckets sum to the total.
705    pub async fn count_edges_by_endpoint_base(
706        &self,
707        token: &NamespaceToken,
708    ) -> RuntimeResult<khive_storage::types::EdgeEndpointBaseCounts> {
709        use khive_storage::types::EdgeEndpointBaseCounts;
710
711        let namespaces: Vec<String> = token
712            .visible_namespaces()
713            .iter()
714            .map(|namespace| namespace.as_str().to_owned())
715            .collect();
716        let graph = self.graph(token)?;
717        match graph
718            .count_edges_by_endpoint_base_in_namespaces(&namespaces)
719            .await
720        {
721            Ok(counts) => Ok(counts),
722            Err(khive_storage::StorageError::Unsupported { operation, .. })
723                if operation == "count_edges_by_endpoint_base_in_namespaces"
724                    || operation == "count_edges_by_endpoint_base" =>
725            {
726                let mut totals = EdgeEndpointBaseCounts::default();
727                for namespace in token.visible_namespaces() {
728                    let scoped = NamespaceToken::for_namespace(namespace.clone());
729                    let counts = self.graph(&scoped)?.count_edges_by_endpoint_base().await?;
730                    totals.entity_entity =
731                        totals.entity_entity.saturating_add(counts.entity_entity);
732                    totals.entity_note = totals.entity_note.saturating_add(counts.entity_note);
733                    totals.note_entity = totals.note_entity.saturating_add(counts.note_entity);
734                    totals.note_note = totals.note_note.saturating_add(counts.note_note);
735                    totals.unresolved = totals.unresolved.saturating_add(counts.unresolved);
736                }
737                Ok(totals)
738            }
739            Err(error) => Err(error.into()),
740        }
741    }
742
743    /// Return the live edge total, its relation counts and its endpoint-base
744    /// counts for `stats`.
745    ///
746    /// The three describe one population: live edges with live endpoints in
747    /// the caller's visible namespaces. A backend with the combined aggregate
748    /// answers all of them from one grouped read, and the total is the sum of
749    /// its groups, so the invariants `stats` reports on (relation counts and
750    /// endpoint-base buckets each summing to the total) hold by construction.
751    /// A backend without it gets the three single-purpose counts.
752    pub async fn edge_stats_counts(
753        &self,
754        token: &NamespaceToken,
755    ) -> RuntimeResult<super::EdgeStatsCounts> {
756        let namespaces: Vec<String> = token
757            .visible_namespaces()
758            .iter()
759            .map(|namespace| namespace.as_str().to_owned())
760            .collect();
761        let graph = self.graph(token)?;
762        match graph
763            .count_edges_by_relation_and_endpoint_base_in_namespaces(&namespaces)
764            .await
765        {
766            Ok(groups) => {
767                let mut counts = super::EdgeStatsCounts::default();
768                for (relation, by_base) in groups {
769                    let relation_total = by_base.total();
770                    counts.edges = counts.edges.saturating_add(relation_total);
771                    let slot = counts
772                        .edges_by_relation
773                        .entry(relation.to_string())
774                        .or_insert(0);
775                    *slot = slot.saturating_add(relation_total);
776                    let totals = &mut counts.edges_by_endpoint_base;
777                    totals.entity_entity =
778                        totals.entity_entity.saturating_add(by_base.entity_entity);
779                    totals.entity_note = totals.entity_note.saturating_add(by_base.entity_note);
780                    totals.note_entity = totals.note_entity.saturating_add(by_base.note_entity);
781                    totals.note_note = totals.note_note.saturating_add(by_base.note_note);
782                    totals.unresolved = totals.unresolved.saturating_add(by_base.unresolved);
783                }
784                Ok(counts)
785            }
786            Err(khive_storage::StorageError::Unsupported { operation, .. })
787                if operation == "count_edges_by_relation_and_endpoint_base_in_namespaces" =>
788            {
789                Ok(super::EdgeStatsCounts {
790                    edges: self
791                        .count_edges(token, crate::curation::EdgeListFilter::default())
792                        .await?,
793                    edges_by_relation: self.count_edges_by_relation(token).await?,
794                    edges_by_endpoint_base: self.count_edges_by_endpoint_base(token).await?,
795                })
796            }
797            Err(error) => Err(error.into()),
798        }
799    }
800
801    /// Patch-style edge update. Only `Some(_)` fields are applied.
802    ///
803    /// When `relation` is `Some(new_rel)`, validates that the edge's existing endpoints
804    /// are legal for `new_rel` before persisting. Weight-only updates (`relation = None`)
805    /// skip validation. Returns `InvalidInput` if the new relation would violate the
806    /// three-case endpoint contract; the edge is NOT mutated on error.
807    ///
808    /// For symmetric relations (`competes_with`, `composed_with`), endpoint order is
809    /// canonicalised to `source_uuid < target_uuid` after validation. If a canonical
810    /// row already exists at the target triple, the non-canonical edge is deleted and
811    /// the existing canonical row is preserved unchanged (ADR-039 ON CONFLICT DO
812    /// NOTHING, mirroring `merge_entity_sql`) — its attributes, including a soft-deleted
813    /// `deleted_at`, are never overwritten by the discarded edge's patch.
814    pub async fn update_edge(
815        &self,
816        token: &NamespaceToken,
817        edge_id: Uuid,
818        patch: crate::curation::EdgePatch,
819    ) -> RuntimeResult<Edge> {
820        // Fetch the edge by UUID: ID-only, no namespace check.
821        // get_edge already uses the record's stored namespace internally.
822        let graph_for_fetch = self.graph(token)?;
823        let mut edge = graph_for_fetch
824            .get_edge(LinkId::from(edge_id))
825            .await?
826            .ok_or_else(|| crate::RuntimeError::NotFound(format!("edge {edge_id}")))?;
827        let expected_updated_at = edge.updated_at;
828        let expected_deleted_at = edge.deleted_at;
829        #[cfg(test)]
830        crate::curation::race_seam::pause_after_read().await;
831
832        // After fetching, all mutations and validation must use the
833        // RECORD's namespace, not the caller's.  Derive record_tok from the stored edge
834        // namespace so that endpoint validation, raw-SQL predicates, and graph routing
835        // all address the correct backend partition.
836        let record_ns: String = edge.namespace.clone();
837        let record_tok = token.with_namespace(
838            khive_types::Namespace::parse(&record_ns)
839                .map_err(|e| RuntimeError::Internal(format!("edge namespace invalid: {e}")))?,
840        );
841        let graph = self.graph(&record_tok)?;
842
843        let mut changed_fields: Vec<&'static str> = Vec::new();
844        if let Some(r) = patch.relation {
845            // Validate before mutating — use the existing endpoints with the new relation.
846            // Use record_tok so that endpoint existence checks look in the edge's own namespace.
847            self.validate_edge_relation_endpoints(&record_tok, edge.source_id, edge.target_id, r)
848                .await?;
849            edge.relation = r;
850            changed_fields.push("relation");
851        }
852        if let Some(w) = patch.weight {
853            // Reject non-finite or out-of-range weight explicitly; do not silently
854            // clamp invalid caller input (coding-standards §608-622).
855            if !khive_types::validate_edge_weight(w) {
856                return Err(RuntimeError::InvalidInput(format!(
857                    "edge weight must be a finite value in [0.0, 1.0]; got {w}"
858                )));
859            }
860            edge.weight = w;
861            changed_fields.push("weight");
862        }
863        if let Some(props) = patch.properties {
864            crate::secret_gate::reject_reserved_secret_gate_property(Some(&props))?;
865            edge.metadata = Some(props);
866        }
867
868        // For symmetric relations, canonicalise endpoint order and check
869        // for natural-key conflicts regardless of whether endpoints were flipped.
870        //
871        // The guarded graph operation is used for ALL symmetric relations because `upsert_edge`
872        // resolves ON CONFLICT(namespace,id) first and cannot detect a duplicate at
873        // the natural key (namespace, source_id, target_id, relation) with a different
874        // id. Bug-fix: this path must also run when endpoints are already canonical
875        // (endpoints_flipped=false) to catch conflicts arising from a relation change
876        // that collides with an existing canonical row.
877        let (canon_src, canon_tgt) =
878            canonical_edge_endpoints(edge.relation, edge.source_id, edge.target_id);
879
880        if edge.relation.is_symmetric() {
881            let outcome = graph
882                .update_symmetric_edge_if_unchanged(SymmetricEdgeUpdateRequest {
883                    namespace: record_ns.clone(),
884                    id: edge_id.into(),
885                    source_id: canon_src,
886                    target_id: canon_tgt,
887                    relation: edge.relation,
888                    weight: edge.weight,
889                    metadata: edge.metadata.clone(),
890                    expected_updated_at_micros: expected_updated_at.timestamp_micros(),
891                    expected_deleted_at_micros: expected_deleted_at.map(|v| v.timestamp_micros()),
892                })
893                .await?;
894
895            match outcome {
896                SymmetricEdgeUpdateOutcome::Absorbed(sid) => {
897                    // A conflict was absorbed (ADR-039 DO NOTHING): re-fetch the surviving
898                    // canonical row so the caller receives its real, UNMODIFIED attributes —
899                    // including soft-deleted rows, since the survivor's tombstone state (if
900                    // any) must not be resurrected by the absorbed update either. Use
901                    // record_tok — the surviving row lives in the same namespace as the
902                    // original.
903                    let surviving_uuid = Uuid::parse_str(&sid).map_err(|e| {
904                        RuntimeError::Internal(format!(
905                            "update_edge: surviving id parse failed: {e}"
906                        ))
907                    })?;
908                    edge = self
909                        .get_edge_including_deleted(&record_tok, surviving_uuid)
910                        .await?
911                        .ok_or_else(|| {
912                            RuntimeError::Internal(format!(
913                                "update_edge: surviving canonical row {surviving_uuid} vanished after update"
914                            ))
915                        })?;
916                }
917                SymmetricEdgeUpdateOutcome::Updated => {
918                    // Reflect canonical endpoints in the returned edge (no conflict absorbed).
919                    edge.source_id = canon_src;
920                    edge.target_id = canon_tgt;
921                }
922                SymmetricEdgeUpdateOutcome::Stale => {
923                    return Err(crate::curation::stale_edge_snapshot_error(edge_id));
924                }
925            }
926        } else {
927            // Non-symmetric: replace_edge_if_unchanged takes namespace from edge.namespace
928            // (not from the graph store's routing namespace), so this is already
929            // record-namespace correct. `graph` is already self.graph(&record_tok)?.
930            // Guarded on the fetched snapshot's revision — a concurrent writer that moved
931            // this edge between the fetch above and this write must be refused, not
932            // silently overwritten by a full-row replacement derived from stale state.
933            // `updated_at` must advance past the snapshot: the guard requires the
934            // replacement revision to be strictly greater than the persisted one.
935            // Make it strictly advance even when two operations land inside one
936            // clock microsecond; saturating to i64::MAX would let the CAS accept
937            // a write without advancing its revision, so that is not a valid
938            // fallback (mirrors the note path).
939            let minimum_updated_at_micros = expected_updated_at
940                .timestamp_micros()
941                .checked_add(1)
942                .ok_or_else(|| {
943                RuntimeError::Internal(format!(
944                    "edge {edge_id} updated_at is already at i64::MAX and cannot advance"
945                ))
946            })?;
947            let now_micros = chrono::Utc::now()
948                .timestamp_micros()
949                .max(minimum_updated_at_micros);
950            edge.updated_at =
951                chrono::DateTime::from_timestamp_micros(now_micros).ok_or_else(|| {
952                    RuntimeError::Internal(format!(
953                        "edge {edge_id}: computed updated_at {now_micros} is not a valid timestamp"
954                    ))
955                })?;
956            let persisted = graph
957                .replace_edge_if_unchanged(edge.clone(), expected_updated_at, expected_deleted_at)
958                .await?;
959            if !persisted {
960                return Err(crate::curation::stale_edge_snapshot_error(edge_id));
961            }
962        }
963
964        // Audit event: use the record's namespace (record_ns) for the event payload.
965        let event_store = self.events(&record_tok)?;
966        let event = khive_storage::event::Event::new(
967            record_ns.clone(),
968            "update",
969            EventKind::EdgeUpdated,
970            SubstrateKind::Entity,
971            "",
972        )
973        .with_target(edge_id)
974        .with_payload(
975            serde_json::json!({"id": edge_id, "namespace": record_ns, "changed_fields": changed_fields}),
976        );
977        event_store.append_event(event).await.map_err(|e| {
978            RuntimeError::Internal(format!("update_edge: event store write failed: {e}"))
979        })?;
980
981        Ok(edge)
982    }
983
984    /// Hard-delete an edge by id.
985    ///
986    /// Cascades to remove any `annotates` edges whose target is the deleted edge
987    /// (`annotates` is note → anything; deleting an edge target leaves annotation
988    /// edges dangling if not cleaned up). Returns `true` if the primary
989    /// edge was removed.
990    ///
991    /// If `edge_id` does not refer to an edge (e.g. the caller passes an entity or
992    /// note UUID by mistake), this method returns `Ok(false)` immediately with no
993    /// side effects — it does **not** cascade inbound edges of the non-edge record.
994    pub async fn delete_edge(
995        &self,
996        token: &NamespaceToken,
997        edge_id: Uuid,
998        hard: bool,
999    ) -> RuntimeResult<bool> {
1000        let mode = if hard {
1001            DeleteMode::Hard
1002        } else {
1003            DeleteMode::Soft
1004        };
1005
1006        // Fetch the edge first to obtain the record's own namespace.
1007        // By-ID ops cross namespace boundaries; all graph routing and audit
1008        // events must use the record namespace, not the caller's (mirrors update_edge).
1009        // For hard delete we also check soft-deleted rows so a soft-deleted edge
1010        // can still be purged via its edge ID.
1011        let edge = if hard {
1012            self.get_edge_including_deleted(token, edge_id).await?
1013        } else {
1014            self.get_edge(token, edge_id).await?
1015        };
1016        let Some(edge) = edge else {
1017            return Ok(false);
1018        };
1019
1020        // Derive record_ns / record_tok from the fetched edge (mirrors update_edge).
1021        let record_ns: String = edge.namespace.clone();
1022        let record_tok = token.with_namespace(
1023            khive_types::Namespace::parse(&record_ns)
1024                .map_err(|e| RuntimeError::Internal(format!("edge namespace invalid: {e}")))?,
1025        );
1026        let graph = self.graph(&record_tok)?;
1027        let actor = format!("{}:{}", token.actor().kind, token.actor().id);
1028
1029        // Cascade: on hard delete, remove ALL annotates edges targeting this edge — including
1030        // already-soft-deleted ones: to prevent dangling graph_edges rows. The row
1031        // delete and the cascade purge run as ONE write transaction: see
1032        // `atomic_hard_delete_with_edge_purge`.
1033        // On soft delete the cascade is skipped (data-vs-view principle: soft-deleting the base
1034        // edge does not cascade to annotation edges; only a hard purge cleans up incident rows).
1035        let deleted = if hard {
1036            self.atomic_hard_delete_with_edge_purge(
1037                edge_hard_delete_statement(edge_id),
1038                edge_id,
1039                &record_ns,
1040                &actor,
1041                SubstrateKind::Entity,
1042            )
1043            .await?
1044        } else {
1045            graph.delete_edge(LinkId::from(edge_id), mode).await?
1046        };
1047        if deleted {
1048            // Audit event: use the record's namespace (record_ns), not the caller's namespace.
1049            let event_store = self.events(&record_tok)?;
1050            let event = khive_storage::event::Event::new(
1051                record_ns.clone(),
1052                "delete",
1053                EventKind::EdgeDeleted,
1054                SubstrateKind::Entity,
1055                "",
1056            )
1057            .with_target(edge_id)
1058            .with_payload(serde_json::json!({"id": edge_id, "namespace": record_ns, "hard": hard}));
1059            event_store.append_event(event).await.map_err(|e| {
1060                RuntimeError::Internal(format!("delete_edge: event store write failed: {e}"))
1061            })?;
1062        }
1063        Ok(deleted)
1064    }
1065
1066    /// Count edges matching `filter` across the caller's visible namespaces.
1067    pub async fn count_edges(
1068        &self,
1069        token: &NamespaceToken,
1070        filter: crate::curation::EdgeListFilter,
1071    ) -> RuntimeResult<u64> {
1072        let namespaces: Vec<String> = token
1073            .visible_namespaces()
1074            .iter()
1075            .map(|namespace| namespace.as_str().to_owned())
1076            .collect();
1077        let graph = self.graph(token)?;
1078        match graph
1079            .count_edges_in_namespaces(&namespaces, filter.clone().into())
1080            .await
1081        {
1082            Ok(count) => Ok(count),
1083            Err(khive_storage::StorageError::Unsupported { operation, .. })
1084                if operation == "count_edges_in_namespaces" =>
1085            {
1086                let mut total = 0;
1087                for namespace in token.visible_namespaces() {
1088                    let scoped = NamespaceToken::for_namespace(namespace.clone());
1089                    total += self
1090                        .graph(&scoped)?
1091                        .count_edges(filter.clone().into())
1092                        .await?;
1093                }
1094                Ok(total)
1095            }
1096            Err(error) => Err(error.into()),
1097        }
1098    }
1099}