Skip to main content

khive_storage/
graph.rs

1//! Graph storage capability — edge CRUD and traversal.
2
3use async_trait::async_trait;
4use chrono::{DateTime, Utc};
5use khive_types::EdgeRelation;
6use uuid::Uuid;
7
8use crate::capability::StorageCapability;
9use crate::error::StorageError;
10use crate::types::{
11    BatchWriteSummary, DeleteMode, DirectedNeighborHit, Direction, Edge, EdgeEndpointBaseCounts,
12    EdgeFilter, EdgeSeekPage, EdgeSortField, EdgeUpsertRequest, EdgeUpsertResult, GraphPath,
13    GuardedBatchOutcome, GuardedEdgeBatchUpsertOutcome, GuardedEdgeUpsertOutcome,
14    GuardedWriteOutcome, LinkId, NeighborCursor, NeighborHit, NeighborQuery, Page, PageRequest,
15    SeekCursor, SeekPage, SortOrder, StorageResult, TraversalRequest,
16};
17
18/// The exact persisted cursor row observed by the reconciliation preview.
19#[derive(Clone, Debug)]
20pub struct CommitAnnotationCursorValue {
21    pub value: Vec<u8>,
22    pub updated_at: i64,
23}
24
25/// Preconditions carried from the selected repository and preview into the
26/// graph store's writer transaction.
27#[derive(Clone, Debug)]
28pub struct CommitAnnotationGuard {
29    pub expected_sha: String,
30    pub source_identity: String,
31    pub commits: CommitAnnotationCursorValue,
32    pub checkpoint: CommitAnnotationCursorValue,
33}
34
35/// Result of a create-only historical commit-to-project annotation attempt.
36/// The store decides this under its writer transaction; a caller-side preview
37/// is never authority to replace or resurrect an edge.
38#[derive(Clone, Debug)]
39pub enum CommitAnnotationInsertOutcome {
40    Created(Edge),
41    ExistingLive,
42    Tombstoned,
43    SourceChanged,
44    TargetChanged,
45    CursorChanged,
46}
47
48/// Directed edge CRUD and graph traversal over the knowledge graph.
49#[async_trait]
50pub trait GraphStore: Send + Sync + 'static {
51    /// Return the newest live note of `kind` carrying the exact string `tag`
52    /// in its properties.tags array and connected to `node_id` by a live
53    /// incoming `annotates` edge in this store's namespace. Returns its UUID
54    /// and creation timestamp; equal timestamps choose the smallest UUID.
55    ///
56    /// Apply all predicates before limiting to one result. Note lookup follows
57    /// the by-ID contract; visibility is determined by the annotation edge's
58    /// namespace, not by introducing a second namespace filter on the note.
59    /// The note and edge must belong to this backend. Unsupported backends
60    /// fail explicitly rather than scanning an arbitrary annotation window.
61    async fn latest_annotating_note(
62        &self,
63        _node_id: Uuid,
64        _kind: &str,
65        _tag: &str,
66    ) -> StorageResult<Option<(Uuid, i64)>> {
67        Err(StorageError::Unsupported {
68            capability: StorageCapability::Graph,
69            operation: "latest_annotating_note".into(),
70            message: "this backend does not implement latest matching annotation lookup".into(),
71        })
72    }
73
74    /// Like `latest_annotating_note`, but also require one exact top-level
75    /// string property before selecting the newest candidate.
76    async fn latest_annotating_note_with_property(
77        &self,
78        _node_id: Uuid,
79        _kind: &str,
80        _tag: &str,
81        _property_key: &str,
82        _property_value: &str,
83    ) -> StorageResult<Option<(Uuid, i64)>> {
84        Err(StorageError::Unsupported {
85            capability: StorageCapability::Graph,
86            operation: "latest_annotating_note_with_property".into(),
87            message: "this backend does not implement latest matching annotation lookup".into(),
88        })
89    }
90
91    /// Insert or update a single edge.
92    async fn upsert_edge(&self, edge: Edge) -> StorageResult<()>;
93    /// Insert an edge only when neither its id nor natural key already
94    /// exists. Returns `true` when this call inserted the row and `false`
95    /// when an existing row won the race. The existing row is never updated.
96    ///
97    /// The default returns `Unsupported` rather than falling back to
98    /// [`GraphStore::upsert_edge`], because an upsert would overwrite the
99    /// winning row and violate this method's conditional-insert contract.
100    async fn insert_edge_if_absent(&self, _edge: Edge) -> StorageResult<bool> {
101        Err(StorageError::Unsupported {
102            capability: StorageCapability::Graph,
103            operation: "insert_edge_if_absent".into(),
104            message: "this backend does not implement conditional edge insert".into(),
105        })
106    }
107    /// Create an `annotates` edge only while its source is a live `commit`
108    /// note in `edge.namespace` with the exact SHA, its target is the live
109    /// project for the selected source, the paired commit cursor rows still
110    /// match the preview, and no row of the edge's natural key exists,
111    /// including a tombstone. All checks and the insert occur in one writer
112    /// transaction. Existing rows are immutable.
113    async fn insert_commit_annotation_if_absent(
114        &self,
115        _edge: Edge,
116        _guard: CommitAnnotationGuard,
117    ) -> StorageResult<CommitAnnotationInsertOutcome> {
118        Err(StorageError::Unsupported {
119            capability: StorageCapability::Graph,
120            operation: "insert_commit_annotation_if_absent".into(),
121            message: "this backend does not implement guarded commit annotation insert".into(),
122        })
123    }
124    /// Insert or update a batch of edges.
125    async fn upsert_edges(&self, edges: Vec<Edge>) -> StorageResult<BatchWriteSummary>;
126    /// Insert or replace one edge and return the transaction-observed
127    /// disposition plus preimage. Tombstone restoration is controlled by the
128    /// request rather than being an implicit side effect of every upsert.
129    async fn upsert_edge_observed(
130        &self,
131        _request: EdgeUpsertRequest,
132    ) -> StorageResult<EdgeUpsertResult> {
133        Err(StorageError::Unsupported {
134            capability: StorageCapability::Graph,
135            operation: "upsert_edge_observed".into(),
136            message: "this backend does not implement observed edge upserts".into(),
137        })
138    }
139    /// Replace an edge only when the persisted row still matches the
140    /// caller's read snapshot.
141    ///
142    /// `expected_updated_at` is the snapshot revision and
143    /// `expected_deleted_at` closes the soft-delete race. The replacement
144    /// edge's `updated_at` must be strictly greater than that persisted
145    /// revision. Returns `false` when the row disappeared, changed, or was
146    /// supplied a non-advancing replacement revision. This is the full-edge
147    /// compare-and-swap seam used when a caller derives coupled fields from
148    /// that snapshot before persistence — mirrors
149    /// [`crate::NoteStore::replace_note_if_unchanged`]. The default returns
150    /// `Unsupported` rather than falling back to an unguarded upsert and
151    /// reintroducing the stale-snapshot race.
152    async fn replace_edge_if_unchanged(
153        &self,
154        _edge: Edge,
155        _expected_updated_at: DateTime<Utc>,
156        _expected_deleted_at: Option<DateTime<Utc>>,
157    ) -> StorageResult<bool> {
158        Err(StorageError::Unsupported {
159            capability: StorageCapability::Graph,
160            operation: "replace_edge_if_unchanged".into(),
161            message: "this backend does not implement guarded edge replacement".into(),
162        })
163    }
164    /// Insert or update a single edge, re-checking that both endpoints still
165    /// exist (and are not soft-deleted) as part of the same write, not a
166    /// separate prior read. Closes the TOCTOU window between an async
167    /// prepare-time existence check and a later, unconditional write: a
168    /// concurrent hard-delete of an endpoint that lands between the two can
169    /// otherwise leave a durably dangling edge (#769).
170    ///
171    /// Returns [`GuardedWriteOutcome::Refused`] naming exactly which
172    /// endpoint(s) were missing, determined by the guard's own in-transaction
173    /// probe — never reconstructed by a caller re-reading the endpoints after
174    /// the write already failed, since a concurrent write landing between the
175    /// refusal and any such later read could misreport which endpoint was
176    /// actually missing at write time.
177    ///
178    /// Default returns `StorageError::Unsupported`: a backend that does not
179    /// override this method cannot honor the endpoint-existence guarantee,
180    /// and silently falling back to [`GraphStore::upsert_edge`] would
181    /// reintroduce the TOCTOU window this method exists to close.
182    async fn upsert_edge_guarded(&self, _edge: Edge) -> StorageResult<GuardedWriteOutcome> {
183        Err(StorageError::Unsupported {
184            capability: StorageCapability::Graph,
185            operation: "upsert_edge_guarded".into(),
186            message: "this backend does not implement guarded edge writes".into(),
187        })
188    }
189    /// Observed form of [`GraphStore::upsert_edge_guarded`]. In addition to
190    /// the endpoint guard, it distinguishes create, live replacement, and
191    /// explicit resurrection without a caller-side read/write race.
192    async fn upsert_edge_guarded_observed(
193        &self,
194        _request: EdgeUpsertRequest,
195    ) -> StorageResult<GuardedEdgeUpsertOutcome> {
196        Err(StorageError::Unsupported {
197            capability: StorageCapability::Graph,
198            operation: "upsert_edge_guarded_observed".into(),
199            message: "this backend does not implement observed guarded edge upserts".into(),
200        })
201    }
202    /// Batch form of [`GraphStore::upsert_edge_guarded`]. All-or-nothing:
203    /// if any edge's endpoints are missing at write time, no edge from the
204    /// batch is persisted, `BatchWriteSummary::affected` is `0`, and
205    /// `GuardedBatchOutcome::refused` names the first failing batch entry and
206    /// its missing endpoint(s) — determined by the same in-transaction
207    /// pre-check that aborted the batch, not a post-hoc re-read.
208    /// Retain the original ordered input to enumerate every aborted write via
209    /// [`GuardedBatchOutcome::refusal_page`] beyond the default bounded sample.
210    ///
211    /// Default returns `StorageError::Unsupported`, for the same reason as
212    /// [`GraphStore::upsert_edge_guarded`]'s default.
213    async fn upsert_edges_guarded(&self, _edges: Vec<Edge>) -> StorageResult<GuardedBatchOutcome> {
214        Err(StorageError::Unsupported {
215            capability: StorageCapability::Graph,
216            operation: "upsert_edges_guarded".into(),
217            message: "this backend does not implement guarded edge writes".into(),
218        })
219    }
220    /// All-or-nothing observed batch form. Implementations must perform
221    /// endpoint and tombstone-policy preflight in the same write transaction
222    /// before applying any row.
223    async fn upsert_edges_guarded_observed(
224        &self,
225        _requests: Vec<EdgeUpsertRequest>,
226    ) -> StorageResult<GuardedEdgeBatchUpsertOutcome> {
227        Err(StorageError::Unsupported {
228            capability: StorageCapability::Graph,
229            operation: "upsert_edges_guarded_observed".into(),
230            message: "this backend does not implement observed guarded edge batches".into(),
231        })
232    }
233    /// Fetch an edge by link ID, returning `None` if absent. Filters soft-deleted rows.
234    async fn get_edge(&self, id: LinkId) -> StorageResult<Option<Edge>>;
235    /// Fetch an edge by link ID including soft-deleted rows. Used by the runtime hard-delete path
236    /// to locate and namespace-check an already-soft-deleted edge before purging it.
237    async fn get_edge_including_deleted(&self, id: LinkId) -> StorageResult<Option<Edge>>;
238    /// Fetch an edge by natural key (namespace, source, target, relation) including
239    /// soft-deleted rows. Used by the atomic-apply result renderer for a symmetric-relation
240    /// update whose surviving canonical row may be tombstoned (ADR-039 DO NOTHING) — the
241    /// normal `query_edges`/`list_edges` path filters `deleted_at IS NULL` and would report
242    /// "not found" for exactly that row.
243    ///
244    /// `namespace` is the natural key's own `namespace` column value (part of the
245    /// `UNIQUE(namespace, source_id, target_id, relation)` constraint this method queries by)
246    /// — it is passed explicitly rather than implied by whichever store instance `self` is,
247    /// so a caller who resolved the record's namespace independently of its own ambient token
248    /// (the atomic-apply renderer, which knows the committed edge's namespace from its prepare-
249    /// time `EdgeNaturalKey`, not from the caller's token) cannot accidentally query the wrong
250    /// namespace by relying on implicit store scoping.
251    async fn get_edge_by_natural_key_including_deleted(
252        &self,
253        namespace: &str,
254        source_id: Uuid,
255        target_id: Uuid,
256        relation: EdgeRelation,
257    ) -> StorageResult<Option<Edge>>;
258    /// Delete an edge by link ID using the specified delete mode.
259    async fn delete_edge(&self, id: LinkId, mode: DeleteMode) -> StorageResult<bool>;
260    /// Query edges with filter, sort, and pagination without an implicit
261    /// exact count. Implementations should return `total: None`; callers that
262    /// need a count use [`Self::count_edges`] explicitly.
263    async fn query_edges(
264        &self,
265        filter: EdgeFilter,
266        sort: Vec<SortOrder<EdgeSortField>>,
267        page: PageRequest,
268    ) -> StorageResult<Page<Edge>>;
269    /// Query edges across the given namespaces in one deterministic query
270    /// with real SQL paging. The multi-namespace analogue of
271    /// [`Self::query_edges`]: a single statement with `namespace IN (...)`
272    /// keeps `offset` continuation coherent, where fetching per-namespace
273    /// prefixes and slicing a client-side merge floats the window between
274    /// calls (silent duplicate/skip enumeration). Backends without batched
275    /// namespace support retain the single-namespace path and reject
276    /// multi-namespace requests explicitly. Implementations should return
277    /// `total: None`; callers that need a count use
278    /// [`Self::count_edges_in_namespaces`] explicitly.
279    async fn query_edges_in_namespaces(
280        &self,
281        namespaces: &[String],
282        filter: EdgeFilter,
283        sort: Vec<SortOrder<EdgeSortField>>,
284        page: PageRequest,
285    ) -> StorageResult<Page<Edge>> {
286        match namespaces.len() {
287            0 => Ok(Page {
288                items: Vec::new(),
289                total: None,
290            }),
291            1 => self.query_edges(filter, sort, page).await,
292            _ => Err(StorageError::Unsupported {
293                capability: StorageCapability::Graph,
294                operation: "query_edges_in_namespaces".into(),
295                message: "this backend does not implement batched namespace edge queries".into(),
296            }),
297        }
298    }
299    /// Count edges matching the given filter.
300    async fn count_edges(&self, filter: EdgeFilter) -> StorageResult<u64>;
301    /// Count edges across the given namespaces in one aggregate query.
302    /// Backends without batched namespace support retain the single-namespace
303    /// path and reject multi-namespace requests explicitly.
304    async fn count_edges_in_namespaces(
305        &self,
306        namespaces: &[String],
307        filter: EdgeFilter,
308    ) -> StorageResult<u64> {
309        match namespaces.len() {
310            0 => Ok(0),
311            1 => self.count_edges(filter).await,
312            _ => Err(StorageError::Unsupported {
313                capability: StorageCapability::Graph,
314                operation: "count_edges_in_namespaces".into(),
315                message: "this backend does not implement batched namespace edge counts".into(),
316            }),
317        }
318    }
319    /// Count edges grouped by relation, ignoring soft-deleted rows. Cheap
320    /// aggregate (`GROUP BY relation`) used to report the true per-relation
321    /// population for full-graph audits (#702.3).
322    async fn count_edges_by_relation(&self) -> StorageResult<Vec<(EdgeRelation, u64)>>;
323    /// Count edges grouped by relation across the given namespaces in one
324    /// aggregate query.
325    async fn count_edges_by_relation_in_namespaces(
326        &self,
327        namespaces: &[String],
328    ) -> StorageResult<Vec<(EdgeRelation, u64)>> {
329        match namespaces.len() {
330            0 => Ok(Vec::new()),
331            1 => self.count_edges_by_relation().await,
332            _ => Err(StorageError::Unsupported {
333                capability: StorageCapability::Graph,
334                operation: "count_edges_by_relation_in_namespaces".into(),
335                message: "this backend does not implement batched namespace relation counts".into(),
336            }),
337        }
338    }
339    /// Count live edges grouped by the base each endpoint resolves against.
340    ///
341    /// A relation breakdown cannot answer this: relations do not determine
342    /// endpoint bases, and the same relation appears on both sides of the
343    /// structure/provenance line. One aggregate query, same shape and cost as
344    /// the relation counts.
345    async fn count_edges_by_endpoint_base(&self) -> StorageResult<EdgeEndpointBaseCounts> {
346        Err(StorageError::Unsupported {
347            capability: StorageCapability::Graph,
348            operation: "count_edges_by_endpoint_base".into(),
349            message: "this backend does not implement endpoint-base edge counts".into(),
350        })
351    }
352    /// Count live edges by endpoint base across the given namespaces in one
353    /// aggregate query.
354    async fn count_edges_by_endpoint_base_in_namespaces(
355        &self,
356        namespaces: &[String],
357    ) -> StorageResult<EdgeEndpointBaseCounts> {
358        match namespaces.len() {
359            0 => Ok(EdgeEndpointBaseCounts::default()),
360            1 => self.count_edges_by_endpoint_base().await,
361            _ => Err(StorageError::Unsupported {
362                capability: StorageCapability::Graph,
363                operation: "count_edges_by_endpoint_base_in_namespaces".into(),
364                message: "this backend does not implement batched namespace endpoint-base counts"
365                    .into(),
366            }),
367        }
368    }
369    /// Seek-pagination page of edges ordered by `id` ascending, using an
370    /// indexed range scan (`id > after`) against the `(namespace, id)`
371    /// primary key instead of `OFFSET`. `after` is exclusive; `None` starts
372    /// from the beginning of the set. This remains an efficient compatibility
373    /// path for a fixed edge set, but random UUIDs inserted concurrently may
374    /// sort behind an issued boundary. Public concurrent walks use
375    /// [`Self::query_edges_sequence_after`] instead (#1424).
376    async fn query_edges_after(
377        &self,
378        filter: EdgeFilter,
379        after: Option<Uuid>,
380        limit: u32,
381    ) -> StorageResult<EdgeSeekPage>;
382    /// Resolve an edge id to its immutable insertion sequence.
383    async fn edge_sequence(&self, _id: Uuid) -> StorageResult<Option<i64>> {
384        Err(StorageError::Unsupported {
385            capability: StorageCapability::Graph,
386            operation: "edge_sequence".into(),
387            message: "this backend does not implement edge insertion sequences".into(),
388        })
389    }
390    /// Resolve edge ids to immutable insertion sequences. Implementations may
391    /// override this to batch the lookup; the default preserves correctness.
392    async fn edge_sequences(&self, ids: &[Uuid]) -> StorageResult<Vec<(Uuid, i64)>> {
393        let mut resolved = Vec::with_capacity(ids.len());
394        for id in ids {
395            if let Some(sequence) = self.edge_sequence(*id).await? {
396                resolved.push((*id, sequence));
397            }
398        }
399        Ok(resolved)
400    }
401    /// Seek-pagination page ordered by immutable insertion sequence. This is
402    /// the stable public-list contract for walks overlapping inserts (#1424).
403    async fn query_edges_sequence_after(
404        &self,
405        _filter: EdgeFilter,
406        _after: Option<SeekCursor>,
407        _limit: u32,
408    ) -> StorageResult<SeekPage<Edge>> {
409        Err(StorageError::Unsupported {
410            capability: StorageCapability::Graph,
411            operation: "query_edges_sequence_after".into(),
412            message: "this backend does not implement insertion-sequence edge pagination".into(),
413        })
414    }
415    /// Return immediate neighbors of a graph node.
416    async fn neighbors(
417        &self,
418        node_id: Uuid,
419        query: NeighborQuery,
420    ) -> StorageResult<Vec<NeighborHit>>;
421    /// Return one deterministic neighbor page. `after` is exclusive and
422    /// `neighbor_kinds`, when present, filters entity and note kinds before
423    /// the limit is applied. Backends that do not implement kind-aware paging
424    /// retain an explicit unsupported result rather than silently returning a
425    /// misleading page.
426    async fn neighbors_page(
427        &self,
428        node_id: Uuid,
429        mut query: NeighborQuery,
430        after: Option<NeighborCursor>,
431        neighbor_kinds: Option<Vec<String>>,
432    ) -> StorageResult<Vec<NeighborHit>> {
433        if neighbor_kinds
434            .as_ref()
435            .is_some_and(|kinds| !kinds.is_empty())
436        {
437            return Err(StorageError::Unsupported {
438                capability: StorageCapability::Graph,
439                operation: "neighbors_page".into(),
440                message: "this backend does not implement neighbor kind filtering".into(),
441            });
442        }
443        let limit = query.limit;
444        query.limit = None;
445        let mut hits = self.neighbors(node_id, query).await?;
446        if let Some(cursor) = after {
447            hits.retain(|hit| cursor.is_after(hit));
448        }
449        if let Some(limit) = limit {
450            hits.truncate(limit as usize);
451        }
452        Ok(hits)
453    }
454    /// Return neighbors in BOTH directions in a single call, each tagged with
455    /// the direction (`Out`/`In`) it was found in. `query.direction` is
456    /// ignored — this always fetches both directions.
457    ///
458    /// Exists so a caller that needs both-direction neighbors labeled by
459    /// direction (e.g. the `context` verb) can do so with one storage query
460    /// instead of two separate direction-scoped `neighbors` calls. The
461    /// default implementation preserves the original two-call behavior for
462    /// backends that don't override it; `SqlGraphStore` overrides this with a
463    /// single `UNION ALL` query that projects a direction literal per arm.
464    async fn neighbors_both_directions(
465        &self,
466        node_id: Uuid,
467        query: NeighborQuery,
468    ) -> StorageResult<Vec<DirectedNeighborHit>> {
469        let mut out_query = query.clone();
470        out_query.direction = Direction::Out;
471        let mut in_query = query;
472        in_query.direction = Direction::In;
473        let mut result = Vec::new();
474        for hit in self.neighbors(node_id, out_query).await? {
475            result.push(DirectedNeighborHit {
476                hit,
477                direction: Direction::Out,
478            });
479        }
480        for hit in self.neighbors(node_id, in_query).await? {
481            result.push(DirectedNeighborHit {
482                hit,
483                direction: Direction::In,
484            });
485        }
486        Ok(result)
487    }
488    /// Fetch multiple edges by their link IDs in a single round-trip.
489    ///
490    /// IDs that are not found (absent or soft-deleted) are silently skipped;
491    /// the returned `Vec` may be shorter than `ids`. Backends that support
492    /// batched `IN (...)` queries should override this; the default loops
493    /// `get_edge` so non-SQLite backends keep compiling unchanged.
494    ///
495    /// Callers must chunk large ID lists before calling if they need a strict
496    /// size bound; this method does not enforce a maximum.
497    async fn get_edges(&self, ids: &[LinkId]) -> StorageResult<Vec<Edge>> {
498        let mut out = Vec::with_capacity(ids.len());
499        for &id in ids {
500            if let Some(edge) = self.get_edge(id).await? {
501                out.push(edge);
502            }
503        }
504        Ok(out)
505    }
506    /// Read live edges in input order, retaining each row's decode outcome.
507    ///
508    /// The vector has exactly one entry per input, including duplicates and
509    /// missing/soft-deleted IDs (`Ok(None)`). An inner error belongs to that
510    /// input; an outer error is a statement/admission failure for the batch.
511    /// Callers must bound batches and select errors in their original order.
512    /// The default preserves backend support with point reads; SQLite batches.
513    async fn get_edge_read_outcomes(
514        &self,
515        ids: &[LinkId],
516    ) -> StorageResult<Vec<StorageResult<Option<Edge>>>> {
517        let mut outcomes = Vec::with_capacity(ids.len());
518        for &id in ids {
519            outcomes.push(self.get_edge(id).await);
520        }
521        Ok(outcomes)
522    }
523
524    /// Return neighbors for multiple source nodes in a single round-trip,
525    /// yielding `(source_id, hit)` pairs.
526    ///
527    /// The `query` parameters (direction, relations, min_weight) are applied
528    /// uniformly to every source node. `query.limit` is applied **per source**:
529    /// each source returns at most `limit` hits. Backends that support batched
530    /// `source_id IN (...)` queries should override this; the default loops
531    /// `neighbors` so non-SQLite backends keep compiling unchanged.
532    async fn batch_neighbors(
533        &self,
534        sources: &[Uuid],
535        query: NeighborQuery,
536    ) -> StorageResult<Vec<(Uuid, NeighborHit)>> {
537        let mut out = Vec::new();
538        for &src in sources {
539            let hits = self.neighbors(src, query.clone()).await?;
540            for hit in hits {
541                out.push((src, hit));
542            }
543        }
544        Ok(out)
545    }
546    /// Bounded multi-hop BFS traversal from the given roots.
547    ///
548    /// Implementations must validate [`TraversalRequest::validate`], count
549    /// adjacency rows before first-visit de-duplication against the request's
550    /// shared execution budget, stop a root as soon as its effective result
551    /// limit is filled, and return an error rather than partial paths when the
552    /// work or time budget expires. Minimum-depth BFS selection is normative;
553    /// same-depth tie ordering is not.
554    async fn traverse(&self, request: TraversalRequest) -> StorageResult<Vec<GraphPath>>;
555    /// Hard-delete every incident edge (source or target) for `node_id`, regardless of soft-delete
556    /// state. Used during endpoint hard-delete to prevent dangling `graph_edges` rows (ADR-002
557    /// no-dangling-references contract).
558    async fn purge_incident_edges(&self, node_id: Uuid) -> StorageResult<u64>;
559}