Skip to main content

core_api/
reader.rs

1//! Lock-free MVCC epoch readers.
2//!
3//! Each reader snapshots the db state at a fold point (every [`FOLD_EVERY_K`]
4//! commits) plus a bounded delta tail, allowing concurrent reads without holding
5//! the write lock.
6//!
7//! # Correctness guarantees
8//! 1. **Snapshot isolation**: query returns results consistent with the db state
9//!    at the moment [`GraphDb::reader`] was called.
10//! 2. **RBAC mask coherence** (constraint 2): [`ReaderSnapshot::mask_for_role`]
11//!    and [`ReaderSnapshot::query_masked`] operate on the same frozen base, so no
12//!    node can slip through a stale mask.
13//! 3. **Delta chain bounded**: at most `FOLD_EVERY_K − 1` deltas in the tail
14//!    (fold resets the counter synchronously on the write path).
15
16use std::collections::{BTreeMap, HashSet};
17use std::sync::{Arc, OnceLock};
18
19use core_query::cypher::{execute, is_write_tokens, lex, parse, plan, Params};
20use core_query::{expand, neighborhood, Dir, GraphView, ResultSet};
21use core_storage::fulltext::FulltextIndex;
22use core_storage::v8::seam::{ColumnsView, TopologyView};
23use core_storage::v8::MappedBase;
24use core_storage::wal::WalRecord;
25use core_storage::{
26    ColumnStore, Direction, EdgeProps, EdgePropsView, GraphError, IdMap, Interner, Result,
27    Topology, Value,
28};
29
30use crate::db::{EdgeInfo, NodeInfo};
31use crate::mask::NodeMask;
32use crate::roles::RoleDef;
33
34/// Fold trigger: every K commits, the overlay is cloned into a new `FrozenOverlay`
35/// and `delta_tail` is reset. The tail length is always ≤ K−1.
36pub const FOLD_EVERY_K: usize = 64;
37
38/// Per-commit overlay change record. Immutable after creation.
39pub struct CommitDelta {
40    /// WAL records for this commit (including `Intern` records, in WAL order).
41    pub records: Vec<WalRecord>,
42    /// Rule-derived edge **inserts** fired this commit: `(etype_sym, src_id, dst_id)`.
43    pub derived_inserts: Vec<(u32, u32, u32)>,
44    /// Rule-derived edge **retractions** this commit: `(etype_sym, src_id, dst_id)`.
45    pub derived_deletes: Vec<(u32, u32, u32)>,
46}
47
48/// Full clone of overlay state captured at fold time.
49///
50/// The V8 mmap base is not cloned here; it is `Arc`-shared in `ReaderSnapshot`.
51#[derive(Clone)]
52pub struct FrozenOverlay {
53    pub ids: IdMap,
54    pub syms: Interner,
55    pub topo: Topology,
56    pub props: ColumnStore,
57    pub labels: Vec<u32>,
58    pub edge_props: EdgeProps,
59    pub roles: Option<Vec<RoleDef>>,
60    pub fulltext: FulltextIndex,
61}
62
63/// Lock-free reader snapshot: frozen overlay + optional V8 base + pending delta tail.
64///
65/// Obtained cheaply via [`crate::SharedDb::reader`], which acquires the read lock
66/// only long enough to clone the `Arc` fields. Subsequent query operations run
67/// without any lock.
68///
69/// **Memory bound**: holds at most `FOLD_EVERY_K − 1` [`CommitDelta`] `Arc`s in
70/// its delta tail; the fold that resets the tail runs synchronously on the write path.
71pub struct ReaderSnapshot {
72    /// Most-recent fold of the overlay state.
73    pub frozen: Arc<FrozenOverlay>,
74    /// Shared mmap base (zero-copy, Arc-ref-counted). `None` for legacy stores.
75    pub base: Option<Arc<MappedBase>>,
76    /// Commits since the last fold, in arrival order. Length ≤ `FOLD_EVERY_K − 1`.
77    pub deltas: Vec<Arc<CommitDelta>>,
78    /// Cached materialized overlay (frozen + deltas applied).
79    ///
80    /// Computed at most once per `ReaderSnapshot` on the first call to
81    /// [`Self::effective`] when `deltas` is non-empty.  Stores `Err(String)` if
82    /// WAL application fails so the error is returned to every subsequent caller
83    /// without re-attempting.  When `deltas` is empty this field is never
84    /// populated — `effective` returns `&frozen` directly.
85    cache: OnceLock<std::result::Result<FrozenOverlay, String>>,
86}
87
88// ── Private view-building helpers ─────────────────────────────────────────────
89
90fn build_tv<'a>(topo: &'a Topology, base: &'a Option<Arc<MappedBase>>) -> TopologyView<'a> {
91    match base {
92        None => TopologyView::owned(topo),
93        Some(b) => {
94            let csr = b.topology().expect("base CSR CRC already verified at open");
95            TopologyView::with_base(topo, csr)
96        }
97    }
98}
99
100fn build_cv<'a>(props: &'a ColumnStore, base: &'a Option<Arc<MappedBase>>) -> ColumnsView<'a> {
101    match base {
102        None => ColumnsView::owned(props),
103        Some(b) => {
104            let cols = b
105                .columns()
106                .expect("base columns CRC already verified at open");
107            ColumnsView::with_base(props, cols)
108        }
109    }
110}
111
112fn build_epv<'a>(
113    edge_props: &'a EdgeProps,
114    base: &'a Option<Arc<MappedBase>>,
115) -> EdgePropsView<'a> {
116    match base {
117        None => EdgePropsView::owned(edge_props),
118        Some(b) => {
119            let archived = b
120                .edge_props_section()
121                .expect("base edge_props CRC already verified at open");
122            EdgePropsView::with_base(edge_props, archived)
123        }
124    }
125}
126
127fn make_view<'a>(
128    state: &'a FrozenOverlay,
129    base: &'a Option<Arc<MappedBase>>,
130    mask: Option<&'a HashSet<u32>>,
131) -> GraphView<'a> {
132    GraphView {
133        ids: &state.ids,
134        syms: &state.syms,
135        labels: &state.labels,
136        props: build_cv(&state.props, base),
137        topo: build_tv(&state.topo, base),
138        edge_props: build_epv(&state.edge_props, base),
139        mask,
140        // MVCC reader snapshots don't carry the equality index; IndexScan
141        // falls back to a correct scan+filter on this path.
142        prop_index: None,
143    }
144}
145
146// ── Delta application ─────────────────────────────────────────────────────────
147
148/// Apply a single WAL record (recursing into `Batch`) to mutable working state.
149/// Skips rule/view records that are no-ops on the read path.
150///
151/// Note: fulltext is updated incrementally here for correctness; after all deltas
152/// are applied the caller should call `fulltext.rebuild_all` to correct drift from
153/// multi-field updates and out-of-order incremental additions.
154#[allow(clippy::too_many_arguments)]
155fn apply_one(
156    ids: &mut IdMap,
157    syms: &mut Interner,
158    topo: &mut Topology,
159    props: &mut ColumnStore,
160    edge_props: &mut EdgeProps,
161    labels: &mut Vec<u32>,
162    fulltext: &mut FulltextIndex,
163    rec: &WalRecord,
164) -> Result<()> {
165    match rec {
166        WalRecord::Intern { id, text } => {
167            let got = syms.intern(text);
168            if got != *id {
169                return Err(GraphError::Corrupt {
170                    detail: format!(
171                        "mvcc delta intern mismatch for {text:?}: expected {id}, got {got}"
172                    ),
173                });
174            }
175        }
176
177        WalRecord::InsertNodeId {
178            label,
179            key,
180            props: node_props,
181        } => {
182            let node_id = ids.try_insert(key)?;
183            if labels.len() <= node_id as usize {
184                labels.resize(node_id as usize + 1, u32::MAX);
185            }
186            labels[node_id as usize] = *label;
187            let label_str = syms
188                .resolve(*label)
189                .ok_or_else(|| GraphError::Corrupt {
190                    detail: format!("mvcc delta: unknown label sym {label}"),
191                })?
192                .to_string();
193            for (field_sym, value) in node_props {
194                let field = syms
195                    .resolve(*field_sym)
196                    .ok_or_else(|| GraphError::Corrupt {
197                        detail: format!("mvcc delta: unknown field sym {field_sym}"),
198                    })?
199                    .to_string();
200                props.set(node_id, &field, value.clone());
201                if fulltext.is_enabled(&label_str, &field) {
202                    fulltext.add_tokens(node_id, &field, value);
203                }
204            }
205        }
206
207        WalRecord::InsertNode {
208            label,
209            key,
210            props: node_props,
211        } => {
212            let label_sym = syms.intern(label);
213            let node_id = ids.try_insert(key)?;
214            if labels.len() <= node_id as usize {
215                labels.resize(node_id as usize + 1, u32::MAX);
216            }
217            labels[node_id as usize] = label_sym;
218            for (field, value) in node_props {
219                props.set(node_id, field, value.clone());
220                if fulltext.is_enabled(label, field) {
221                    fulltext.add_tokens(node_id, field, value);
222                }
223            }
224        }
225
226        WalRecord::SetPropId { id, field, value } => {
227            if let Some(field_str) = syms.resolve(*field).map(str::to_string) {
228                props.set(*id, &field_str, value.clone());
229                if let Some(&label_sym) = labels.get(*id as usize) {
230                    if let Some(label_str) = syms.resolve(label_sym) {
231                        if fulltext.is_enabled(label_str, &field_str) {
232                            fulltext.add_tokens(*id, &field_str, value);
233                        }
234                    }
235                }
236            }
237        }
238
239        WalRecord::SetProp { key, field, value } => {
240            if let Some(node_id) = ids.get(key) {
241                props.set(node_id, field, value.clone());
242                if let Some(&label_sym) = labels.get(node_id as usize) {
243                    if let Some(label_str) = syms.resolve(label_sym) {
244                        if fulltext.is_enabled(label_str, field) {
245                            fulltext.add_tokens(node_id, field, value);
246                        }
247                    }
248                }
249            }
250        }
251
252        WalRecord::RemoveProp { key, field } => {
253            if let Some(node_id) = ids.get(key) {
254                props.remove(node_id, field);
255                // A base-resident prop must be masked or ColumnsView falls
256                // through to the archived value (mirrors db.rs WAL replay).
257                // Tombstoning a prop absent from the base is a harmless
258                // false-mask: the overlay short-circuit never reaches it.
259                props.record_prop_tombstone(node_id, field);
260                fulltext.remove_node_field(node_id, field);
261            }
262        }
263
264        WalRecord::DeleteNode { key } => {
265            if let Some(node_id) = ids.delete(key) {
266                props.remove_all(node_id);
267                fulltext.remove_node(node_id);
268                // Mark the label slot as sentinel so label_of returns None.
269                if let Some(slot) = labels.get_mut(node_id as usize) {
270                    *slot = u32::MAX;
271                }
272                // Sweep all edges incident on this node to prevent phantom
273                // adjacency. db.rs deletes these edges inline without emitting
274                // DeleteEdge WAL records, so we must mirror that sweep here.
275                let etypes: Vec<u32> = topo.etypes().collect();
276                let mut doomed = Vec::new();
277                for et in &etypes {
278                    for &dst in topo.neighbors(*et, Direction::Out, node_id).as_ref() {
279                        doomed.push((*et, node_id, dst));
280                    }
281                    for &src in topo.neighbors(*et, Direction::In, node_id).as_ref() {
282                        doomed.push((*et, src, node_id));
283                    }
284                }
285                for (et, s, d) in doomed {
286                    topo.remove_edge(et, s, d);
287                    edge_props.remove_edge(et, s, d);
288                }
289            }
290        }
291
292        WalRecord::InsertEdgeId { etype, src, dst } => {
293            topo.add_edge(*etype, *src, *dst);
294        }
295
296        WalRecord::InsertEdge {
297            edge_type,
298            src_key,
299            dst_key,
300        } => {
301            let etype = syms.intern(edge_type);
302            if let (Some(src), Some(dst)) = (ids.get(src_key), ids.get(dst_key)) {
303                topo.add_edge(etype, src, dst);
304            }
305        }
306
307        WalRecord::DeleteEdge {
308            edge_type,
309            src_key,
310            dst_key,
311        } => {
312            if let Some(etype) = syms.get(edge_type) {
313                if let (Some(src), Some(dst)) = (ids.get(src_key), ids.get(dst_key)) {
314                    topo.remove_edge(etype, src, dst);
315                }
316            }
317        }
318
319        WalRecord::EnableFulltext { label, field } => {
320            fulltext.enable(label, field);
321        }
322
323        WalRecord::DisableFulltext { label, field } => {
324            fulltext.disable(label, field);
325        }
326
327        WalRecord::Batch(inner) => {
328            for r in inner {
329                apply_one(ids, syms, topo, props, edge_props, labels, fulltext, r)?;
330            }
331        }
332
333        // No-ops for the read path: rule and view management do not affect
334        // the structural overlay data that queries read.
335        WalRecord::CreateRule { .. }
336        | WalRecord::DeleteRule { .. }
337        | WalRecord::RebuildRule { .. }
338        | WalRecord::CreateView { .. }
339        | WalRecord::DeleteView { .. }
340        // Property-index declarations are no-ops in the read path: MVCC reader
341        // snapshots do not carry the equality index, so `IndexScan` falls back
342        // to a correct scan+filter for reader queries.
343        | WalRecord::EnableIndex { .. }
344        | WalRecord::DisableIndex { .. }
345        // History markers are no-ops in the read path. Rules re-derive their
346        // edges when the ReaderSnapshot queries the live engine; markers only
347        // serve edge_history / was_linked WAL scans.
348        | WalRecord::DerivedEdgeAdded { .. }
349        | WalRecord::DerivedEdgeRetracted { .. } => {}
350
351        WalRecord::RenameNode { old_key, new_key } => {
352            // Recovery-safe: if old_key is already gone (frozen overlay or a
353            // prior delta already applied the rename), skip cleanly.
354            if ids.get(old_key).is_some() {
355                ids.rename(old_key, new_key).map_err(|_| GraphError::Corrupt {
356                    detail: format!("mvcc delta RenameNode {old_key}→{new_key} failed"),
357                })?;
358            }
359        }
360    }
361    Ok(())
362}
363
364// ── ReaderSnapshot ────────────────────────────────────────────────────────────
365
366fn mask_for_role_from(state: &FrozenOverlay, role: &str) -> Result<NodeMask> {
367    let roles = state.roles.as_ref().ok_or_else(|| GraphError::Corrupt {
368        detail: "roles.json was corrupt at open; fix the file and re-open".into(),
369    })?;
370    let def = roles
371        .iter()
372        .find(|r| r.name == role)
373        .ok_or_else(|| GraphError::KeyNotFound {
374            key: format!("role:{role}"),
375        })?;
376    let mut visible = HashSet::new();
377    for key in &def.keys {
378        if let Some(id) = state.ids.get(key) {
379            visible.insert(id);
380        }
381    }
382    for label_name in &def.labels {
383        if let Some(sym) = state.syms.get(label_name) {
384            for (i, &s) in state.labels.iter().enumerate() {
385                if s == sym {
386                    visible.insert(i as u32);
387                }
388            }
389        }
390    }
391    Ok(NodeMask::from_ids(visible))
392}
393
394impl ReaderSnapshot {
395    /// Apply all pending deltas to a clone of `frozen`.
396    ///
397    /// Returns the frozen state (cloned) with delta changes applied, including
398    /// rule-derived edge inserts/retracts and a rebuilt full-text index.
399    fn materialize(&self) -> Result<FrozenOverlay> {
400        let mut w = (*self.frozen).clone();
401        for delta in &self.deltas {
402            for rec in &delta.records {
403                apply_one(
404                    &mut w.ids,
405                    &mut w.syms,
406                    &mut w.topo,
407                    &mut w.props,
408                    &mut w.edge_props,
409                    &mut w.labels,
410                    &mut w.fulltext,
411                    rec,
412                )?;
413            }
414            for &(etype, src, dst) in &delta.derived_inserts {
415                w.topo.add_edge(etype, src, dst);
416            }
417            for &(etype, src, dst) in &delta.derived_deletes {
418                w.topo.remove_edge(etype, src, dst);
419            }
420        }
421        if !self.deltas.is_empty() {
422            // Rebuild full-text to correct incremental drift accumulated during
423            // delta application (add_tokens is imprecise for multi-field/deletion paths).
424            let cv = build_cv(&w.props, &self.base);
425            w.fulltext.rebuild_all(&w.ids, &w.labels, &w.syms, cv);
426        }
427        Ok(w)
428    }
429
430    /// Construct a `ReaderSnapshot` from its constituent parts.
431    ///
432    /// Used by [`crate::db::GraphDb::reader`] — the only site that builds a
433    /// snapshot — so the private `cache` field stays encapsulated here.
434    pub(crate) fn new(
435        frozen: Arc<FrozenOverlay>,
436        base: Option<Arc<MappedBase>>,
437        deltas: Vec<Arc<CommitDelta>>,
438    ) -> Self {
439        Self {
440            frozen,
441            base,
442            deltas,
443            cache: OnceLock::new(),
444        }
445    }
446
447    // ── Private helpers ───────────────────────────────────────────────────────
448
449    /// Return a reference to the current effective state.
450    ///
451    /// When the delta tail is empty this is a zero-copy borrow of `frozen`.
452    /// Otherwise the delta tail is applied to a clone of `frozen` exactly once
453    /// (cached in `self.cache`) so that all operations within a single
454    /// `ReaderSnapshot` share the same materialized view (F3: no triple
455    /// materialize per request).
456    fn effective(&self) -> Result<&FrozenOverlay> {
457        if self.deltas.is_empty() {
458            return Ok(&self.frozen);
459        }
460        let cached = self
461            .cache
462            .get_or_init(|| self.materialize().map_err(|e| e.to_string()));
463        cached
464            .as_ref()
465            .map_err(|e| GraphError::Corrupt { detail: e.clone() })
466    }
467
468    // ── Public API ────────────────────────────────────────────────────────────
469
470    /// Resolve a role name to a node visibility mask.
471    ///
472    /// Coherent with [`Self::query_masked`]: both read from the same effective
473    /// state (frozen or cached materialization), so the mask is never stale
474    /// relative to the query data.
475    pub fn mask_for_role(&self, role: &str) -> Result<NodeMask> {
476        mask_for_role_from(self.effective()?, role)
477    }
478
479    /// Resolve a node key to its dense id.
480    ///
481    /// Checks the delta tail (via the cached materialization) so that nodes
482    /// inserted since the last fold are visible.
483    pub fn resolve_key(&self, key: &str) -> Option<u32> {
484        self.effective().ok()?.ids.get(key)
485    }
486
487    /// Execute a read-only Cypher query over the epoch snapshot.
488    pub fn query(&self, cypher: &str, params: &BTreeMap<String, Value>) -> Result<ResultSet> {
489        let tokens = lex(cypher).map_err(|e| GraphError::QueryError {
490            detail: format!("lex: {e}"),
491        })?;
492        let ast = parse(&tokens).map_err(|e| GraphError::QueryError {
493            detail: format!("parse: {e}"),
494        })?;
495        let ops = plan(&ast).map_err(|e| GraphError::QueryError {
496            detail: format!("plan: {e}"),
497        })?;
498        let state = self.effective()?;
499        let view = make_view(state, &self.base, None);
500        execute(&view, &ops, &Params(params)).map_err(|e| GraphError::QueryError {
501            detail: format!("execute: {e}"),
502        })
503    }
504
505    /// Execute a read-only Cypher query with a node visibility mask.
506    ///
507    /// Returns `Err` when `cypher` is a write statement (CREATE / MATCH…SET / DELETE).
508    pub fn query_masked(
509        &self,
510        cypher: &str,
511        params: &BTreeMap<String, Value>,
512        mask: &NodeMask,
513    ) -> Result<ResultSet> {
514        let tokens = lex(cypher).map_err(|e| GraphError::QueryError {
515            detail: format!("lex: {e}"),
516        })?;
517        if is_write_tokens(&tokens) {
518            return Err(GraphError::QueryError {
519                detail: "masked queries are read-only".into(),
520            });
521        }
522        let ast = parse(&tokens).map_err(|e| GraphError::QueryError {
523            detail: format!("parse: {e}"),
524        })?;
525        let ops = plan(&ast).map_err(|e| GraphError::QueryError {
526            detail: format!("plan: {e}"),
527        })?;
528        let state = self.effective()?;
529        let view = make_view(state, &self.base, Some(&mask.visible));
530        execute(&view, &ops, &Params(params)).map_err(|e| GraphError::QueryError {
531            detail: format!("execute: {e}"),
532        })
533    }
534
535    /// Live node info from the epoch snapshot. `None` if key is absent or tombstoned.
536    pub fn node_info(&self, key: &str) -> Option<NodeInfo> {
537        node_info_from(key, self.effective().ok()?, &self.base)
538    }
539
540    /// Every directed edge incident on `key`. `derived` is always `false` since
541    /// the reader snapshot has no rule engine.
542    ///
543    /// Unknown key → `Err(GraphError::KeyNotFound)`.
544    pub fn node_edges(&self, key: &str) -> Result<Vec<EdgeInfo>> {
545        node_edges_from(key, self.effective()?, &self.base)
546    }
547
548    /// BFS neighborhood expansion restricted to `mask`-visible nodes.
549    ///
550    /// Hidden nodes are neither returned nor used as traversal intermediaries
551    /// (never-leak invariant). Returns `None` when `key` does not exist.
552    pub fn neighborhood_masked(
553        &self,
554        key: &str,
555        depth: u32,
556        edge_types: Option<&[&str]>,
557        dir: Dir,
558        mask: &NodeMask,
559    ) -> Option<ResultSet> {
560        neighborhood_masked_from(
561            key,
562            self.effective().ok()?,
563            &self.base,
564            depth,
565            edge_types,
566            dir,
567            mask,
568        )
569    }
570}
571
572// ── Free-standing helpers that take state by reference ────────────────────────
573
574fn node_info_from(
575    key: &str,
576    state: &FrozenOverlay,
577    base: &Option<Arc<MappedBase>>,
578) -> Option<NodeInfo> {
579    let id = state.ids.get(key)?;
580    let label_sym = *state.labels.get(id as usize)?;
581    if label_sym == u32::MAX {
582        return None;
583    }
584    let label = state.syms.resolve(label_sym)?.to_string();
585    let cv = build_cv(&state.props, base);
586    let mut props = BTreeMap::new();
587    for field in cv.field_names() {
588        if let Some(vr) = cv.get(id, &field) {
589            props.insert(field, vr.into_value());
590        }
591    }
592    Some(NodeInfo {
593        key: key.to_string(),
594        label,
595        props,
596    })
597}
598
599fn node_edges_from(
600    key: &str,
601    state: &FrozenOverlay,
602    base: &Option<Arc<MappedBase>>,
603) -> Result<Vec<EdgeInfo>> {
604    let id = state
605        .ids
606        .get(key)
607        .ok_or_else(|| GraphError::KeyNotFound { key: key.into() })?;
608    let tv = build_tv(&state.topo, base);
609    let mut edges = Vec::new();
610    for etype in tv.etypes() {
611        let edge_type = state
612            .syms
613            .resolve(etype)
614            .ok_or_else(|| GraphError::Corrupt {
615                detail: format!("reader: topology etype {etype} not in interner"),
616            })?
617            .to_string();
618        for dir in [Direction::Out, Direction::In] {
619            for &nbr in tv.neighbors(etype, dir, id).as_ref() {
620                let (src_key, dst_key) = match dir {
621                    Direction::Out => (
622                        key.to_string(),
623                        state
624                            .ids
625                            .key_of(nbr)
626                            .ok_or_else(|| GraphError::Corrupt {
627                                detail: format!("topology id {nbr} has no key"),
628                            })?
629                            .to_string(),
630                    ),
631                    Direction::In => (
632                        state
633                            .ids
634                            .key_of(nbr)
635                            .ok_or_else(|| GraphError::Corrupt {
636                                detail: format!("topology id {nbr} has no key"),
637                            })?
638                            .to_string(),
639                        key.to_string(),
640                    ),
641                };
642                edges.push(EdgeInfo {
643                    edge_type: edge_type.clone(),
644                    src_key,
645                    dst_key,
646                    derived: false,
647                });
648            }
649        }
650    }
651    edges.sort_by(|a, b| {
652        a.edge_type
653            .cmp(&b.edge_type)
654            .then(a.src_key.cmp(&b.src_key))
655            .then(a.dst_key.cmp(&b.dst_key))
656    });
657    edges.dedup();
658    Ok(edges)
659}
660
661fn neighborhood_masked_from(
662    key: &str,
663    state: &FrozenOverlay,
664    base: &Option<Arc<MappedBase>>,
665    depth: u32,
666    edge_types: Option<&[&str]>,
667    dir: Dir,
668    mask: &NodeMask,
669) -> Option<ResultSet> {
670    let start_id = state.ids.get(key)?;
671    let view = make_view(state, base, Some(&mask.visible));
672    let resolved: Option<Vec<u32>> = edge_types.map(|names| {
673        names
674            .iter()
675            .filter_map(|name| view.syms.get(name))
676            .collect()
677    });
678    let nb = neighborhood(&view, start_id, depth, resolved.as_deref(), dir);
679    let mut rs = ResultSet::new(vec!["key".into(), "label".into(), "depth".into()]);
680    // Collect visible BFS results (start_id at depth 0, BFS nodes after).
681    let mut visited: Vec<(u32, u32)> = Vec::with_capacity(nb.nodes.len() + 1);
682    visited.push((start_id, 0));
683    for (nid, d) in &nb.nodes {
684        let k = view.key_of(*nid);
685        let lbl = view
686            .label_of(*nid)
687            .expect("real nodes always have a label; u32::MAX sentinel cannot occur");
688        rs.push_row(vec![
689            Some(Value::Str(k.to_string())),
690            Some(Value::Str(lbl.to_string())),
691            Some(Value::Int(*d as i64)),
692        ]);
693        visited.push((*nid, *d));
694    }
695    // Stub mode: add hidden direct neighbours of each visited node as stubs.
696    // Hidden nodes are edge-endpoints only — they are not added to the BFS
697    // frontier, so the BFS never expands through them in either mode.
698    //
699    // Role-token callers always pass an Omit-mode mask (mask_for_role uses
700    // NodeMask::from_ids which defaults to Omit; intersect() hard-returns Omit),
701    // so this branch is unreachable on the role path — security is unaffected.
702    if mask.mode() == crate::mask::MaskMode::Stub {
703        let raw_view = make_view(state, base, None);
704        let mut seen: HashSet<u32> = visited.iter().map(|(id, _)| *id).collect();
705        for (node_id, node_depth) in &visited {
706            if *node_depth >= depth {
707                continue;
708            }
709            for e in expand(&raw_view, *node_id, resolved.as_deref(), dir) {
710                let nbr = if e.src == *node_id { e.dst } else { e.src };
711                if !mask.contains_id(nbr) && seen.insert(nbr) {
712                    if let Some(k) = state.ids.key_of(nbr) {
713                        rs.push_row(vec![
714                            Some(Value::Str(k.to_string())),
715                            None,
716                            Some(Value::Int((*node_depth + 1) as i64)),
717                        ]);
718                    }
719                }
720            }
721        }
722    }
723    Some(rs)
724}