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}