Skip to main content

core_api/
db.rs

1use crate::ingest::{IngestOptions, IngestReport};
2use crate::roles::{RoleDef, RolesFile, WriteScope};
3use crate::subscription::{
4    event_matches, DbEvent, SubEntry, SubFilter, SubInner, Subscription, DEFAULT_SUB_CAPACITY,
5};
6use core_query::cypher::ast::ArithOp;
7use core_query::cypher::{
8    execute, execute_union, is_subscribable, is_write_tokens, lex, parse, parse_read, parse_write,
9    plan, MatchDeleteNodeStmt, NodePat, Operand, Params, Pattern, PlanOp, Query, RetItem, RetVal,
10    WriteStatement,
11};
12use core_query::{eval_filter, expand, neighborhood, Dir, Filter, GraphView, ResultSet};
13use core_rules::{
14    decode_rule_def, evaluate, EngineEdgeDelta, GraphMut, NodeView, Predicate, RuleDef, RuleEngine,
15    ViewDef, ViewStore,
16};
17use core_storage::fs::{FileId, Fs, FsIntrospect, RealFs};
18use core_storage::fulltext::FulltextIndex;
19use core_storage::property_index::PropertyIndex;
20use core_storage::v8::encode::{
21    archived_hnsw_to_owned, archived_rules_meta_to_owned, archived_to_idmap, archived_to_interner,
22    archived_views_to_owned, decode_last_change_bytes, decode_meta, encode_v8, V8Meta,
23};
24use core_storage::v8::seam::TopologyView;
25use core_storage::wal::{decode_all, encode_record, WalRecord};
26use core_storage::EdgePropsView;
27use core_storage::{
28    ColumnStore, Direction, EdgeProps, GraphError, IdMap, Interner, Result, Topology, Value,
29};
30use serde::{Deserialize, Serialize};
31use std::collections::{BTreeMap, BTreeSet, HashMap};
32use std::sync::Arc;
33
34/// Print a timing checkpoint when MUSHROOMDB_TRACE_OPEN is set.
35/// Zero-cost when the env var is absent (the var check is O(1) after first call).
36macro_rules! trace_open {
37    ($phase:literal, $t:expr) => {
38        if std::env::var("MUSHROOMDB_TRACE_OPEN").is_ok() {
39            eprintln!(
40                "[MUSHROOMDB_TRACE_OPEN] {:40} {:>9.3?}",
41                $phase,
42                $t.elapsed()
43            );
44        }
45    };
46}
47
48/// Print a migration phase checkpoint when MUSHROOMDB_TRACE_MIGRATE is set.
49/// Zero-cost when the env var is absent (the var check is O(1) after first call).
50macro_rules! trace_migrate {
51    ($phase:literal, $t:expr) => {
52        if std::env::var("MUSHROOMDB_TRACE_MIGRATE").is_ok() {
53            eprintln!(
54                "[MUSHROOMDB_TRACE_MIGRATE] {:40} {:>9.3?}",
55                $phase,
56                $t.elapsed()
57            );
58        }
59    };
60}
61
62// Test-only: counts how many times `pending_deltas_since().to_vec()` actually
63// executes (i.e., at least one view is defined). Used to verify the fast-path
64// guard skips the allocation when `view_store.is_empty()`.
65#[cfg(test)]
66thread_local! {
67    static DELTA_COPY_COUNT: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
68}
69
70/// Internal state for a single `subscribe_query` subscription.
71///
72/// On every commit, `distribute_events` re-executes `ops` against the current
73/// graph state, diffs the result against `prev_rows`, and pushes
74/// `DbEvent::QueryRowAdded` / `QueryRowRemoved` events to `inner`.
75///
76/// **Full re-run per commit; use LIMIT to bound execution cost.**
77/// (Differential evaluation is roadmap / Phase 5.)
78pub(crate) struct QuerySubEntry {
79    /// Compiled plan for the subscribed Cypher query.
80    ops: Vec<PlanOp>,
81    /// Column names from the first execution (fixed for the subscription lifetime).
82    columns: Vec<String>,
83    /// Serialized (JSON) row key → row data, representing the result set at
84    /// the end of the last commit. Used to diff against the new result.
85    prev_row_map: std::collections::HashMap<String, Vec<Option<Value>>>,
86    /// Weak pointer to the subscriber queue; dead Weak → subscription dropped.
87    inner: std::sync::Weak<SubInner>,
88}
89
90/// A post-commit mutation notification.
91///
92/// Emitted from `log_then_apply` after the WAL append, fsync, and
93/// in-memory `apply` all succeed. Never emitted for rejected operations
94/// (validation errors, [`GraphError::RuleOwned`], duplicate keys, no-op
95/// deletes/removes). Event payloads carry user keys and rule names, never
96/// internal ids.
97///
98/// **Replay:** [`GraphDb::open`] / [`GraphDb::open_with`] replay the WAL via
99/// `apply` only. Emission lives exclusively in `log_then_apply`, so
100/// recovery is silent even if a sink were installed (it cannot be: the
101/// sink is in-memory and set after open).
102///
103/// **Ordering:** a `Batch` WAL frame emits one event per inner record, then
104/// [`MutationEvent::BatchApplied`]. An ingest commit emits those same inner
105/// events, then [`MutationEvent::Ingested`] (not `BatchApplied`). An empty
106/// or all-noop batch writes no WAL and emits nothing (including no summary).
107///
108/// **Derived edges:** rule-created or retracted edges are not individually
109/// evented — they are recoverable from the triggering mutation plus the live
110/// rule set. Only the triggering record is emitted.
111///
112/// **Wire form:** externally tagged snake_case JSON
113/// (`{"node_inserted":{"label":"A","key":"k"}}`).
114#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
115#[serde(rename_all = "snake_case")]
116pub enum MutationEvent {
117    NodeInserted {
118        label: String,
119        key: String,
120    },
121    PropSet {
122        key: String,
123        field: String,
124    },
125    PropRemoved {
126        key: String,
127        field: String,
128    },
129    EdgeInserted {
130        edge_type: String,
131        src: String,
132        dst: String,
133    },
134    EdgeDeleted {
135        edge_type: String,
136        src: String,
137        dst: String,
138    },
139    NodeDeleted {
140        key: String,
141    },
142    RuleCreated {
143        name: String,
144    },
145    RuleDeleted {
146        name: String,
147    },
148    RuleRebuilt {
149        name: String,
150    },
151    BatchApplied {
152        ops: usize,
153    },
154    Ingested {
155        label: String,
156        inserted: usize,
157    },
158}
159
160fn event_from_record(rec: &WalRecord, intern: &Interner, ids: &IdMap) -> Option<MutationEvent> {
161    match rec {
162        WalRecord::InsertNode { label, key, .. } => Some(MutationEvent::NodeInserted {
163            label: label.clone(),
164            key: key.clone(),
165        }),
166        WalRecord::InsertNodeId { label, key, .. } => Some(MutationEvent::NodeInserted {
167            label: intern.resolve(*label)?.to_string(),
168            key: key.clone(),
169        }),
170        WalRecord::SetProp { key, field, .. } => Some(MutationEvent::PropSet {
171            key: key.clone(),
172            field: field.clone(),
173        }),
174        WalRecord::SetPropId { id, field, .. } => Some(MutationEvent::PropSet {
175            key: ids.key_of(*id)?.to_string(),
176            field: intern.resolve(*field)?.to_string(),
177        }),
178        WalRecord::RemoveProp { key, field } => Some(MutationEvent::PropRemoved {
179            key: key.clone(),
180            field: field.clone(),
181        }),
182        WalRecord::InsertEdge {
183            edge_type,
184            src_key,
185            dst_key,
186        } => Some(MutationEvent::EdgeInserted {
187            edge_type: edge_type.clone(),
188            src: src_key.clone(),
189            dst: dst_key.clone(),
190        }),
191        WalRecord::InsertEdgeId { etype, src, dst } => Some(MutationEvent::EdgeInserted {
192            edge_type: intern.resolve(*etype)?.to_string(),
193            src: ids.key_of(*src)?.to_string(),
194            dst: ids.key_of(*dst)?.to_string(),
195        }),
196        WalRecord::DeleteEdge {
197            edge_type,
198            src_key,
199            dst_key,
200        } => Some(MutationEvent::EdgeDeleted {
201            edge_type: edge_type.clone(),
202            src: src_key.clone(),
203            dst: dst_key.clone(),
204        }),
205        WalRecord::DeleteNode { key } => Some(MutationEvent::NodeDeleted { key: key.clone() }),
206        WalRecord::CreateRule { def_bytes } => {
207            let def: RuleDef = decode_rule_def(def_bytes).ok()?;
208            Some(MutationEvent::RuleCreated { name: def.name })
209        }
210        WalRecord::DeleteRule { name } => Some(MutationEvent::RuleDeleted { name: name.clone() }),
211        WalRecord::RebuildRule { name } => Some(MutationEvent::RuleRebuilt { name: name.clone() }),
212        WalRecord::Batch(_)
213        | WalRecord::CreateView { .. }
214        | WalRecord::DeleteView { .. }
215        | WalRecord::EnableFulltext { .. }
216        | WalRecord::DisableFulltext { .. }
217        | WalRecord::EnableIndex { .. }
218        | WalRecord::DisableIndex { .. }
219        | WalRecord::Intern { .. }
220        // History markers are no-ops for mutation events — they carry no new
221        // state and rules re-derive deterministically on replay.
222        | WalRecord::DerivedEdgeAdded { .. }
223        | WalRecord::DerivedEdgeRetracted { .. }
224        // RenameNode carries no node/edge count change; no special event.
225        | WalRecord::RenameNode { .. } => None,
226    }
227}
228
229/// Database-wide counters plus per-rule budget/fire stats.
230#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
231pub struct Stats {
232    pub nodes_live: usize,
233    pub nodes_tombstoned: usize,
234    pub edges: u64,
235    pub rules: Vec<RuleStats>,
236}
237
238/// One rule's provenance size, trip latch, and fire counter.
239///
240/// `tripped` is a one-way latch: once set, the engine adds no new edges for
241/// that rule until [`GraphDb::rebuild_rule`] (and only if the full desired
242/// set then fits). `fires` counts `on_node_changed` evaluations plus
243/// backfill/rebuild participant ticks (rebuild counts even when it is a
244/// provenance no-op).
245#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
246pub struct RuleStats {
247    pub name: String,
248    pub edges: u64,
249    pub tripped: bool,
250    pub fires: u64,
251    /// Whether this rule uses the approximate IVF-Flat candidate path.
252    pub approximate: bool,
253}
254
255/// Wire summary of a [`Predicate`]. JSON only — `Explanation` is never
256/// bincode-persisted (WAL/snapshots store `RuleDef` bytes, not this type).
257#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
258pub struct PredicateSummary {
259    pub kind: String,
260    pub fields: Vec<String>,
261    pub min: Option<f64>,
262    pub tolerance: Option<f64>,
263    pub km: Option<f64>,
264    pub parts: Option<Vec<PredicateSummary>>,
265    /// True when the owning rule has `approximate=true` (IVF-Flat candidate path).
266    /// Always false for predicates reported without rule context (sub-predicates in `parts`).
267    #[serde(default)]
268    pub approximate: bool,
269}
270
271impl From<&Predicate> for PredicateSummary {
272    fn from(p: &Predicate) -> Self {
273        match p {
274            Predicate::KeyMatch { field } => PredicateSummary {
275                kind: "key_match".into(),
276                fields: vec![field.clone()],
277                min: None,
278                tolerance: None,
279                km: None,
280                parts: None,
281                approximate: false,
282            },
283            Predicate::FieldEqual { field } => PredicateSummary {
284                kind: "field_equal".into(),
285                fields: vec![field.clone()],
286                min: None,
287                tolerance: None,
288                km: None,
289                parts: None,
290                approximate: false,
291            },
292            Predicate::Overlap { field, min } => PredicateSummary {
293                kind: "overlap".into(),
294                fields: vec![field.clone()],
295                min: Some(*min),
296                tolerance: None,
297                km: None,
298                parts: None,
299                approximate: false,
300            },
301            Predicate::NumericWithin { field, tolerance } => PredicateSummary {
302                kind: "numeric_within".into(),
303                fields: vec![field.clone()],
304                min: None,
305                tolerance: Some(*tolerance),
306                km: None,
307                parts: None,
308                approximate: false,
309            },
310            Predicate::GeoRadius { field, km } => PredicateSummary {
311                kind: "geo_radius".into(),
312                fields: vec![field.clone()],
313                min: None,
314                tolerance: None,
315                km: Some(*km),
316                parts: None,
317                approximate: false,
318            },
319            Predicate::VectorSimilar { field, min } => PredicateSummary {
320                kind: "vector_similar".into(),
321                fields: vec![field.clone()],
322                min: Some(*min),
323                tolerance: None,
324                km: None,
325                parts: None,
326                approximate: false,
327            },
328            Predicate::All(inner) => {
329                let parts: Vec<PredicateSummary> = inner.iter().map(Self::from).collect();
330                let mut fields = Vec::new();
331                for part in &parts {
332                    for f in &part.fields {
333                        if !fields.contains(f) {
334                            fields.push(f.clone());
335                        }
336                    }
337                }
338                PredicateSummary {
339                    kind: "all".into(),
340                    fields,
341                    min: None,
342                    tolerance: None,
343                    km: None,
344                    parts: Some(parts),
345                    approximate: false,
346                }
347            }
348            Predicate::Any(inner) => {
349                let parts: Vec<PredicateSummary> = inner.iter().map(Self::from).collect();
350                let mut fields = Vec::new();
351                for part in &parts {
352                    for f in &part.fields {
353                        if !fields.contains(f) {
354                            fields.push(f.clone());
355                        }
356                    }
357                }
358                PredicateSummary {
359                    kind: "any".into(),
360                    fields,
361                    min: None,
362                    tolerance: None,
363                    km: None,
364                    parts: Some(parts),
365                    approximate: false,
366                }
367            }
368        }
369    }
370}
371
372/// Snapshot of a live node's key, label, and columnar properties.
373///
374/// `props` is a [`BTreeMap`] so field order is deterministic (sorted by name)
375/// regardless of insert order or the columnar store's `HashMap` iteration.
376///
377/// Deliberately does not derive `Serialize`: `Value`'s serde form is
378/// internally tagged. Wire JSON is built by `value_to_json` in the server.
379#[derive(Debug, Clone, PartialEq)]
380pub struct NodeInfo {
381    pub key: String,
382    pub label: String,
383    pub props: BTreeMap<String, Value>,
384}
385
386/// Counts returned by [`GraphDb::delete_node`].
387#[derive(Debug, Clone, PartialEq, Eq, Default)]
388pub struct DeleteReport {
389    /// Number of manual (user-inserted) edges removed.
390    pub manual_edges: u64,
391    /// Number of derived (rule-owned) edges retracted.
392    pub derived_edges: u64,
393}
394
395/// One directed edge incident on a node, with provenance membership.
396///
397/// `derived` is true iff `(edge_type, src, dst)` is in the rule engine's
398/// Plan-8 `by_node` provenance index.
399#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
400pub struct EdgeInfo {
401    pub edge_type: String,
402    pub src_key: String,
403    pub dst_key: String,
404    pub derived: bool,
405}
406
407/// An edge with mask-aware endpoint visibility.
408///
409/// Returned by [`GraphDb::node_edges_masked`] in [`crate::mask::MaskMode::Stub`]
410/// mode — hidden endpoints carry `*_restricted: true`.
411#[derive(Debug, Clone, PartialEq, Eq)]
412pub struct MaskedEdge {
413    pub edge_type: String,
414    pub src_key: String,
415    /// `true` when `src_key` is in the DB but hidden from the mask.
416    pub src_restricted: bool,
417    pub dst_key: String,
418    /// `true` when `dst_key` is in the DB but hidden from the mask.
419    pub dst_restricted: bool,
420    pub derived: bool,
421}
422
423/// Result of a mask-aware node lookup via [`GraphDb::node_info_masked`].
424///
425/// `None` from that method means the key does not exist (→ 404).
426/// `Some(Restricted)` is only produced when `mask.mode() == MaskMode::Stub`.
427#[derive(Debug, PartialEq)]
428pub enum MaskedNodeResult {
429    Visible(NodeInfo),
430    /// Node exists in the DB but is hidden from this mask.
431    Restricted,
432}
433
434/// One rule-owned edge between two nodes, with the rule name, edge type,
435/// direction (src_key → dst_key), and weight if the rule stores one.
436#[derive(Debug, Clone, PartialEq, Serialize)]
437pub struct Explanation {
438    pub rule: String,
439    pub edge_type: String,
440    pub src_key: String,
441    pub dst_key: String,
442    pub weight: Option<f64>,
443    pub predicate: PredicateSummary,
444}
445
446/// Report returned by [`GraphDb::backup_to`].
447#[derive(Debug, Clone)]
448pub struct BackupReport {
449    /// Filenames copied into the destination directory (sorted ascending).
450    pub files: Vec<String>,
451    /// Total bytes written across all copied files.
452    pub bytes: u64,
453    /// `true` when the destination opened cleanly and passed post-copy checks.
454    ///
455    /// For stores that have a `snapshot.bin` this means: all V8 section CRCs
456    /// matched **and** the destination opened without error.
457    ///
458    /// For WAL-only stores (no `snapshot.bin`) there is no snapshot to
459    /// CRC-check; `verified` is `true` when the destination opened and
460    /// replayed the WAL without error (record-level checksums in the WAL
461    /// provide the integrity signal, not section CRCs).
462    pub verified: bool,
463}
464
465/// One directed edge in export form, with optional rule attribution for derived edges.
466///
467/// Returned by [`GraphDb::all_edges_for_export`].
468#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
469pub struct ExportEdge {
470    pub edge_type: String,
471    pub src: String,
472    pub dst: String,
473    pub derived: bool,
474    /// Rule name that created this edge, if derived. `None` for manual edges.
475    pub rule: Option<String>,
476}
477
478/// Construct the standard write-query result set (columns: created, properties_set, deleted).
479fn write_result_set() -> ResultSet {
480    ResultSet::new(vec![
481        "created".into(),
482        "properties_set".into(),
483        "deleted".into(),
484    ])
485}
486
487fn resolve_merge_set_value(op: &Operand, params: &BTreeMap<String, Value>) -> Result<Value> {
488    match op {
489        Operand::Lit(v) => Ok(v.clone()),
490        Operand::Param(name) => params
491            .get(name)
492            .cloned()
493            .ok_or_else(|| GraphError::QueryError {
494                detail: format!("missing parameter `{name}`"),
495            }),
496        _ => Err(GraphError::QueryError {
497            detail: "ON CREATE/ON MATCH SET value must be a literal or $parameter".into(),
498        }),
499    }
500}
501
502fn operand_node_vars(op: &Operand, out: &mut Vec<String>) {
503    match op {
504        Operand::Prop { var, .. } | Operand::Var(var) => {
505            if !out.contains(var) {
506                out.push(var.clone());
507            }
508        }
509        Operand::FuncCall { args, .. } => {
510            for arg in args {
511                operand_node_vars(arg, out);
512            }
513        }
514        Operand::BinArith { left, right, .. } => {
515            operand_node_vars(left, out);
516            operand_node_vars(right, out);
517        }
518        Operand::Case { branches, default } => {
519            // Branch conditions reference vars already bound (and mask-filtered)
520            // by the MATCH phase, so collecting from the value operands + ELSE
521            // is sufficient for RETURN-projection var discovery.
522            for (_, value) in branches {
523                operand_node_vars(value, out);
524            }
525            if let Some(d) = default {
526                operand_node_vars(d, out);
527            }
528        }
529        Operand::Lit(_) | Operand::Param(_) => {}
530    }
531}
532
533fn ret_node_vars(items: &[RetItem]) -> Vec<String> {
534    let mut out = Vec::new();
535    for item in items {
536        match &item.value {
537            RetVal::Var(v) | RetVal::Prop { var: v, .. } => {
538                if !out.contains(v) {
539                    out.push(v.clone());
540                }
541            }
542            RetVal::FuncCall { args, .. } => {
543                for arg in args {
544                    operand_node_vars(arg, &mut out);
545                }
546            }
547            RetVal::ScalarExpr(op) => operand_node_vars(op, &mut out),
548            RetVal::Agg { .. } => {}
549        }
550    }
551    out
552}
553
554fn add_var(out: &mut Vec<String>, v: &str) {
555    if !out.iter().any(|x| x == v) {
556        out.push(v.to_string());
557    }
558}
559
560fn pattern_node_vars(pats: &[Pattern]) -> Vec<String> {
561    let mut out = Vec::new();
562    for p in pats {
563        if let Some(v) = &p.start.var {
564            add_var(&mut out, v);
565        }
566        for (_, dest) in &p.chain {
567            if let Some(v) = &dest.var {
568                add_var(&mut out, v);
569            }
570        }
571    }
572    out
573}
574
575fn pattern_rel_vars(pats: &[Pattern]) -> Vec<String> {
576    let mut out = Vec::new();
577    for p in pats {
578        for (rel, _) in &p.chain {
579            if rel.hops.is_none() {
580                if let Some(v) = &rel.var {
581                    add_var(&mut out, v);
582                }
583            }
584        }
585    }
586    out
587}
588
589fn rel_type_alias(var: &str) -> String {
590    format!("__rt_{var}")
591}
592
593fn ret_column_name(item: &RetItem) -> String {
594    if let Some(alias) = &item.alias {
595        return alias.clone();
596    }
597    match &item.value {
598        RetVal::Var(v) => v.clone(),
599        RetVal::Prop { var, field } => format!("{var}.{field}"),
600        RetVal::FuncCall { name, args } => {
601            let arg_strs: Vec<String> = args
602                .iter()
603                .map(|a| match a {
604                    Operand::Var(v) => v.clone(),
605                    Operand::Prop { var, field } => format!("{var}.{field}"),
606                    Operand::Lit(_) => "<lit>".to_string(),
607                    Operand::Param(p) => format!("${p}"),
608                    Operand::FuncCall { name: n, .. } => format!("{n}(...)"),
609                    Operand::BinArith { .. } => "<arith>".to_string(),
610                    Operand::Case { .. } => "<case>".to_string(),
611                })
612                .collect();
613            format!("{name}({})", arg_strs.join(", "))
614        }
615        RetVal::ScalarExpr(_) => "<expr>".to_string(),
616        RetVal::Agg { .. } => "<agg>".to_string(),
617    }
618}
619
620fn eval_set_return_operand<F: Fs>(
621    db: &GraphDb<F>,
622    match_rs: &ResultSet,
623    row: usize,
624    rel_vars: &[String],
625    op: &Operand,
626    params: &BTreeMap<String, Value>,
627) -> Result<Option<Value>> {
628    match op {
629        Operand::Lit(v) => Ok(Some(v.clone())),
630        Operand::Param(name) => params.get(name).cloned().ok_or_else(|| GraphError::QueryError {
631            detail: format!("missing parameter `{name}`"),
632        }).map(Some),
633        Operand::Var(name) if rel_vars.iter().any(|r| r == name) => Err(GraphError::QueryError {
634            detail: format!(
635                "cannot return relationship variable '{name}' bare; return its properties ({name}.field) instead"
636            ),
637        }),
638        Operand::Var(name) => Ok(match_rs.get(row, name).cloned()),
639        Operand::Prop { var, field } => {
640            if rel_vars.iter().any(|r| r == var) {
641                return Ok(None);
642            }
643            let Some(Value::Str(key)) = match_rs.get(row, var) else {
644                return Ok(None);
645            };
646            Ok(db.get_prop(key, field))
647        }
648        Operand::FuncCall { name, args } => {
649            eval_set_return_func(db, match_rs, row, rel_vars, name, args, params)
650        }
651        Operand::BinArith { op, left, right } => {
652            let lv = eval_set_return_operand(db, match_rs, row, rel_vars, left, params)?;
653            let rv = eval_set_return_operand(db, match_rs, row, rel_vars, right, params)?;
654            eval_set_return_arith(op, lv, rv)
655        }
656        // CASE is supported in read-query RETURN; in a write-statement RETURN
657        // projection (CREATE/MERGE/SET … RETURN) it is not yet wired.
658        Operand::Case { .. } => Err(GraphError::QueryError {
659            detail: "CASE is not supported in a write-statement RETURN projection; \
660                     use a read query"
661                .into(),
662        }),
663    }
664}
665
666fn eval_set_return_arith(
667    op: &ArithOp,
668    lv: Option<Value>,
669    rv: Option<Value>,
670) -> Result<Option<Value>> {
671    match (lv, rv) {
672        (None, _) | (_, None) => Ok(None),
673        (Some(Value::Int(a)), Some(Value::Int(b))) => {
674            let result = match op {
675                ArithOp::Sub => a.saturating_sub(b),
676                ArithOp::Mul => a.saturating_mul(b),
677                ArithOp::Add => a.saturating_add(b),
678                ArithOp::Div => {
679                    if b == 0 {
680                        return Err(GraphError::QueryError {
681                            detail: "division by zero".into(),
682                        });
683                    }
684                    a.checked_div(b).unwrap_or(i64::MAX)
685                }
686            };
687            Ok(Some(Value::Int(result)))
688        }
689        (Some(lv), Some(rv)) => {
690            let a = match &lv {
691                Value::Float(f) => *f,
692                Value::Int(i) => *i as f64,
693                _ => {
694                    return Err(GraphError::QueryError {
695                        detail: format!("arithmetic operand must be numeric, got {lv:?}"),
696                    })
697                }
698            };
699            let b = match &rv {
700                Value::Float(f) => *f,
701                Value::Int(i) => *i as f64,
702                _ => {
703                    return Err(GraphError::QueryError {
704                        detail: format!("arithmetic operand must be numeric, got {rv:?}"),
705                    })
706                }
707            };
708            let result = match op {
709                ArithOp::Sub => a - b,
710                ArithOp::Mul => a * b,
711                ArithOp::Add => a + b,
712                ArithOp::Div => {
713                    if b == 0.0 {
714                        return Err(GraphError::QueryError {
715                            detail: "division by zero".into(),
716                        });
717                    }
718                    a / b
719                }
720            };
721            Ok(Some(Value::Float(result)))
722        }
723    }
724}
725
726fn eval_set_return_func<F: Fs>(
727    db: &GraphDb<F>,
728    match_rs: &ResultSet,
729    row: usize,
730    rel_vars: &[String],
731    name: &str,
732    args: &[Operand],
733    params: &BTreeMap<String, Value>,
734) -> Result<Option<Value>> {
735    let norm = name.to_ascii_lowercase();
736    if norm == "type" {
737        if args.len() != 1 {
738            return Err(GraphError::QueryError {
739                detail: format!("type() requires exactly 1 argument, got {}", args.len()),
740            });
741        }
742        let Operand::Var(rel) = &args[0] else {
743            return Err(GraphError::QueryError {
744                detail: "type() argument must be a relationship variable (e.g. type(r))".into(),
745            });
746        };
747        return Ok(match_rs.get(row, &rel_type_alias(rel)).cloned());
748    }
749    let mut vals = Vec::with_capacity(args.len());
750    for arg in args {
751        vals.push(eval_set_return_operand(
752            db, match_rs, row, rel_vars, arg, params,
753        )?);
754    }
755    match norm.as_str() {
756        "tolower" => {
757            if vals.len() != 1 {
758                return Err(GraphError::QueryError {
759                    detail: format!("toLower() requires exactly 1 argument, got {}", vals.len()),
760                });
761            }
762            Ok(vals[0].clone().map(|val| match val {
763                Value::Str(s) => Value::Str(s.to_ascii_lowercase()),
764                other => other,
765            }))
766        }
767        "toupper" => {
768            if vals.len() != 1 {
769                return Err(GraphError::QueryError {
770                    detail: format!("toUpper() requires exactly 1 argument, got {}", vals.len()),
771                });
772            }
773            Ok(vals[0].clone().map(|val| match val {
774                Value::Str(s) => Value::Str(s.to_ascii_uppercase()),
775                other => other,
776            }))
777        }
778        "size" => match vals.first().cloned().flatten() {
779            None => Ok(None),
780            Some(Value::Str(s)) => Ok(Some(Value::Int(s.len() as i64))),
781            Some(Value::List(items)) => Ok(Some(Value::Int(items.len() as i64))),
782            Some(_) => Ok(None),
783        },
784        "coalesce" => Ok(vals.into_iter().flatten().next()),
785        "abs" => match vals.first().cloned().flatten() {
786            None => Ok(None),
787            Some(Value::Int(n)) => Ok(Some(Value::Int(n.saturating_abs()))),
788            Some(Value::Float(f)) => Ok(Some(Value::Float(f.abs()))),
789            Some(_) => Ok(None),
790        },
791        "round" => match vals.first().cloned().flatten() {
792            None => Ok(None),
793            Some(Value::Float(f)) => Ok(Some(Value::Float(f.round()))),
794            Some(Value::Int(n)) => Ok(Some(Value::Int(n))),
795            Some(_) => Ok(None),
796        },
797        _ => Err(GraphError::QueryError {
798            detail: format!(
799                "unknown function `{name}`; supported: toLower, toUpper, size, coalesce, type, abs, round, textMatches"
800            ),
801        }),
802    }
803}
804
805fn eval_set_return_item<F: Fs>(
806    db: &GraphDb<F>,
807    match_rs: &ResultSet,
808    row: usize,
809    rel_vars: &[String],
810    item: &RetItem,
811    params: &BTreeMap<String, Value>,
812) -> Result<Option<Value>> {
813    match &item.value {
814        RetVal::Var(v) => eval_set_return_operand(
815            db,
816            match_rs,
817            row,
818            rel_vars,
819            &Operand::Var(v.clone()),
820            params,
821        ),
822        RetVal::Prop { var, field } => eval_set_return_operand(
823            db,
824            match_rs,
825            row,
826            rel_vars,
827            &Operand::Prop {
828                var: var.clone(),
829                field: field.clone(),
830            },
831            params,
832        ),
833        RetVal::FuncCall { name, args } => {
834            eval_set_return_func(db, match_rs, row, rel_vars, name, args, params)
835        }
836        RetVal::ScalarExpr(op) => eval_set_return_operand(db, match_rs, row, rel_vars, op, params),
837        RetVal::Agg { .. } => Err(GraphError::QueryError {
838            detail: "aggregates are not supported in MATCH … SET … RETURN".into(),
839        }),
840    }
841}
842
843/// Project user RETURN from original MATCH rows after SET. No rematch.
844fn project_set_return_rows<F: Fs>(
845    db: &GraphDb<F>,
846    rel_vars: &[String],
847    match_rs: &ResultSet,
848    returns: &[RetItem],
849    params: &BTreeMap<String, Value>,
850) -> Result<ResultSet> {
851    let columns: Vec<String> = returns.iter().map(ret_column_name).collect();
852    let mut out = ResultSet::new(columns);
853    for row in 0..match_rs.len() {
854        let mut cells = Vec::with_capacity(returns.len());
855        for item in returns {
856            cells.push(eval_set_return_item(
857                db, match_rs, row, rel_vars, item, params,
858            )?);
859        }
860        out.push_row(cells);
861    }
862    Ok(out)
863}
864
865/// Single construction point for a `GraphMut` view over the split-borrowed graph fields.
866/// Callers use `std::mem::take` on the engine before calling this, then restore it after.
867/// Extract a `Vec<f64>` from a `Value::List` whose items are all numeric.
868/// Returns `None` for non-list values or lists with non-numeric elements.
869fn value_as_float_list(v: &Value) -> Option<Vec<f64>> {
870    match v {
871        Value::List(items) => items
872            .iter()
873            .map(|item| match item {
874                Value::Float(f) => Some(*f),
875                Value::Int(i) => Some(*i as f64),
876                _ => None,
877            })
878            .collect(),
879        _ => None,
880    }
881}
882
883fn make_graph_mut<'a>(
884    ids: &'a IdMap,
885    syms: &'a mut Interner,
886    labels: &'a [u32],
887    props: core_storage::v8::seam::ColumnsView<'a>,
888    topo: &'a mut Topology,
889    edge_props: &'a mut EdgeProps,
890) -> GraphMut<'a> {
891    GraphMut {
892        ids,
893        syms,
894        labels,
895        props,
896        topo,
897        edge_props,
898    }
899}
900
901/// Build a `ColumnsView` from the disjoint `props` overlay and optional V8 base.
902///
903/// Takes explicit field references rather than `&self` so the caller can hold
904/// simultaneous mutable borrows of other fields (e.g. `syms`, `topo`).
905fn build_props_view<'a>(
906    props: &'a ColumnStore,
907    base: &'a Option<std::sync::Arc<core_storage::v8::MappedBase>>,
908) -> core_storage::v8::seam::ColumnsView<'a> {
909    match base {
910        None => core_storage::v8::seam::ColumnsView::owned(props),
911        Some(b) => {
912            let archived = b
913                .columns()
914                .expect("base columns section bounds validated at open");
915            core_storage::v8::seam::ColumnsView::with_base(props, archived)
916        }
917    }
918}
919
920fn build_topo_view<'a>(
921    overlay: &'a Topology,
922    base: &'a Option<std::sync::Arc<core_storage::v8::MappedBase>>,
923) -> core_storage::v8::seam::TopologyView<'a> {
924    match base {
925        None => core_storage::v8::seam::TopologyView::owned(overlay),
926        Some(b) => {
927            let archived_csr = b
928                .topology()
929                .expect("base topology section bounds validated at open");
930            core_storage::v8::seam::TopologyView::with_base(overlay, archived_csr)
931        }
932    }
933}
934
935/// When [`GraphDb`] calls `Fs::sync` after a WAL append.
936///
937/// Default is [`Strict`](FsyncPolicy::Strict): every `log_then_apply_with`
938/// fsyncs (single `insert_node` / `set_prop`). Ingest and `write_batch`
939/// emit one `WalRecord::Batch` and fsync once at that frame (Batched).
940/// [`Relaxed`](FsyncPolicy::Relaxed) skips WAL sync; [`GraphDb::snapshot`]
941/// is still durable via `write_atomic`. Crash-recovery DST stays Strict.
942#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
943pub enum FsyncPolicy {
944    /// Every WAL commit calls `fs.sync` (today's behavior).
945    #[default]
946    Strict,
947    /// Sync only at a `Batch` frame end. Single-op path stays Strict unless
948    /// this policy is set on the database.
949    Batched,
950    /// Never call `fs.sync`. [`GraphDb::snapshot`] still syncs via `write_atomic`.
951    Relaxed,
952}
953
954/// A precondition for a compare-and-set batch write.
955///
956/// All preconditions in a [`GraphDb::write_batch_cas`] or
957/// [`crate::SharedDb::submit_batch_cas`] call are checked atomically before
958/// any operation in the batch is applied.  If any precondition fails, the
959/// entire batch is rejected with [`GraphError::CasConflict`] and no WAL frame
960/// is written.
961///
962/// # Touch definition
963///
964/// A node's last-change commit (`last_changed`) is updated when any of the
965/// following state-changing WAL records touch it:
966///
967/// - `InsertNode` / `InsertNodeId` — the newly-inserted node.
968/// - `SetProp` / `SetPropId` / `RemoveProp` — the property-bearing node.
969/// - `InsertEdge` / `InsertEdgeId` / `DeleteEdge` — **both** src and dst
970///   endpoints (an edge change touches both sides).
971/// - `DeleteNode` — the node is tombstoned; `last_changed` returns `None`
972///   for deleted keys so the pre-deletion entry is never observed.
973///
974/// History markers (`DerivedEdgeAdded` / `DerivedEdgeRetracted`) are
975/// state no-ops.  The underlying mutation that triggered rule firing already
976/// updated the relevant nodes' last-change entries.  Rule-management records
977/// (`CreateRule`, `DeleteRule`, `RebuildRule`) and view/full-text declarations
978/// do not touch any node's last-change.
979#[derive(Debug, Clone, PartialEq, Eq)]
980pub enum Precondition {
981    /// The node's last-change commit must equal `expected`.
982    ///
983    /// Fails with [`GraphError::CasConflict`] when:
984    /// - The node does not exist (`last_changed` returns `None`), or
985    /// - The recorded commit seq does not match `expected`.
986    NodeUnchangedSince { key: String, expected: u64 },
987    /// The node must not exist (not inserted, or already deleted).
988    ///
989    /// Fails with [`GraphError::CasConflict`] (expected=`u64::MAX`,
990    /// actual=`last_changed(key).unwrap_or(0)`) when the node is live.
991    NodeAbsent { key: String },
992}
993
994pub struct GraphDb<F: Fs> {
995    fs: F,
996    ids: IdMap,
997    syms: Interner,
998    topo: Topology,
999    props: ColumnStore,
1000    labels: Vec<u32>, // node id -> label symbol
1001    edge_props: EdgeProps,
1002    engine: RuleEngine,
1003    view_store: ViewStore,
1004    /// Incremental inverted index for full-text-lite search.
1005    /// Rebuild-on-open: populated from WAL replay + rebuild_all at open end.
1006    fulltext: FulltextIndex,
1007    /// Opt-in equality index over scalar node properties.
1008    /// Rebuild-on-open: declarations replay from the WAL, postings rebuild at
1009    /// open end (mirrors `fulltext`).
1010    prop_index: PropertyIndex,
1011    event_sink: Option<Box<dyn Fn(MutationEvent) + Send + Sync>>,
1012    /// WAL fsync cadence. Default [`FsyncPolicy::Strict`].
1013    fsync: FsyncPolicy,
1014    /// Monotonically increasing per-commit counter.  A single `log_then_apply_with`
1015    /// call increments this once; all events emitted from that call share the same
1016    /// `commit_seq` value.
1017    commit_seq: u64,
1018    /// RBAC role definitions loaded from `roles.json` at open.
1019    ///
1020    /// `Some(roles)` — loaded successfully (may be empty when no roles are defined).
1021    /// `None` — `roles.json` was present but corrupt; `mask_for_role` returns
1022    /// `Err` for any request (fail-loud, never silently grant empty visibility).
1023    roles: Option<Vec<RoleDef>>,
1024    /// Live subscriptions.  Entries with a dead `Weak` are pruned on the next
1025    /// distribute_events call.
1026    subscriptions: Vec<SubEntry>,
1027    /// Live query subscriptions. Re-executed on every commit when non-empty.
1028    /// Dead `Weak` entries are pruned inside `distribute_events`.
1029    query_subscriptions: Vec<QuerySubEntry>,
1030    /// Queue capacity for new subscriptions created by this db.  Default is
1031    /// [`DEFAULT_SUB_CAPACITY`]; can be overridden via [`set_sub_capacity`]
1032    /// to test Lagged behaviour with small queues.
1033    sub_capacity: usize,
1034    /// True for as-of instances opened via [`GraphDb::open_at`].
1035    /// Every mutation method and `snapshot()` returns [`GraphError::ReadOnly`]
1036    /// when this flag is set.
1037    read_only: bool,
1038    /// Total WAL commit count at the time [`open_at`] was called.
1039    /// 0 for normal (non-as-of) instances.
1040    total_wal_commits: u64,
1041    /// Immutable mmap-backed base snapshot (V8).  When `Some`, `self.topo` is
1042    /// the WAL-replay overlay (empty at open time, populated by apply()) and
1043    /// reads go through a merged `TopologyView`.  `self.props` is always
1044    /// fully materialized (base + WAL replay) for HNSW/IVF and view compat.
1045    base: Option<Arc<core_storage::v8::MappedBase>>,
1046    // ── MVCC epoch reader state ───────────────────────────────────────────────
1047    /// Most-recent full overlay clone.  Initialized at end of `open_with` /
1048    /// `open_at_with`; refreshed every `FOLD_EVERY_K` commits.
1049    /// `None` only between struct creation and the first fold.
1050    fold_overlay: Option<Arc<crate::reader::FrozenOverlay>>,
1051    /// Per-commit deltas accumulated since the last fold.
1052    delta_tail: Vec<Arc<crate::reader::CommitDelta>>,
1053    /// How many commits have occurred since the last fold.
1054    commits_since_fold: usize,
1055    /// When true, `log_then_apply_with` buffers event notifications instead of
1056    /// firing them immediately.  Used by the group-commit drain thread to defer
1057    /// events until after the group fsync (R2: durability before notification).
1058    /// Cleared to false once the drain thread flushes or discards the buffer.
1059    defer_events: bool,
1060    /// Buffered events accumulated while `defer_events` is true.
1061    deferred_events: Vec<DeferredEvent>,
1062    /// Set to true by the group-commit drain thread when a group fsync fails
1063    /// after WAL truncation.  All subsequent mutation attempts return an IO
1064    /// error until the database is reopened.
1065    degraded: bool,
1066    /// Set to `true` after `ensure_v8_base_sections_loaded` has read provenance,
1067    /// HNSW, and IVF sections from the mmap base into the engine's retained
1068    /// fields.  `false` on all opens until first use; always `true` for non-V8
1069    /// opens (base is None, fast-path sets flag immediately).
1070    v8_sections_loaded: std::sync::atomic::AtomicBool,
1071    /// Serializes the one-time section population in `ensure_v8_base_sections_loaded`.
1072    v8_sections_mutex: std::sync::Mutex<()>,
1073    /// Per-node last-change commit sequence.  `last_change[node_id] = seq` means
1074    /// the node was last modified by commit `seq`.
1075    ///
1076    /// Loaded from V8 section 11 at open; updated on every state-changing commit
1077    /// and WAL replay frame.  V5-V7 stores start with an empty map; pre-WAL-horizon
1078    /// nodes return `None` from `last_changed` until they are next mutated.
1079    ///
1080    /// See [`Precondition`] for the full touch definition.
1081    last_change: HashMap<u32, u64>,
1082    /// WAL archive retention policy set by [`set_wal_archive_retention`].
1083    /// `None` = unlimited (keep all archives); `Some(N)` = keep N newest archives,
1084    /// pruning older ones at snapshot time.  0 is treated as unlimited.
1085    wal_archive_retention: Option<u32>,
1086    /// Global frame index of the first commit that is still reachable through
1087    /// surviving archives.  Persisted to `wal.floor` sidecar when pruning occurs.
1088    /// Default 0 = all history reachable.
1089    wal_horizon_floor: u64,
1090    /// True when the surviving archive chain forms a continuous WAL history
1091    /// starting from the store's first commit (the genesis chain).
1092    ///
1093    /// `open_at` may replay archive-resident commits from empty state only when
1094    /// this flag is true AND `wal_horizon_floor == 0`.  Cleared whenever:
1095    ///   - a WAL-truncating snapshot (`keep_wal=false`) is taken after archives
1096    ///     already exist (breaks the chain for subsequent archives), or
1097    ///   - any archive is pruned (floor advances past zero).
1098    ///
1099    /// Persisted via the `wal.genesis` marker file; loaded from it at open.
1100    archive_genesis_chain: bool,
1101    /// Transient write-authz context set by `write_batch_authz` /
1102    /// `query_write_authz` for the duration of ONE mutation call.
1103    /// Always `None` at rest.  Never serialized, never WAL-replayed.
1104    pending_write_authz: Option<WriteAuthz>,
1105}
1106
1107/// One group of deferred event notifications, held until the group fsync
1108/// completes.  Replayed by [`GraphDb::flush_deferred_events`].
1109struct DeferredEvent {
1110    rec: core_storage::WalRecord,
1111    engine_deltas: Vec<EngineEdgeDelta>,
1112    seq: u64,
1113    ingest: Option<(String, usize)>,
1114}
1115
1116/// Options for [`GraphDb::open_with_options`].
1117#[derive(Clone, Copy, Debug)]
1118pub struct OpenOptions {
1119    /// Rewrite an old-format snapshot to the current VERSION after a
1120    /// successful load (default `true`). The old snapshot is kept as
1121    /// `snapshot.bin.bak` until the next clean open at the current version,
1122    /// at which point the `.bak` is deleted.
1123    ///
1124    /// Set to `false` to open a store without touching any on-disk files
1125    /// (useful for read-only inspection of a store at an older format).
1126    pub auto_migrate: bool,
1127}
1128
1129impl Default for OpenOptions {
1130    fn default() -> Self {
1131        Self { auto_migrate: true }
1132    }
1133}
1134
1135/// Authorization context carried by `write_batch_authz` / `query_write_authz`.
1136///
1137/// `None` at the call site = full authority (today's zero-cost behavior).
1138/// `Some(WriteAuthz)` = role-scoped: the decision table (plan §"authz decision
1139/// table") is evaluated per-op inside `commit_logged_batch` BEFORE any WAL
1140/// record is built.  A denial returns an error with no WAL frame written.
1141///
1142/// The mask is ALWAYS `Omit`-mode: role-token paths must never acknowledge
1143/// hidden-node existence to callers.
1144#[derive(Clone, Debug)]
1145pub struct WriteAuthz {
1146    pub role: String,
1147    pub scope: WriteScope,
1148    /// Resolved by `mask_for_role` under the same write guard as the mutation.
1149    /// Always `Omit`-mode — never `Stub`.
1150    pub mask: crate::mask::NodeMask,
1151}
1152
1153/// Write `bytes` to `snapshot.bin.bak` atomically with full fsync.
1154///
1155/// Uses [`RealFs::write_atomic`] which applies `F_FULLFSYNC` on macOS and
1156/// `sync_all` on other platforms, then renames the `.tmp` file into place and
1157/// syncs the directory entry. This is the only correct path for writing the
1158/// `.bak` — plain `std::fs::write + sync_all` misses both `F_FULLFSYNC` and
1159/// the directory sync.
1160pub fn write_snapshot_bak(dir: &std::path::Path, bytes: &[u8]) -> crate::Result<()> {
1161    use core_storage::fs::{FileId, Fs as _};
1162    RealFs::new(dir)
1163        .map_err(core_storage::GraphError::Io)?
1164        .write_atomic(FileId::SnapshotBak, bytes)
1165        .map_err(core_storage::GraphError::Io)
1166}
1167
1168/// Return the on-disk snapshot format version without decoding the full snapshot.
1169///
1170/// Reads only the 6-byte header (magic + version LE). Returns `None` when no
1171/// snapshot file exists (WAL-only store). Returns an error if the header is
1172/// malformed.
1173pub fn snapshot_version_at(dir: &std::path::Path) -> crate::Result<Option<u16>> {
1174    use std::io::Read as _;
1175    let path = dir.join("snapshot.bin");
1176    let mut header = [0u8; 6];
1177    let n = match std::fs::File::open(&path) {
1178        Ok(mut f) => f.read(&mut header).map_err(core_storage::GraphError::Io)?,
1179        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
1180        Err(e) => return Err(core_storage::GraphError::Io(e)),
1181    };
1182    core_storage::snapshot::peek_version(&header[..n])
1183}
1184
1185/// Options for [`GraphDb::snapshot_with`].
1186#[derive(Debug, Clone, Default)]
1187pub struct SnapshotOptions {
1188    /// When `true`, the WAL is preserved after the snapshot write.
1189    /// Pre-snapshot commits remain reachable via [`GraphDb::open_at`].
1190    /// When `false` (the default), the WAL is truncated to a minimal
1191    /// baseline so cold-start replay stays fast.
1192    pub keep_wal: bool,
1193    /// When `true`, the current WAL is renamed to `wal.<commit_seq>.archive`
1194    /// before a fresh WAL baseline is written (history-preserving snapshot).
1195    ///
1196    /// This is the feature opt-in: `false` (the default) leaves the existing
1197    /// truncation / keep-wal behaviour byte-identical.  `archive_wal` takes
1198    /// precedence over `keep_wal` when both are set.
1199    ///
1200    /// Archives can be scanned by [`GraphDb::node_history`],
1201    /// [`GraphDb::edge_history`], [`GraphDb::was_linked`], and
1202    /// [`GraphDb::open_at`], extending the reachable history horizon across
1203    /// snapshot boundaries.
1204    pub archive_wal: bool,
1205}
1206
1207impl GraphDb<RealFs> {
1208    /// Open the database at `dir` with default options.
1209    ///
1210    /// Equivalent to `open_with_options(dir, OpenOptions::default())`.
1211    /// Old-format snapshots (V5, V6) are automatically migrated to the
1212    /// current version on a successful load (see [`OpenOptions::auto_migrate`]).
1213    pub fn open(dir: &std::path::Path) -> Result<Self> {
1214        Self::open_with_options(dir, OpenOptions::default())
1215    }
1216
1217    /// Open the database at `dir` with explicit options.
1218    ///
1219    /// When `opts.auto_migrate` is `true` (the default) and the on-disk
1220    /// snapshot is an older format version, this function:
1221    ///   1. Copies the current `snapshot.bin` to `snapshot.bin.bak` (atomic
1222    ///      + fsynced) before any modification.
1223    ///   2. Rewrites `snapshot.bin` at the current format version via
1224    ///      [`GraphDb::snapshot_with`] with `keep_wal: true` (WAL preserved).
1225    ///
1226    /// If migration fails the error is returned and the original files are
1227    /// intact (the `.bak` was written before the new snapshot was attempted).
1228    ///
1229    /// A clean open that finds the snapshot already at the current version
1230    /// deletes any leftover `.bak` file.
1231    ///
1232    /// WAL-only stores (no snapshot) are never auto-migrated on open.
1233    pub fn open_with_options(dir: &std::path::Path, opts: OpenOptions) -> Result<Self> {
1234        // Header-only peek — 6 bytes, no full decode.
1235        let snap_version = snapshot_version_at(dir)?;
1236
1237        // Full load: decode snapshot + replay WAL + rebuild indexes.
1238        let mut db = Self::open_with(RealFs::new(dir)?)?;
1239
1240        if opts.auto_migrate {
1241            match snap_version {
1242                Some(ver) if ver < core_storage::snapshot::VERSION => {
1243                    let _tm = std::time::Instant::now();
1244                    // Copy the original snapshot to .bak at OS level — no in-memory
1245                    // buffer required for a 2+ GiB file.
1246                    //
1247                    // Crash-safety: snapshot.bin remains intact (write_atomic inside
1248                    // snapshot_with uses a .tmp+rename) until the V8 write succeeds.
1249                    // A torn .bak on crash is acceptable because the original
1250                    // snapshot.bin is the authoritative source until after the rename.
1251                    std::fs::copy(dir.join("snapshot.bin"), dir.join("snapshot.bin.bak"))
1252                        .map_err(core_storage::GraphError::Io)?;
1253                    trace_migrate!("bak copy done", _tm);
1254                    // Rewrite snapshot at current version; keep WAL intact.
1255                    db.snapshot_with(SnapshotOptions {
1256                        keep_wal: true,
1257                        ..SnapshotOptions::default()
1258                    })?;
1259                    trace_migrate!("snapshot_with done", _tm);
1260                }
1261                Some(_) => {
1262                    // Already current version: remove any leftover .bak.
1263                    let bak = dir.join("snapshot.bin.bak");
1264                    if bak.exists() {
1265                        std::fs::remove_file(&bak).map_err(core_storage::GraphError::Io)?;
1266                    }
1267                }
1268                None => {
1269                    // WAL-only store — nothing to migrate on open.
1270                }
1271            }
1272        }
1273
1274        Ok(db)
1275    }
1276
1277    /// Open a read-only view of the database as it existed after `commit`.
1278    ///
1279    /// Commit indices are 0-based over the current WAL: commit 0 is the state
1280    /// after the first WAL frame, commit N-1 is the state after the N-th (most
1281    /// recent) frame.  Call [`GraphDb::open`] to read the full current state.
1282    ///
1283    /// **Replay base.** [`GraphDb::snapshot`] truncates the WAL when it runs,
1284    /// so as-of can only reach commits recorded in the current WAL (those
1285    /// written after the most recent snapshot, or all commits if no snapshot
1286    /// was ever taken).  Commit 0 in `open_at` always refers to the first
1287    /// frame in the WAL that exists on disk, not the first ever write to the
1288    /// database.  When the on-disk snapshot recorded that it truncated the
1289    /// WAL (V7, default `keep_wal: false`), it is loaded as the base state
1290    /// before frame replay, so the as-of view includes all pre-snapshot data.
1291    /// Snapshots written with `keep_wal: true` (and legacy V5/V6 snapshots)
1292    /// are ignored and replay is WAL-only, as before.
1293    ///
1294    /// **Read-only.** Every mutation method and `snapshot()` on the returned
1295    /// instance returns [`GraphError::ReadOnly`].  Queries, `explain()`, and
1296    /// `stats()` work normally.
1297    ///
1298    /// # Errors
1299    /// - [`GraphError::CommitOutOfRange`] if `commit >= wal_commit_count` (including
1300    ///   when the WAL is empty after a snapshot).
1301    pub fn open_at(dir: &std::path::Path, commit: u64) -> Result<Self> {
1302        Self::open_at_with(RealFs::new(dir)?, commit)
1303    }
1304
1305    /// Run a **read-only** Cypher query against the graph as it existed at
1306    /// `commit` — the "time-travel" / agent-replay query. Opens a temporal view
1307    /// of this store's directory at that commit and executes the read there.
1308    ///
1309    /// The current instance is unaffected. Write statements are rejected (the
1310    /// temporal view is read-only). `commit` is a 0-based WAL commit index;
1311    /// `commit == wal_commit_count` (or `open_at`'s range) yields the newest
1312    /// state. Prefer this over holding many historical instances open.
1313    ///
1314    /// # Errors
1315    /// - [`GraphError::CommitOutOfRange`] if `commit` is past the WAL horizon.
1316    /// - A query error for a malformed or write query.
1317    pub fn query_at(
1318        &self,
1319        commit: u64,
1320        cypher: &str,
1321        params: &std::collections::BTreeMap<String, Value>,
1322    ) -> Result<ResultSet> {
1323        let dir = self.fs.dir().to_path_buf();
1324        let temporal = Self::open_at(&dir, commit)?;
1325        if is_write_tokens(&lex(cypher).map_err(|e| GraphError::QueryError {
1326            detail: format!("lex: {e}"),
1327        })?) {
1328            return Err(GraphError::QueryError {
1329                detail: "query_at is read-only: write statements are not permitted in a \
1330                         time-travel query"
1331                    .into(),
1332            });
1333        }
1334        temporal.query(cypher, params)
1335    }
1336}
1337
1338impl<F: Fs> GraphDb<F> {
1339    pub fn open_with(fs: F) -> Result<Self> {
1340        let mut db = Self {
1341            fs,
1342            ids: IdMap::new(),
1343            syms: Interner::new(),
1344            topo: Topology::new(),
1345            props: ColumnStore::new(),
1346            labels: Vec::new(),
1347            edge_props: EdgeProps::new(),
1348            engine: RuleEngine::new(),
1349            view_store: ViewStore::new(),
1350            fulltext: FulltextIndex::new(),
1351            prop_index: PropertyIndex::new(),
1352            event_sink: None,
1353            fsync: FsyncPolicy::Strict,
1354            commit_seq: 0,
1355            roles: Some(vec![]),
1356            subscriptions: Vec::new(),
1357            query_subscriptions: Vec::new(),
1358            sub_capacity: DEFAULT_SUB_CAPACITY,
1359            read_only: false,
1360            total_wal_commits: 0,
1361            base: None,
1362            fold_overlay: None,
1363            delta_tail: Vec::new(),
1364            commits_since_fold: 0,
1365            defer_events: false,
1366            deferred_events: Vec::new(),
1367            degraded: false,
1368            v8_sections_loaded: std::sync::atomic::AtomicBool::new(false),
1369            v8_sections_mutex: std::sync::Mutex::new(()),
1370            last_change: HashMap::new(),
1371            wal_archive_retention: None,
1372            wal_horizon_floor: 0,
1373            archive_genesis_chain: false,
1374            pending_write_authz: None,
1375        };
1376        db.wal_horizon_floor = db.fs.read_horizon_floor()?;
1377        db.archive_genesis_chain = db.fs.has_genesis_marker();
1378        // Opening cleanup: remove orphaned archives — archives whose frames all
1379        // fall below the horizon floor.  Orphans arise when a crash interrupted
1380        // the retention-prune sequence after the floor was written but before
1381        // all surplus archives were deleted.  Safe to delete: floor already
1382        // accounts for their frames.
1383        db.cleanup_orphaned_archives()?;
1384        let _t0 = std::time::Instant::now();
1385        // Peek 6 bytes to determine snapshot version without reading the full
1386        // file. For RealFs this is a true partial read (O(1)); for SimFs the
1387        // default impl reads all bytes and truncates (still correct).
1388        let snap_header = db.fs.read_prefix(FileId::Snapshot, 6)?;
1389        let is_v8 = snap_header.len() >= 6
1390            && &snap_header[0..4] == b"GDB1"
1391            && u16::from_le_bytes([snap_header[4], snap_header[5]])
1392                == core_storage::snapshot::VERSION_8;
1393        if is_v8 {
1394            // V8: map the file zero-copy (RealFs) or read full bytes (SimFs).
1395            // No 2.4GB heap Vec is allocated on RealFs.
1396            let mapped = Arc::new(
1397                if let Some(snap_path) = db.fs.snapshot_path() {
1398                    core_storage::v8::MappedBase::map(&snap_path)
1399                } else {
1400                    let snap_bytes = db.fs.read(FileId::Snapshot)?;
1401                    core_storage::v8::MappedBase::from_bytes(snap_bytes)
1402                }
1403                .map_err(|e| GraphError::Corrupt {
1404                    detail: format!("v8: mmap open: {e:?}"),
1405                })?,
1406            );
1407            db.restore_v8_base(Arc::clone(&mapped))?;
1408            trace_open!("restore_v8_base", _t0);
1409            db.base = Some(mapped);
1410            trace_open!("base assigned", _t0);
1411        } else if !snap_header.is_empty() {
1412            // Legacy V5-V7: full read required for decode.
1413            let snap_bytes = db.fs.read(FileId::Snapshot)?;
1414            if let Some(state) = core_storage::snapshot::decode(&snap_bytes)? {
1415                db.restore_snapshot_state(state)?;
1416            }
1417        }
1418        // else: snap_header is empty = no snapshot file, fresh store.
1419        //
1420        // Seed commit_seq from the highest seq persisted in last_change so that
1421        // WAL-replay frames (which start at commit_seq+1) always exceed any seq
1422        // already stored in the snapshot.  Without this, a db with one snapshot
1423        // commit would save last_change["a"]=1, then on reopen the first WAL
1424        // frame would replay at seq=1 again — colliding and making WAL-tail
1425        // mutations indistinguishable from the snapshot baseline.
1426        //
1427        // Safety invariant (seq-recycling):
1428        //   Recycled seqs (those below the seeded baseline) were NEVER stored in
1429        //   last_change because they belonged to a previous db lifetime — a new
1430        //   db starts at commit_seq=0 with an empty last_change.  Therefore no
1431        //   CAS precondition can carry a recycled seq as its `expected` value
1432        //   and accidentally match a live node's last_change entry.
1433        //
1434        // `expected:0` on a deleted-then-reinserted node:
1435        //   After deletion, last_changed() returns None; callers that call
1436        //   last_changed() and then use NodeUnchangedSince get None.unwrap_or(0)
1437        //   = 0.  The reinserted node gets seq > 0, so a subsequent CAS with
1438        //   expected=0 correctly conflicts.  The only way to observe actual=0 in
1439        //   a CasConflict would be a caller that invented expected=0 without ever
1440        //   calling last_changed() — unreachable via the documented API contract.
1441        if let Some(&max_seq) = db.last_change.values().max() {
1442            db.commit_seq = db.commit_seq.max(max_seq);
1443        }
1444        let bytes = db.fs.read(FileId::Wal)?;
1445        let (records, valid_len) = decode_all(&bytes);
1446        if valid_len < bytes.len() {
1447            db.fs.write_atomic(FileId::Wal, &bytes[..valid_len])?;
1448        }
1449        // WAL-present path: build indexes eagerly BEFORE replay so that the
1450        // first replayed record does not trigger the lazy-init guard (which
1451        // would call reindex_all_load_ivf on an empty graph, defeating the
1452        // point of restoring IVF/HNSW blobs from the snapshot).
1453        if !records.is_empty() {
1454            db.ensure_v8_base_sections_loaded();
1455            trace_open!("lazy sections loaded (WAL path)", _t0);
1456            db.engine.consume_retained_state_eager(
1457                &db.ids,
1458                &db.syms,
1459                &db.labels,
1460                build_props_view(&db.props, &db.base),
1461            );
1462        }
1463        for rec in records {
1464            db.apply(&rec)?;
1465            // Drain per-frame to keep pending_deltas O(1) during replay (I-2).
1466            // No subscriber exists yet; discard is correct.
1467            let _ = db.engine.drain_deltas();
1468            // Track commit_seq during replay so last_change entries are
1469            // consistent with the seqs assigned by log_then_apply_with on
1470            // subsequent live commits.  After N replayed frames, commit_seq=N;
1471            // live commits begin at N+1.
1472            db.commit_seq += 1;
1473            let replay_seq = db.commit_seq;
1474            db.update_last_change_from_rec(&rec, replay_seq);
1475        }
1476        // Enforce I-2: if the per-frame drain above is ever removed or skipped,
1477        // this assert catches the regression in debug builds immediately.
1478        debug_assert_eq!(
1479            db.engine.pending_delta_count(),
1480            0,
1481            "pending_deltas non-empty after replay — \
1482             per-frame drain must run inside the loop to keep memory O(1)"
1483        );
1484        // T2 note: the per-frame drain IS the suppression seam for replay.
1485        // Any future as-of replay path (Plan-15 T2) must drain here to feed
1486        // replaying subscribers; the mechanism is already in place.
1487        let _ = db.engine.drain_deltas(); // belt-and-braces no-op after loop drain
1488        trace_open!("wal replay done", _t0);
1489        // Rebuild view values after WAL replay only when there is no V8 base.
1490        // With a V8 base, view values are correct in the snapshot and are updated
1491        // incrementally during WAL replay (on_edge_changed / on_prop_changed).
1492        // A full rebuild would read overlay-only props (empty after restore_v8_base)
1493        // and overwrite correct base values with wrong results (e.g. NeighborAgg
1494        // Sum reads no "score" in overlay → writes 0.0, shadowing the correct
1495        // base value).
1496        if db.base.is_none() {
1497            let topo_view = TopologyView::owned(&db.topo);
1498            db.view_store
1499                .rebuild_all(&mut db.props, &topo_view, &db.ids, &db.syms, &db.labels);
1500        }
1501        // Rebuild full-text index after WAL replay.  Corrects drift from
1502        // per-record incremental apply during replay.
1503        db.fulltext.rebuild_all(
1504            &db.ids,
1505            &db.labels,
1506            &db.syms,
1507            build_props_view(&db.props, &db.base),
1508        );
1509        db.prop_index.rebuild_all(
1510            &db.ids,
1511            &db.labels,
1512            &db.syms,
1513            build_props_view(&db.props, &db.base),
1514        );
1515        // Load roles sidecar. Missing file = no roles (Some(vec![])).
1516        // Corrupt/unparseable = poisoned (None); mask_for_role will fail-loud.
1517        db.roles = Self::load_roles_from_fs(&db.fs)?;
1518        // Capture the initial MVCC fold so reader() is ready immediately.
1519        db.fold_now();
1520        trace_open!("open_with complete", _t0);
1521        Ok(db)
1522    }
1523
1524    /// As-of replay for [`GraphDb::open_at`]: snapshot base (only when the
1525    /// snapshot truncated the WAL) plus the first `commit + 1` WAL frames;
1526    /// see [`GraphDb::open_at`] for the semantics.  The per-frame drain
1527    /// mirrors `open_with` exactly so pending_delta_count is 0 on exit.
1528    /// Restore all persisted state from a decoded snapshot. Shared by
1529    /// `open_with` and (when the snapshot truncated the WAL) `open_at_with`.
1530    fn restore_snapshot_state(
1531        &mut self,
1532        state: core_storage::snapshot::SnapshotState,
1533    ) -> Result<()> {
1534        self.ids = state.ids;
1535        self.syms = state.syms;
1536        self.topo = state.topo;
1537        self.props = state.props;
1538        self.labels = state.labels;
1539        self.edge_props = state.edge_props;
1540        // Cross-section label integrity for V5/V7 snapshots: same invariants as
1541        // restore_v8_base.  A crafted bincode snapshot with a short `labels` vec,
1542        // out-of-range sym ids, or a sentinel label on a live node would otherwise
1543        // open successfully and panic later in `NodeRef::label()` or
1544        // `neighborhood_masked()`.  Catching it here turns those into typed
1545        // `GraphError::Corrupt` at open time.
1546        {
1547            let ids_len = self.ids.len();
1548            if self.labels.len() != ids_len {
1549                return Err(GraphError::Corrupt {
1550                    detail: format!(
1551                        "snapshot: labels vec has {} entries but id table has {} total slots",
1552                        self.labels.len(),
1553                        ids_len,
1554                    ),
1555                });
1556            }
1557            let syms_len = self.syms.len() as u32;
1558            for (i, &sym) in self.labels.iter().enumerate() {
1559                let is_tombstoned = self.ids.is_tombstoned(i as u32);
1560                if sym == u32::MAX {
1561                    if !is_tombstoned {
1562                        return Err(GraphError::Corrupt {
1563                            detail: format!(
1564                                "snapshot: live node at id slot {i} has sentinel label (u32::MAX)"
1565                            ),
1566                        });
1567                    }
1568                } else if sym >= syms_len {
1569                    return Err(GraphError::Corrupt {
1570                        detail: format!(
1571                            "snapshot: label at id slot {i} references sym {sym} \
1572                             which is out of interner range ({syms_len})"
1573                        ),
1574                    });
1575                }
1576            }
1577        }
1578        let defs: Vec<RuleDef> = state
1579            .rule_defs
1580            .iter()
1581            .map(|b| {
1582                decode_rule_def(b).map_err(|e| GraphError::Corrupt {
1583                    detail: format!("snapshot rule_def deserialize: {e}"),
1584                })
1585            })
1586            .collect::<Result<Vec<_>>>()?;
1587        self.engine =
1588            RuleEngine::from_persist(defs, state.provenance, state.rule_tripped, state.rule_fires);
1589        // Candidate indexes are rebuilt lazily on the first mutation (see
1590        // RuleEngine::on_node_changed).  HNSW blobs and IVF centroids from the
1591        // snapshot are retained without deserializing so that:
1592        //   - clean-open (empty WAL): indexes stay empty; blobs load on first
1593        //     ANN query via ensure_hnsw_loaded, or on first mutation via the
1594        //     lazy-init guard which calls reindex_all_load_ivf + load_hnsw_state.
1595        //   - WAL-present: open_with calls consume_retained_state_eager before
1596        //     replay so HNSW/IVF are live before any record fires the hooks.
1597        let ivf_bytes = if state.ivf_state.is_empty() {
1598            Vec::new()
1599        } else {
1600            bincode::serialize(&state.ivf_state).expect("IVF state serialize cannot fail")
1601        };
1602        // Store blobs without eagerly deserializing them.
1603        self.engine
1604            .store_snapshot_state(state.hnsw_state, ivf_bytes);
1605        // Restore view defs from snapshot (V5).
1606        // The ColumnStore already contains view values from the snapshot;
1607        // use restore_view (no collision check, no backfill) so the store
1608        // is aware of the definitions.  rebuild_all runs after WAL replay.
1609        for def_bytes in &state.view_defs {
1610            let def: ViewDef =
1611                bincode::deserialize(def_bytes).map_err(|e| GraphError::Corrupt {
1612                    detail: format!("snapshot view_def deserialize: {e}"),
1613                })?;
1614            self.view_store
1615                .restore_view(def)
1616                .map_err(|e| GraphError::Corrupt {
1617                    detail: format!("snapshot view restore: {e}"),
1618                })?;
1619        }
1620        Ok(())
1621    }
1622
1623    /// Restore all persisted state from a V8 `MappedBase` snapshot, **except**
1624    /// topology (`self.topo` stays empty and serves as the WAL-replay overlay).
1625    ///
1626    /// `self.props` IS fully materialised from the base so that HNSW/IVF blob
1627    /// deserialization and view rebuild have access to all column data.
1628    fn restore_v8_base(&mut self, mapped: Arc<core_storage::v8::MappedBase>) -> Result<()> {
1629        self.ids = archived_to_idmap(mapped.ids().map_err(|e| GraphError::Corrupt {
1630            detail: format!("v8: ids section: {e:?}"),
1631        })?);
1632        self.syms = archived_to_interner(mapped.syms().map_err(|e| GraphError::Corrupt {
1633            detail: format!("v8: syms section: {e:?}"),
1634        })?);
1635
1636        // C1: self.props is left as an empty overlay. Column reads go through
1637        // props_view() (ColumnsView::with_base), which consults the archived base
1638        // section zero-copy. This avoids the O(columns) heap copy at every open.
1639
1640        // self.topo deliberately left as Topology::new() — overlay path.
1641
1642        let meta = decode_meta(mapped.meta_bytes().map_err(|e| GraphError::Corrupt {
1643            detail: format!("v8: meta section: {e:?}"),
1644        })?)
1645        .map_err(|e| GraphError::Corrupt {
1646            detail: format!("v8: meta decode: {e:?}"),
1647        })?;
1648        self.labels = meta.labels;
1649        // Cross-section label integrity: labels must cover every id slot (live
1650        // and tombstoned), every non-sentinel sym must be within the interner's
1651        // bound, and no live (non-tombstoned) node may carry the u32::MAX
1652        // sentinel label.  Without this check, a crafted snapshot where the META
1653        // section (small, CRC-validated) holds a short `labels` vec, out-of-range
1654        // sym ids, or a sentinel label on a live node, would open successfully
1655        // and then panic in `NodeRef::label()`, `neighborhood_masked()`, and
1656        // related read paths.  Catching the inconsistency here converts those
1657        // panics into typed `GraphError::Corrupt` at open time.
1658        {
1659            let ids_len = self.ids.len();
1660            if self.labels.len() != ids_len {
1661                return Err(GraphError::Corrupt {
1662                    detail: format!(
1663                        "v8: labels section has {} entries but id table has {} total slots",
1664                        self.labels.len(),
1665                        ids_len,
1666                    ),
1667                });
1668            }
1669            let syms_len = self.syms.len() as u32;
1670            for (i, &sym) in self.labels.iter().enumerate() {
1671                let is_tombstoned = self.ids.is_tombstoned(i as u32);
1672                if sym == u32::MAX {
1673                    // Sentinel is only valid for tombstoned slots.
1674                    if !is_tombstoned {
1675                        return Err(GraphError::Corrupt {
1676                            detail: format!(
1677                                "v8: live node at id slot {i} has sentinel label (u32::MAX)"
1678                            ),
1679                        });
1680                    }
1681                } else if sym >= syms_len {
1682                    return Err(GraphError::Corrupt {
1683                        detail: format!(
1684                            "v8: label at id slot {i} references sym {sym} \
1685                             which is out of interner range ({syms_len})"
1686                        ),
1687                    });
1688                }
1689            }
1690        }
1691        // C3: self.edge_props stays as an empty overlay.  Reads go through
1692        // edge_props_view() which consults the mmap'd base section zero-copy
1693        // via EdgePropsView::with_base.  No heap decode at open time.
1694
1695        // Restore rule engine.
1696        let (rule_def_bytes, rule_tripped, rule_fires) =
1697            archived_rules_meta_to_owned(mapped.rules_meta_section().map_err(|e| {
1698                GraphError::Corrupt {
1699                    detail: format!("v8: rules_meta section: {e:?}"),
1700                }
1701            })?);
1702        let defs: Vec<RuleDef> = rule_def_bytes
1703            .iter()
1704            .map(|b| {
1705                decode_rule_def(b).map_err(|e| GraphError::Corrupt {
1706                    detail: format!("v8: rule_def deserialize: {e}"),
1707                })
1708            })
1709            .collect::<Result<Vec<_>>>()?;
1710        self.engine = RuleEngine::from_persist(defs, BTreeMap::new(), rule_tripped, rule_fires);
1711        // C4+C5: provenance, HNSW, and IVF sections are NOT read here.
1712        // `ensure_v8_base_sections_loaded` reads them on first use from
1713        // `self.base` (set by the caller immediately after this returns).
1714        // A clean open touches only: header + IDS + SYMS + META + RULES_META.
1715
1716        // Restore view definitions.
1717        let view_defs =
1718            archived_views_to_owned(mapped.views_section().map_err(|e| GraphError::Corrupt {
1719                detail: format!("v8: views section: {e:?}"),
1720            })?);
1721        for def_bytes in &view_defs {
1722            let def: ViewDef =
1723                bincode::deserialize(def_bytes).map_err(|e| GraphError::Corrupt {
1724                    detail: format!("v8: view_def deserialize: {e}"),
1725                })?;
1726            self.view_store
1727                .restore_view(def)
1728                .map_err(|e| GraphError::Corrupt {
1729                    detail: format!("v8: view restore: {e}"),
1730                })?;
1731        }
1732        // Load the last-change map from section 11 (small section; load eagerly).
1733        // Pre-Task-3 snapshots lack this section; `last_change_bytes` returns &[]
1734        // in that case and `decode_last_change_bytes` returns an empty map.
1735        let last_change_raw = mapped
1736            .last_change_bytes()
1737            .map_err(|e| GraphError::Corrupt {
1738                detail: format!("v8: last_change section: {e:?}"),
1739            })?;
1740        self.last_change = decode_last_change_bytes(last_change_raw);
1741
1742        // Validate that all deferred sections (provenance, HNSW, IVF) fit within
1743        // the file.  Pure bounds check — no bytes read, no page faults triggered.
1744        // Catches truncated snapshots at open time before the lazy deferred reads.
1745        mapped.validate_section_bounds().map_err(|e| match e {
1746            GraphError::Corrupt { detail } => GraphError::Corrupt {
1747                detail: format!("v8: section bounds: {detail}"),
1748            },
1749            other => other,
1750        })?;
1751        Ok(())
1752    }
1753
1754    /// Read provenance, HNSW, and IVF sections from the mmap base into the
1755    /// engine's retained fields on first call.  Subsequent calls are a no-op
1756    /// (AtomicBool fast-path).
1757    ///
1758    /// Must be called before any code path that reads or mutates engine
1759    /// provenance, HNSW, or IVF state:
1760    /// - WAL replay (before `consume_retained_state_eager`)
1761    /// - First mutation (`log_then_apply_with`)
1762    /// - Read-only paths (`stats`, `explain`, `node_edges`)
1763    /// - Snapshot (`snapshot_with`)
1764    ///
1765    /// No-op for fresh stores and V5-V7 opens (`self.base` is `None`).
1766    fn ensure_v8_base_sections_loaded(&self) {
1767        use std::sync::atomic::Ordering;
1768        if self.v8_sections_loaded.load(Ordering::Acquire) {
1769            return;
1770        }
1771        let _guard = self
1772            .v8_sections_mutex
1773            .lock()
1774            .expect("v8 sections mutex poisoned");
1775        if self.v8_sections_loaded.load(Ordering::Acquire) {
1776            return; // another caller populated while we waited
1777        }
1778        let _t = std::time::Instant::now();
1779        if let Some(base) = &self.base {
1780            // Provenance: raw rkyv bytes; CRC validated inside section_bytes.
1781            // Bounds are already validated at open time (restore_v8_base →
1782            // validate_section_bounds) — unreachable post-validate_section_bounds;
1783            // unwrap_or_default is a safety belt against impossible errors.
1784            let prov_bytes = base
1785                .provenance_raw_bytes()
1786                .map(|b| b.to_vec())
1787                .unwrap_or_default();
1788            self.engine.store_provenance_bytes(prov_bytes);
1789            // HNSW: decode rkyv blobs into owned map.
1790            let hnsw_state = base
1791                .hnsw_section()
1792                .map(archived_hnsw_to_owned)
1793                .unwrap_or_default();
1794            // IVF: raw bincode bytes; deserialized on first mutation/query.
1795            let ivf_bytes = base.ivf_bytes().map(|b| b.to_vec()).unwrap_or_default();
1796            self.engine.store_snapshot_state(hnsw_state, ivf_bytes);
1797        }
1798        self.v8_sections_loaded.store(true, Ordering::Release);
1799        if std::env::var("MUSHROOMDB_TRACE_OPEN").is_ok() {
1800            eprintln!(
1801                "[MUSHROOMDB_TRACE_OPEN] ensure_v8_base_sections_loaded: {:>9.3?}",
1802                _t.elapsed()
1803            );
1804        }
1805    }
1806
1807    /// Return a `TopologyView` that merges the mmap'd base (when present) with
1808    /// the in-memory WAL overlay.  Used by all read paths in db.rs that need
1809    /// the full merged topology without going through `self.view()`.
1810    fn topo_view(&self) -> TopologyView<'_> {
1811        match self.base {
1812            None => TopologyView::owned(&self.topo),
1813            Some(ref base) => {
1814                // SAFETY: base lives as long as self; section bounds validated at open.
1815                // topology() uses access_unchecked; all field reads are bounds-checked in seam.rs.
1816                let archived = base
1817                    .topology()
1818                    .expect("base topology section bounds validated at open");
1819                TopologyView::with_base(&self.topo, archived)
1820            }
1821        }
1822    }
1823
1824    /// Return a `ColumnsView` that merges the mmap'd base columns (when a V8
1825    /// snapshot is open) with the in-memory WAL overlay.  Reads consult the
1826    /// overlay first, then fall through to the archived base section zero-copy.
1827    fn props_view(&self) -> core_storage::v8::seam::ColumnsView<'_> {
1828        match self.base {
1829            None => core_storage::v8::seam::ColumnsView::owned(&self.props),
1830            Some(ref base) => {
1831                // columns() uses access_unchecked; field reads are bounds-checked in seam.rs.
1832                let archived = base
1833                    .columns()
1834                    .expect("base columns section bounds validated at open");
1835                core_storage::v8::seam::ColumnsView::with_base(&self.props, archived)
1836            }
1837        }
1838    }
1839
1840    /// Return an `EdgePropsView` that merges the mmap'd base edge-props section
1841    /// (when a V8 snapshot is open) with the in-memory WAL overlay.
1842    ///
1843    /// Reads consult the overlay first (for post-snapshot mutations), then fall
1844    /// through to the archived base section zero-copy.  Tombstones in the
1845    /// overlay mask deleted-from-base entries.
1846    fn edge_props_view(&self) -> EdgePropsView<'_> {
1847        match self.base {
1848            None => EdgePropsView::owned(&self.edge_props),
1849            Some(ref base) => {
1850                // edge_props_section() uses access_unchecked; field reads bounds-checked in seam.rs.
1851                let archived = base
1852                    .edge_props_section()
1853                    .expect("base edge_props section bounds validated at open");
1854                EdgePropsView::with_base(&self.edge_props, archived)
1855            }
1856        }
1857    }
1858
1859    fn open_at_with(fs: F, commit: u64) -> Result<Self> {
1860        let mut db = Self {
1861            fs,
1862            ids: IdMap::new(),
1863            syms: Interner::new(),
1864            topo: Topology::new(),
1865            props: ColumnStore::new(),
1866            labels: Vec::new(),
1867            edge_props: EdgeProps::new(),
1868            engine: RuleEngine::new(),
1869            view_store: ViewStore::new(),
1870            fulltext: FulltextIndex::new(),
1871            prop_index: PropertyIndex::new(),
1872            event_sink: None,
1873            fsync: FsyncPolicy::Strict,
1874            commit_seq: 0,
1875            roles: Some(vec![]),
1876            subscriptions: Vec::new(),
1877            query_subscriptions: Vec::new(),
1878            sub_capacity: DEFAULT_SUB_CAPACITY,
1879            read_only: false, // set to true after replay
1880            total_wal_commits: 0,
1881            base: None,
1882            fold_overlay: None,
1883            delta_tail: Vec::new(),
1884            commits_since_fold: 0,
1885            defer_events: false,
1886            deferred_events: Vec::new(),
1887            degraded: false,
1888            v8_sections_loaded: std::sync::atomic::AtomicBool::new(false),
1889            v8_sections_mutex: std::sync::Mutex::new(()),
1890            last_change: HashMap::new(),
1891            wal_archive_retention: None,
1892            wal_horizon_floor: 0,
1893            archive_genesis_chain: false,
1894            pending_write_authz: None,
1895        };
1896        db.wal_horizon_floor = db.fs.read_horizon_floor()?;
1897        db.archive_genesis_chain = db.fs.has_genesis_marker();
1898        // Same orphaned-archive cleanup as open_with: floor was written first
1899        // during pruning, so a crash may have left stale archives below floor.
1900        db.cleanup_orphaned_archives()?;
1901        // Collect archive frames (oldest-first) and live WAL frames.
1902        // Archives represent pre-snapshot history; the snapshot captures the
1903        // cumulative state at the time of archiving.  Crash-window guarantee:
1904        //   A: crash before rename → WAL intact, no archive. Reopen: normal.
1905        //   B: crash after rename, before new WAL → archive present, WAL
1906        //      absent. Reopen: snapshot loaded (full state), no WAL replay.
1907        //   C: crash after new baseline WAL written → normal post-archive.
1908        let archive_ns = db.fs.list_archives()?;
1909        let mut archive_frames_all: Vec<WalRecord> = Vec::new();
1910        for n in &archive_ns {
1911            let arc_bytes = db.fs.read_archive(*n)?;
1912            let (arc_frames, _) = decode_all(&arc_bytes);
1913            archive_frames_all.extend(arc_frames);
1914        }
1915        let total_archive_frames = archive_frames_all.len() as u64;
1916
1917        let live_bytes = db.fs.read(FileId::Wal)?;
1918        let (live_records, _valid_len) = decode_all(&live_bytes);
1919        let total_surviving = total_archive_frames + live_records.len() as u64;
1920        // Global total including any pruned history below the horizon floor.
1921        let total = db.wal_horizon_floor + total_surviving;
1922
1923        // Horizon and range check.
1924        if commit < db.wal_horizon_floor {
1925            return Err(GraphError::CommitOutOfRange { commit, total });
1926        }
1927        if commit >= total {
1928            return Err(GraphError::CommitOutOfRange { commit, total });
1929        }
1930
1931        // Local index into surviving frames (0 = first frame of oldest archive).
1932        let local = commit - db.wal_horizon_floor;
1933
1934        if local < total_archive_frames {
1935            // Target commit is in an archive.  Correct replay from empty state
1936            // is only possible when the archive chain is an uninterrupted
1937            // genesis chain (first archive taken from a fresh store, no prior
1938            // WAL truncation) and no archives have been pruned (floor == 0).
1939            //
1940            // If either condition is violated the prefix needed to reconstruct
1941            // the requested state is gone; refuse rather than return wrong data.
1942            if db.wal_horizon_floor > 0 || !db.archive_genesis_chain {
1943                return Err(GraphError::CommitOutOfRange { commit, total });
1944            }
1945            // Replay all archive frames up to and including the target commit
1946            // from an empty database state.  Archives must be replayed in order
1947            // so that dense-id intern tables are built up correctly.
1948            for rec in archive_frames_all.into_iter().take((local + 1) as usize) {
1949                db.apply(&rec)?;
1950                let _ = db.engine.drain_deltas();
1951            }
1952        } else {
1953            // Target commit is in the live WAL: load snapshot as base, then
1954            // replay the needed live WAL prefix.
1955            //
1956            // Base state: a truncating snapshot (wal_truncated=true) compacts
1957            // all pre-truncation / pre-archive commits.  Dense-id records in
1958            // the live WAL reference ids/interns that the snapshot provides.
1959            // Peek 6 bytes (same pattern as open_with).
1960            let snap_header = db.fs.read_prefix(FileId::Snapshot, 6)?;
1961            let is_v8 = snap_header.len() >= 6
1962                && &snap_header[0..4] == b"GDB1"
1963                && u16::from_le_bytes([snap_header[4], snap_header[5]])
1964                    == core_storage::snapshot::VERSION_8;
1965            if is_v8 {
1966                let state = if let Some(snap_path) = db.fs.snapshot_path() {
1967                    let mapped = core_storage::v8::MappedBase::map(&snap_path).map_err(|e| {
1968                        GraphError::Corrupt {
1969                            detail: format!("v8: open_at mmap: {e:?}"),
1970                        }
1971                    })?;
1972                    core_storage::snapshot::decode_v8_from_mapped(&mapped)?
1973                } else {
1974                    let snap_bytes = db.fs.read(FileId::Snapshot)?;
1975                    core_storage::snapshot::decode(&snap_bytes)?
1976                };
1977                if let Some(state) = state {
1978                    if state.wal_truncated {
1979                        db.restore_snapshot_state(state)?;
1980                    }
1981                }
1982            } else if !snap_header.is_empty() {
1983                let snap_bytes = db.fs.read(FileId::Snapshot)?;
1984                if let Some(state) = core_storage::snapshot::decode(&snap_bytes)? {
1985                    if state.wal_truncated {
1986                        db.restore_snapshot_state(state)?;
1987                    }
1988                }
1989            }
1990            // else: snap_header empty = no snapshot file.
1991            let live_local = local - total_archive_frames;
1992            for rec in live_records.into_iter().take((live_local + 1) as usize) {
1993                db.apply(&rec)?;
1994                let _ = db.engine.drain_deltas();
1995            }
1996        }
1997        // Pin: pending_delta_count must be 0 after as-of replay, mirroring T1's
1998        // post-loop assert in open_with.
1999        debug_assert_eq!(
2000            db.engine.pending_delta_count(),
2001            0,
2002            "pending_deltas non-empty after open_at replay — \
2003             per-frame drain must run inside the loop to keep memory O(1)"
2004        );
2005        let _ = db.engine.drain_deltas(); // belt-and-braces no-op
2006                                          // Rebuild view values after WAL replay so derived-edge-driven views
2007                                          // reflect the as-of state.  open_at always uses the legacy path (no V8
2008                                          // base), so topo_view is always owned.
2009        {
2010            let topo_view = TopologyView::owned(&db.topo);
2011            db.view_store
2012                .rebuild_all(&mut db.props, &topo_view, &db.ids, &db.syms, &db.labels);
2013        }
2014        // Rebuild full-text index for as-of view (mirrors open_with pattern).
2015        db.fulltext.rebuild_all(
2016            &db.ids,
2017            &db.labels,
2018            &db.syms,
2019            build_props_view(&db.props, &db.base),
2020        );
2021        db.prop_index.rebuild_all(
2022            &db.ids,
2023            &db.labels,
2024            &db.syms,
2025            build_props_view(&db.props, &db.base),
2026        );
2027        // Load roles sidecar (current roles, not point-in-time).
2028        db.roles = Self::load_roles_from_fs(&db.fs)?;
2029        db.read_only = true;
2030        db.total_wal_commits = total;
2031        // Capture initial fold so reader() is immediately usable.
2032        db.fold_now();
2033        Ok(db)
2034    }
2035
2036    /// Whether this instance is a read-only as-of view.
2037    pub fn is_read_only(&self) -> bool {
2038        self.read_only
2039    }
2040
2041    // ── MVCC epoch reader ─────────────────────────────────────────────────────
2042
2043    /// Clone the current overlay state into a new `FrozenOverlay` and reset
2044    /// the delta tail. Called automatically every `FOLD_EVERY_K` commits and at
2045    /// the end of `open_with` / `open_at_with` to prime the reader.
2046    fn fold_now(&mut self) {
2047        let frozen = crate::reader::FrozenOverlay {
2048            ids: self.ids.clone(),
2049            syms: self.syms.clone(),
2050            topo: self.topo.clone(),
2051            props: self.props.clone(),
2052            labels: self.labels.clone(),
2053            edge_props: self.edge_props.clone(),
2054            roles: self.roles.clone(),
2055            fulltext: self.fulltext.clone(),
2056        };
2057        self.fold_overlay = Some(Arc::new(frozen));
2058        self.delta_tail.clear();
2059        self.commits_since_fold = 0;
2060    }
2061
2062    /// Capture a lock-free reader snapshot of the current db state.
2063    ///
2064    /// The read lock is held only for the duration of this call (to clone a
2065    /// handful of `Arc` handles). Subsequent query operations run without any
2066    /// lock.
2067    pub fn reader(&self) -> crate::reader::ReaderSnapshot {
2068        crate::reader::ReaderSnapshot::new(
2069            self.fold_overlay
2070                .clone()
2071                .expect("fold_overlay is always Some after open_with; call reader() after open"),
2072            self.base.clone(),
2073            self.delta_tail.clone(),
2074        )
2075    }
2076
2077    /// Total number of WAL commits at the time [`open_at`] was called.
2078    /// Returns 0 for normal (non-as-of) instances.
2079    pub fn total_wal_commits(&self) -> u64 {
2080        self.total_wal_commits
2081    }
2082
2083    /// Apply a record to in-memory state. Used by both live writes and replay,
2084    /// so replay is definitionally identical to the original execution.
2085    fn apply(&mut self, rec: &WalRecord) -> Result<()> {
2086        match rec {
2087            WalRecord::InsertNode { label, key, props } => {
2088                let id = self.ids.try_insert(key)?;
2089                let sym = self.syms.intern(label);
2090                if self.labels.len() <= id as usize {
2091                    // gap slots are sentinels, never valid label symbols
2092                    self.labels.resize(id as usize + 1, u32::MAX);
2093                }
2094                self.labels[id as usize] = sym;
2095                for (field, value) in props {
2096                    self.props.set(id, field, value.clone());
2097                }
2098                // Initialize view values for the new node before the engine runs so
2099                // delta-based increments start from a known zero baseline.
2100                self.view_store
2101                    .init_node_views(id, &mut self.props, &self.syms, &self.labels);
2102                // Fire rules for the newly inserted node.
2103                let cursor = self.engine.pending_delta_count();
2104                let mut eng = std::mem::take(&mut self.engine);
2105                {
2106                    let mut gm = make_graph_mut(
2107                        &self.ids,
2108                        &mut self.syms,
2109                        &self.labels,
2110                        build_props_view(&self.props, &self.base),
2111                        &mut self.topo,
2112                        &mut self.edge_props,
2113                    );
2114                    eng.on_node_changed(id, None, &mut gm);
2115                }
2116                self.engine = eng;
2117                // Process derived-edge deltas for view maintenance.
2118                // Fast path: skip the O(delta_count) allocation when no views exist.
2119                if !self.view_store.is_empty() {
2120                    #[cfg(test)]
2121                    DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
2122                    let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
2123                    for d in &new_deltas {
2124                        self.view_store.on_edge_changed(
2125                            d.etype_sym,
2126                            d.src_id,
2127                            d.dst_id,
2128                            d.fired,
2129                            &mut self.props,
2130                            &build_topo_view(&self.topo, &self.base),
2131                            &self.ids,
2132                            &self.syms,
2133                            &self.labels,
2134                            self.base.as_ref().map(|b| {
2135                                b.columns()
2136                                    .expect("base columns section bounds validated at open")
2137                            }),
2138                        );
2139                    }
2140                }
2141                // Full-text index maintenance: index enabled fields for this label.
2142                if self.fulltext.has_label(label) {
2143                    for (field, value) in props {
2144                        if self.fulltext.is_enabled(label, field) {
2145                            self.fulltext.add_tokens(id, field, value);
2146                        }
2147                    }
2148                }
2149                // Property (equality) index maintenance.
2150                if self.prop_index.has_label(label) {
2151                    for (field, value) in props {
2152                        self.prop_index.set(label, field, id, value);
2153                    }
2154                }
2155            }
2156            WalRecord::InsertEdge {
2157                edge_type,
2158                src_key,
2159                dst_key,
2160            } => {
2161                let src = self.ids.get(src_key).ok_or_else(|| GraphError::Corrupt {
2162                    detail: format!("wal replay references unknown key {src_key}"),
2163                })?;
2164                let dst = self.ids.get(dst_key).ok_or_else(|| GraphError::Corrupt {
2165                    detail: format!("wal replay references unknown key {dst_key}"),
2166                })?;
2167                let etype = self.syms.intern(edge_type);
2168                // Skip if the edge is already visible in the merged base+overlay
2169                // view.  This keeps WAL replay idempotent when the WAL contains
2170                // pre-snapshot records that are already encoded in a V8 base
2171                // (keep_wal=true opens and crash-before-truncation scenarios).
2172                if self.base.is_some()
2173                    && self
2174                        .topo_view()
2175                        .neighbors(etype, Direction::Out, src)
2176                        .contains(&dst)
2177                {
2178                    return Ok(());
2179                }
2180                self.topo.add_edge(etype, src, dst);
2181                // View maintenance for manual edge insert.
2182                self.view_store.on_edge_changed(
2183                    etype,
2184                    src,
2185                    dst,
2186                    true,
2187                    &mut self.props,
2188                    &build_topo_view(&self.topo, &self.base),
2189                    &self.ids,
2190                    &self.syms,
2191                    &self.labels,
2192                    self.base.as_ref().map(|b| {
2193                        b.columns()
2194                            .expect("base columns section bounds validated at open")
2195                    }),
2196                );
2197                // Rule engine: via-hop rules must update when user edges change.
2198                let cursor = self.engine.pending_delta_count();
2199                let mut eng = std::mem::take(&mut self.engine);
2200                {
2201                    let mut gm = make_graph_mut(
2202                        &self.ids,
2203                        &mut self.syms,
2204                        &self.labels,
2205                        build_props_view(&self.props, &self.base),
2206                        &mut self.topo,
2207                        &mut self.edge_props,
2208                    );
2209                    eng.on_edge_changed(edge_type, src, dst, &mut gm);
2210                }
2211                self.engine = eng;
2212                if !self.view_store.is_empty() {
2213                    let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
2214                    for d in &new_deltas {
2215                        self.view_store.on_edge_changed(
2216                            d.etype_sym,
2217                            d.src_id,
2218                            d.dst_id,
2219                            d.fired,
2220                            &mut self.props,
2221                            &build_topo_view(&self.topo, &self.base),
2222                            &self.ids,
2223                            &self.syms,
2224                            &self.labels,
2225                            self.base.as_ref().map(|b| {
2226                                b.columns()
2227                                    .expect("base columns section bounds validated at open")
2228                            }),
2229                        );
2230                    }
2231                }
2232            }
2233            WalRecord::SetProp { key, field, value } => {
2234                let id = self.ids.get(key).ok_or_else(|| GraphError::Corrupt {
2235                    detail: format!("wal replay references unknown key {key}"),
2236                })?;
2237                let old_value = build_props_view(&self.props, &self.base)
2238                    .get(id, field)
2239                    .map(|vr| vr.into_value());
2240                self.props.set(id, field, value.clone());
2241                // Fire rules for the changed field.
2242                let cursor = self.engine.pending_delta_count();
2243                let mut eng = std::mem::take(&mut self.engine);
2244                {
2245                    let mut gm = make_graph_mut(
2246                        &self.ids,
2247                        &mut self.syms,
2248                        &self.labels,
2249                        build_props_view(&self.props, &self.base),
2250                        &mut self.topo,
2251                        &mut self.edge_props,
2252                    );
2253                    eng.on_node_changed(id, Some((field, old_value)), &mut gm);
2254                }
2255                self.engine = eng;
2256                // Derived-edge deltas → view updates.
2257                if !self.view_store.is_empty() {
2258                    #[cfg(test)]
2259                    DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
2260                    let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
2261                    for d in &new_deltas {
2262                        self.view_store.on_edge_changed(
2263                            d.etype_sym,
2264                            d.src_id,
2265                            d.dst_id,
2266                            d.fired,
2267                            &mut self.props,
2268                            &build_topo_view(&self.topo, &self.base),
2269                            &self.ids,
2270                            &self.syms,
2271                            &self.labels,
2272                            self.base.as_ref().map(|b| {
2273                                b.columns()
2274                                    .expect("base columns section bounds validated at open")
2275                            }),
2276                        );
2277                    }
2278                }
2279                // Neighbor-aggregate views that read `field` must also update.
2280                self.view_store.on_prop_changed(
2281                    id,
2282                    field,
2283                    &mut self.props,
2284                    &build_topo_view(&self.topo, &self.base),
2285                    &self.ids,
2286                    &self.syms,
2287                    &self.labels,
2288                    self.base.as_ref().map(|b| {
2289                        b.columns()
2290                            .expect("base columns section bounds validated at open")
2291                    }),
2292                );
2293                // Full-text index maintenance: update tokens for this field if indexed.
2294                if self.fulltext.field_indexed(field) {
2295                    let label_opt = self.labels.get(id as usize).and_then(|&sym| {
2296                        if sym == u32::MAX {
2297                            None
2298                        } else {
2299                            self.syms.resolve(sym)
2300                        }
2301                    });
2302                    if let Some(label) = label_opt {
2303                        if self.fulltext.is_enabled(label, field) {
2304                            self.fulltext.remove_node_field(id, field);
2305                            self.fulltext.add_tokens(id, field, value);
2306                        }
2307                    }
2308                }
2309                // Property (equality) index maintenance: re-key this node's value.
2310                if self.prop_index.field_indexed(field) {
2311                    let label_opt = self.labels.get(id as usize).and_then(|&sym| {
2312                        if sym == u32::MAX {
2313                            None
2314                        } else {
2315                            self.syms.resolve(sym)
2316                        }
2317                    });
2318                    if let Some(label) = label_opt {
2319                        self.prop_index.set(label, field, id, value);
2320                    }
2321                }
2322            }
2323            WalRecord::Intern { id, text } => {
2324                if let Some(existing) = self.syms.get(text) {
2325                    if existing != *id {
2326                        return Err(GraphError::Corrupt {
2327                            detail: format!(
2328                                "wal intern mismatch for {text:?}: have {existing}, record {id}"
2329                            ),
2330                        });
2331                    }
2332                } else {
2333                    let got = self.syms.intern(text);
2334                    if got != *id {
2335                        return Err(GraphError::Corrupt {
2336                            detail: format!(
2337                                "wal intern assigned {got} for {text:?}, record wanted {id}"
2338                            ),
2339                        });
2340                    }
2341                }
2342            }
2343            WalRecord::InsertNodeId { label, key, props } => {
2344                let id = self.ids.try_insert(key)?;
2345                if self.labels.len() <= id as usize {
2346                    self.labels.resize(id as usize + 1, u32::MAX);
2347                }
2348                self.labels[id as usize] = *label;
2349                let label_str = self
2350                    .syms
2351                    .resolve(*label)
2352                    .ok_or_else(|| GraphError::Corrupt {
2353                        detail: format!("wal InsertNodeId unknown label intern {label}"),
2354                    })?
2355                    .to_string();
2356                for (field_sym, value) in props {
2357                    let field =
2358                        self.syms
2359                            .resolve(*field_sym)
2360                            .ok_or_else(|| GraphError::Corrupt {
2361                                detail: format!(
2362                                    "wal InsertNodeId unknown field intern {field_sym}"
2363                                ),
2364                            })?;
2365                    self.props.set(id, field, value.clone());
2366                }
2367                self.view_store
2368                    .init_node_views(id, &mut self.props, &self.syms, &self.labels);
2369                let cursor = self.engine.pending_delta_count();
2370                let mut eng = std::mem::take(&mut self.engine);
2371                {
2372                    let mut gm = make_graph_mut(
2373                        &self.ids,
2374                        &mut self.syms,
2375                        &self.labels,
2376                        build_props_view(&self.props, &self.base),
2377                        &mut self.topo,
2378                        &mut self.edge_props,
2379                    );
2380                    eng.on_node_changed(id, None, &mut gm);
2381                }
2382                self.engine = eng;
2383                if !self.view_store.is_empty() {
2384                    #[cfg(test)]
2385                    DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
2386                    let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
2387                    for d in &new_deltas {
2388                        self.view_store.on_edge_changed(
2389                            d.etype_sym,
2390                            d.src_id,
2391                            d.dst_id,
2392                            d.fired,
2393                            &mut self.props,
2394                            &build_topo_view(&self.topo, &self.base),
2395                            &self.ids,
2396                            &self.syms,
2397                            &self.labels,
2398                            self.base.as_ref().map(|b| {
2399                                b.columns()
2400                                    .expect("base columns section bounds validated at open")
2401                            }),
2402                        );
2403                    }
2404                }
2405                if self.fulltext.has_label(&label_str) {
2406                    for (field_sym, value) in props {
2407                        let Some(field) = self.syms.resolve(*field_sym) else {
2408                            continue;
2409                        };
2410                        if self.fulltext.is_enabled(&label_str, field) {
2411                            self.fulltext.add_tokens(id, field, value);
2412                        }
2413                    }
2414                }
2415                if self.prop_index.has_label(&label_str) {
2416                    for (field_sym, value) in props {
2417                        let Some(field) = self.syms.resolve(*field_sym) else {
2418                            continue;
2419                        };
2420                        self.prop_index.set(&label_str, field, id, value);
2421                    }
2422                }
2423            }
2424            WalRecord::InsertEdgeId { etype, src, dst } => {
2425                // Replay-over-snapshot: dense ids in the pre-snapshot WAL may
2426                // already be tombstoned. Skip rather than attaching edges to
2427                // dead ids (DeleteNode keys the live re-insert, not the old id).
2428                if self.ids.is_tombstoned(*src)
2429                    || self.ids.is_tombstoned(*dst)
2430                    || self.ids.key_of(*src).is_none()
2431                    || self.ids.key_of(*dst).is_none()
2432                {
2433                    return Ok(());
2434                }
2435                // Skip if already visible in the merged view (same idempotency
2436                // guard as InsertEdge above: prevents double-counting when
2437                // pre-snapshot WAL records are replayed over a V8 base).
2438                if self.base.is_some()
2439                    && self
2440                        .topo_view()
2441                        .neighbors(*etype, Direction::Out, *src)
2442                        .contains(dst)
2443                {
2444                    return Ok(());
2445                }
2446                self.topo.add_edge(*etype, *src, *dst);
2447                self.view_store.on_edge_changed(
2448                    *etype,
2449                    *src,
2450                    *dst,
2451                    true,
2452                    &mut self.props,
2453                    &build_topo_view(&self.topo, &self.base),
2454                    &self.ids,
2455                    &self.syms,
2456                    &self.labels,
2457                    self.base.as_ref().map(|b| {
2458                        b.columns()
2459                            .expect("base columns section bounds validated at open")
2460                    }),
2461                );
2462                // Rule engine: via-hop rules fire when user via-edges are inserted.
2463                // Resolve etype back to string so on_edge_changed can match rules by name.
2464                if let Some(etype_str) = self.syms.resolve(*etype).map(|s| s.to_string()) {
2465                    let cursor = self.engine.pending_delta_count();
2466                    let mut eng = std::mem::take(&mut self.engine);
2467                    {
2468                        let mut gm = make_graph_mut(
2469                            &self.ids,
2470                            &mut self.syms,
2471                            &self.labels,
2472                            build_props_view(&self.props, &self.base),
2473                            &mut self.topo,
2474                            &mut self.edge_props,
2475                        );
2476                        eng.on_edge_changed(&etype_str, *src, *dst, &mut gm);
2477                    }
2478                    self.engine = eng;
2479                    if !self.view_store.is_empty() {
2480                        let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
2481                        for d in &new_deltas {
2482                            self.view_store.on_edge_changed(
2483                                d.etype_sym,
2484                                d.src_id,
2485                                d.dst_id,
2486                                d.fired,
2487                                &mut self.props,
2488                                &build_topo_view(&self.topo, &self.base),
2489                                &self.ids,
2490                                &self.syms,
2491                                &self.labels,
2492                                self.base.as_ref().map(|b| {
2493                                    b.columns()
2494                                        .expect("base columns section bounds validated at open")
2495                                }),
2496                            );
2497                        }
2498                    }
2499                }
2500            }
2501            WalRecord::SetPropId { id, field, value } => {
2502                if self.ids.is_tombstoned(*id) || self.ids.key_of(*id).is_none() {
2503                    return Ok(());
2504                }
2505                let field_str = self
2506                    .syms
2507                    .resolve(*field)
2508                    .ok_or_else(|| GraphError::Corrupt {
2509                        detail: format!("wal SetPropId unknown field intern {field}"),
2510                    })?
2511                    .to_string();
2512                let old_value = build_props_view(&self.props, &self.base)
2513                    .get(*id, &field_str)
2514                    .map(|vr| vr.into_value());
2515                self.props.set(*id, &field_str, value.clone());
2516                let cursor = self.engine.pending_delta_count();
2517                let mut eng = std::mem::take(&mut self.engine);
2518                {
2519                    let mut gm = make_graph_mut(
2520                        &self.ids,
2521                        &mut self.syms,
2522                        &self.labels,
2523                        build_props_view(&self.props, &self.base),
2524                        &mut self.topo,
2525                        &mut self.edge_props,
2526                    );
2527                    eng.on_node_changed(*id, Some((field_str.as_str(), old_value)), &mut gm);
2528                }
2529                self.engine = eng;
2530                if !self.view_store.is_empty() {
2531                    #[cfg(test)]
2532                    DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
2533                    let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
2534                    for d in &new_deltas {
2535                        self.view_store.on_edge_changed(
2536                            d.etype_sym,
2537                            d.src_id,
2538                            d.dst_id,
2539                            d.fired,
2540                            &mut self.props,
2541                            &build_topo_view(&self.topo, &self.base),
2542                            &self.ids,
2543                            &self.syms,
2544                            &self.labels,
2545                            self.base.as_ref().map(|b| {
2546                                b.columns()
2547                                    .expect("base columns section bounds validated at open")
2548                            }),
2549                        );
2550                    }
2551                }
2552                self.view_store.on_prop_changed(
2553                    *id,
2554                    &field_str,
2555                    &mut self.props,
2556                    &build_topo_view(&self.topo, &self.base),
2557                    &self.ids,
2558                    &self.syms,
2559                    &self.labels,
2560                    self.base.as_ref().map(|b| {
2561                        b.columns()
2562                            .expect("base columns section bounds validated at open")
2563                    }),
2564                );
2565                if self.fulltext.field_indexed(&field_str) {
2566                    let label_opt = self.labels.get(*id as usize).and_then(|&sym| {
2567                        if sym == u32::MAX {
2568                            None
2569                        } else {
2570                            self.syms.resolve(sym)
2571                        }
2572                    });
2573                    if let Some(label) = label_opt {
2574                        if self.fulltext.is_enabled(label, &field_str) {
2575                            self.fulltext.remove_node_field(*id, &field_str);
2576                            self.fulltext.add_tokens(*id, &field_str, value);
2577                        }
2578                    }
2579                }
2580                if self.prop_index.field_indexed(&field_str) {
2581                    let label_opt = self.labels.get(*id as usize).and_then(|&sym| {
2582                        if sym == u32::MAX {
2583                            None
2584                        } else {
2585                            self.syms.resolve(sym)
2586                        }
2587                    });
2588                    if let Some(label) = label_opt {
2589                        self.prop_index.set(label, &field_str, *id, value);
2590                    }
2591                }
2592            }
2593            WalRecord::CreateRule { def_bytes } => {
2594                let def: RuleDef = decode_rule_def(def_bytes).map_err(|e| GraphError::Corrupt {
2595                    detail: format!("CreateRule def_bytes deserialize failed: {e}"),
2596                })?;
2597                // Replay-over-snapshot idempotency: the rule was captured in the snapshot
2598                // so the engine already has it; silently skip to avoid a spurious
2599                // RuleInvalid error in the crash window between snapshot write and WAL
2600                // truncation.
2601                if self.engine.rules().any(|r| r.name == def.name) {
2602                    return Ok(());
2603                }
2604                let cursor = self.engine.pending_delta_count();
2605                let mut eng = std::mem::take(&mut self.engine);
2606                let result = {
2607                    let mut gm = make_graph_mut(
2608                        &self.ids,
2609                        &mut self.syms,
2610                        &self.labels,
2611                        build_props_view(&self.props, &self.base),
2612                        &mut self.topo,
2613                        &mut self.edge_props,
2614                    );
2615                    eng.create_rule(def, &mut gm)
2616                };
2617                self.engine = eng;
2618                result.map_err(|e| GraphError::RuleInvalid { detail: e })?;
2619                // Derived-edge fires from backfill → view updates.
2620                // Fast path: skip O(edge_count) allocation when no views exist.
2621                if !self.view_store.is_empty() {
2622                    #[cfg(test)]
2623                    DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
2624                    let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
2625                    for d in &new_deltas {
2626                        self.view_store.on_edge_changed(
2627                            d.etype_sym,
2628                            d.src_id,
2629                            d.dst_id,
2630                            d.fired,
2631                            &mut self.props,
2632                            &build_topo_view(&self.topo, &self.base),
2633                            &self.ids,
2634                            &self.syms,
2635                            &self.labels,
2636                            self.base.as_ref().map(|b| {
2637                                b.columns()
2638                                    .expect("base columns section bounds validated at open")
2639                            }),
2640                        );
2641                    }
2642                }
2643            }
2644            WalRecord::DeleteRule { name } => {
2645                // Replay-over-snapshot idempotency: the snapshot already captured the
2646                // post-delete state so the rule is absent; silently skip to avoid a
2647                // spurious RuleNotFound error in the crash window between snapshot write
2648                // and WAL truncation.
2649                if !self.engine.rules().any(|r| r.name == *name) {
2650                    return Ok(());
2651                }
2652                let cursor = self.engine.pending_delta_count();
2653                let mut eng = std::mem::take(&mut self.engine);
2654                let result = {
2655                    let mut gm = make_graph_mut(
2656                        &self.ids,
2657                        &mut self.syms,
2658                        &self.labels,
2659                        build_props_view(&self.props, &self.base),
2660                        &mut self.topo,
2661                        &mut self.edge_props,
2662                    );
2663                    eng.delete_rule(name, &mut gm)
2664                };
2665                self.engine = eng;
2666                result.map_err(|_| GraphError::RuleNotFound { name: name.clone() })?;
2667                // Derived-edge retractions → view updates.
2668                if !self.view_store.is_empty() {
2669                    #[cfg(test)]
2670                    DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
2671                    let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
2672                    for d in &new_deltas {
2673                        self.view_store.on_edge_changed(
2674                            d.etype_sym,
2675                            d.src_id,
2676                            d.dst_id,
2677                            d.fired,
2678                            &mut self.props,
2679                            &build_topo_view(&self.topo, &self.base),
2680                            &self.ids,
2681                            &self.syms,
2682                            &self.labels,
2683                            self.base.as_ref().map(|b| {
2684                                b.columns()
2685                                    .expect("base columns section bounds validated at open")
2686                            }),
2687                        );
2688                    }
2689                }
2690            }
2691            WalRecord::RemoveProp { key, field } => {
2692                // Recovery-safe: unknown key or already-absent field is a
2693                // clean no-op. Crash-window replay over a snapshot that
2694                // already applied this record must not Err.
2695                let Some(id) = self.ids.get(key) else {
2696                    return Ok(());
2697                };
2698                // Read old value through the seam for rule retraction.
2699                let old = build_props_view(&self.props, &self.base)
2700                    .get(id, field)
2701                    .map(|vr| vr.into_value());
2702                self.props.remove(id, field);
2703                // If the base still supplies the value after the overlay removal,
2704                // record a tombstone so ColumnsView::get does not resurrect it.
2705                // This covers both the base-only case AND the both-resident case:
2706                //   base-only (in_overlay=false): old prop was only in base, remove
2707                //     is a no-op on overlay, base still visible → tombstone needed.
2708                //   both-resident (in_overlay=true): overlay had v2, base has v1;
2709                //     removing overlay uncovers v1 → tombstone needed.
2710                // Idempotent on double-replay: second pass sees the tombstone →
2711                // get() returns None → condition is false → no duplicate tombstone.
2712                if build_props_view(&self.props, &self.base)
2713                    .get(id, field)
2714                    .is_some()
2715                {
2716                    self.props.record_prop_tombstone(id, field);
2717                }
2718                let cursor = self.engine.pending_delta_count();
2719                let mut eng = std::mem::take(&mut self.engine);
2720                {
2721                    let mut gm = make_graph_mut(
2722                        &self.ids,
2723                        &mut self.syms,
2724                        &self.labels,
2725                        build_props_view(&self.props, &self.base),
2726                        &mut self.topo,
2727                        &mut self.edge_props,
2728                    );
2729                    eng.on_node_changed(id, Some((field, old)), &mut gm);
2730                }
2731                self.engine = eng;
2732                // Derived-edge deltas → view updates.
2733                if !self.view_store.is_empty() {
2734                    #[cfg(test)]
2735                    DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
2736                    let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
2737                    for d in &new_deltas {
2738                        self.view_store.on_edge_changed(
2739                            d.etype_sym,
2740                            d.src_id,
2741                            d.dst_id,
2742                            d.fired,
2743                            &mut self.props,
2744                            &build_topo_view(&self.topo, &self.base),
2745                            &self.ids,
2746                            &self.syms,
2747                            &self.labels,
2748                            self.base.as_ref().map(|b| {
2749                                b.columns()
2750                                    .expect("base columns section bounds validated at open")
2751                            }),
2752                        );
2753                    }
2754                }
2755                // Neighbor-aggregate views that read `field` must also update.
2756                self.view_store.on_prop_changed(
2757                    id,
2758                    field,
2759                    &mut self.props,
2760                    &build_topo_view(&self.topo, &self.base),
2761                    &self.ids,
2762                    &self.syms,
2763                    &self.labels,
2764                    self.base.as_ref().map(|b| {
2765                        b.columns()
2766                            .expect("base columns section bounds validated at open")
2767                    }),
2768                );
2769                // Full-text index maintenance: remove tokens for this field.
2770                if self.fulltext.field_indexed(field) {
2771                    self.fulltext.remove_node_field(id, field);
2772                }
2773                // Property (equality) index maintenance: drop this node's entry.
2774                if self.prop_index.field_indexed(field) {
2775                    if let Some(label) = self.labels.get(id as usize).and_then(|&sym| {
2776                        (sym != u32::MAX).then(|| self.syms.resolve(sym)).flatten()
2777                    }) {
2778                        self.prop_index.remove_node(label, field, id);
2779                    }
2780                }
2781            }
2782            WalRecord::DeleteEdge {
2783                edge_type,
2784                src_key,
2785                dst_key,
2786            } => {
2787                // Recovery-safe: unknown keys, unknown etype, or already-
2788                // absent edge is a clean no-op (remove_edge returns false).
2789                let Some(src) = self.ids.get(src_key) else {
2790                    return Ok(());
2791                };
2792                let Some(dst) = self.ids.get(dst_key) else {
2793                    return Ok(());
2794                };
2795                let Some(etype) = self.syms.get(edge_type) else {
2796                    return Ok(());
2797                };
2798                // I3: phantom-tombstone guard.  When a V8 base is present, a
2799                // DeleteEdge WAL record for an edge that was already absorbed into
2800                // the new base (i.e. neither in overlay nor in base) must be skipped.
2801                // Without this guard, remove_edge records a tombstone for an edge
2802                // that no longer exists, incorrectly understating edge_count.
2803                if self.base.is_some()
2804                    && !self
2805                        .topo_view()
2806                        .neighbors(etype, core_storage::topology::Direction::Out, src)
2807                        .contains(&dst)
2808                {
2809                    return Ok(());
2810                }
2811                self.topo.remove_edge(etype, src, dst);
2812                self.edge_props.remove_edge(etype, src, dst);
2813                // View maintenance for manual edge delete (topo already updated above).
2814                self.view_store.on_edge_changed(
2815                    etype,
2816                    src,
2817                    dst,
2818                    false,
2819                    &mut self.props,
2820                    &build_topo_view(&self.topo, &self.base),
2821                    &self.ids,
2822                    &self.syms,
2823                    &self.labels,
2824                    self.base.as_ref().map(|b| {
2825                        b.columns()
2826                            .expect("base columns section bounds validated at open")
2827                    }),
2828                );
2829                // Rule engine: via-hop rules must retract when user via-edges are deleted.
2830                let cursor = self.engine.pending_delta_count();
2831                let mut eng = std::mem::take(&mut self.engine);
2832                {
2833                    let mut gm = make_graph_mut(
2834                        &self.ids,
2835                        &mut self.syms,
2836                        &self.labels,
2837                        build_props_view(&self.props, &self.base),
2838                        &mut self.topo,
2839                        &mut self.edge_props,
2840                    );
2841                    eng.on_edge_changed(edge_type, src, dst, &mut gm);
2842                }
2843                self.engine = eng;
2844                if !self.view_store.is_empty() {
2845                    let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
2846                    for d in &new_deltas {
2847                        self.view_store.on_edge_changed(
2848                            d.etype_sym,
2849                            d.src_id,
2850                            d.dst_id,
2851                            d.fired,
2852                            &mut self.props,
2853                            &build_topo_view(&self.topo, &self.base),
2854                            &self.ids,
2855                            &self.syms,
2856                            &self.labels,
2857                            self.base.as_ref().map(|b| {
2858                                b.columns()
2859                                    .expect("base columns section bounds validated at open")
2860                            }),
2861                        );
2862                    }
2863                }
2864            }
2865            WalRecord::DeleteNode { key } => {
2866                // Recovery-safe: already-tombstoned / unknown key is a clean
2867                // no-op. Crash-window replay over a snapshot that already
2868                // applied this record cannot recover the retired id from the
2869                // key (`IdMap::get` is None), so every subsequent step is
2870                // skipped. Each step is independently idempotent if invoked
2871                // twice on a still-live id: retraction is a no-op on empty
2872                // provenance, `remove_edge` returns false, `remove_all` is a
2873                // no-op, `ids.delete` returns None, label sentinel is sticky.
2874                let Some(n) = self.ids.get(key) else {
2875                    return Ok(());
2876                };
2877
2878                // (1) Retract derived edges + de-index while props/labels live.
2879                let cursor = self.engine.pending_delta_count();
2880                let mut eng = std::mem::take(&mut self.engine);
2881                {
2882                    let mut gm = make_graph_mut(
2883                        &self.ids,
2884                        &mut self.syms,
2885                        &self.labels,
2886                        build_props_view(&self.props, &self.base),
2887                        &mut self.topo,
2888                        &mut self.edge_props,
2889                    );
2890                    eng.on_node_removed(n, &mut gm);
2891                }
2892                self.engine = eng;
2893                // Derived-edge retractions → view updates for neighbors.
2894                if !self.view_store.is_empty() {
2895                    #[cfg(test)]
2896                    DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
2897                    let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
2898                    for d in &new_deltas {
2899                        self.view_store.on_edge_changed(
2900                            d.etype_sym,
2901                            d.src_id,
2902                            d.dst_id,
2903                            d.fired,
2904                            &mut self.props,
2905                            &build_topo_view(&self.topo, &self.base),
2906                            &self.ids,
2907                            &self.syms,
2908                            &self.labels,
2909                            self.base.as_ref().map(|b| {
2910                                b.columns()
2911                                    .expect("base columns section bounds validated at open")
2912                            }),
2913                        );
2914                    }
2915                }
2916
2917                // (2) Sweep ALL remaining edges incident to n, both directions,
2918                // every etype. This cascade is intentionally mask-independent:
2919                // topology integrity requires removing every edge touching the
2920                // deleted node regardless of the caller's visibility scope.
2921                // (The mask limits which nodes a role's read phase can return;
2922                // the WAL delete always executes with full storage authority.)
2923                // Collect then remove so neighbor slices stay valid during
2924                // iteration. Remove from topo first, then call view maintenance
2925                // so Avg/Min/Max recompute sees the correct (reduced) neighbor set.
2926                let etypes: Vec<u32> = self.topo.etypes().collect();
2927                let mut doomed = Vec::new();
2928                for et in &etypes {
2929                    for &dst in self.topo.neighbors(*et, Direction::Out, n).as_ref() {
2930                        doomed.push((*et, n, dst));
2931                    }
2932                    for &src in self.topo.neighbors(*et, Direction::In, n).as_ref() {
2933                        doomed.push((*et, src, n));
2934                    }
2935                }
2936                for (et, s, d) in doomed {
2937                    self.topo.remove_edge(et, s, d);
2938                    self.edge_props.remove_edge(et, s, d);
2939                    // View maintenance: n's own view values will be cleared by
2940                    // remove_all below; only update surviving neighbors.
2941                    self.view_store.on_edge_changed(
2942                        et,
2943                        s,
2944                        d,
2945                        false,
2946                        &mut self.props,
2947                        &build_topo_view(&self.topo, &self.base),
2948                        &self.ids,
2949                        &self.syms,
2950                        &self.labels,
2951                        self.base.as_ref().map(|b| {
2952                            b.columns()
2953                                .expect("base columns section bounds validated at open")
2954                        }),
2955                    );
2956                }
2957
2958                // (3) Drop every remaining prop (`ColumnStore::remove_all`).
2959                self.props.remove_all(n);
2960                // Full-text index maintenance: remove all tokens for this node.
2961                self.fulltext.remove_node(n);
2962                // Property (equality) index maintenance: drop all entries for n.
2963                self.prop_index.remove_node_all(n);
2964
2965                // (4) Retire the dense id and stamp the label sentinel.
2966                self.ids.delete(key);
2967                if let Some(slot) = self.labels.get_mut(n as usize) {
2968                    *slot = u32::MAX;
2969                }
2970            }
2971            WalRecord::Batch(inner) => {
2972                // Apply each inner record in order through the same apply path.
2973                // Inner records are validated free of nested Batch by encode_record.
2974                for rec in inner {
2975                    self.apply(rec)?;
2976                }
2977            }
2978            WalRecord::RebuildRule { name } => {
2979                // Replay-over-snapshot idempotency: the snapshot may already
2980                // reflect a later delete_rule, so the rule is absent; skip.
2981                if !self.engine.rules().any(|r| r.name == *name) {
2982                    return Ok(());
2983                }
2984                let cursor = self.engine.pending_delta_count();
2985                let mut eng = std::mem::take(&mut self.engine);
2986                let result = {
2987                    let mut gm = make_graph_mut(
2988                        &self.ids,
2989                        &mut self.syms,
2990                        &self.labels,
2991                        build_props_view(&self.props, &self.base),
2992                        &mut self.topo,
2993                        &mut self.edge_props,
2994                    );
2995                    eng.rebuild(name, &mut gm)
2996                };
2997                self.engine = eng;
2998                result.map_err(|_| GraphError::RuleNotFound { name: name.clone() })?;
2999                // Derived-edge delta changes → view updates.
3000                if !self.view_store.is_empty() {
3001                    #[cfg(test)]
3002                    DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
3003                    let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
3004                    for d in &new_deltas {
3005                        self.view_store.on_edge_changed(
3006                            d.etype_sym,
3007                            d.src_id,
3008                            d.dst_id,
3009                            d.fired,
3010                            &mut self.props,
3011                            &build_topo_view(&self.topo, &self.base),
3012                            &self.ids,
3013                            &self.syms,
3014                            &self.labels,
3015                            self.base.as_ref().map(|b| {
3016                                b.columns()
3017                                    .expect("base columns section bounds validated at open")
3018                            }),
3019                        );
3020                    }
3021                }
3022            }
3023            WalRecord::CreateView { def_bytes } => {
3024                let def: ViewDef =
3025                    bincode::deserialize(def_bytes).map_err(|e| GraphError::Corrupt {
3026                        detail: format!("CreateView def_bytes deserialize failed: {e}"),
3027                    })?;
3028                // Replay-over-snapshot idempotency: view already present → skip.
3029                if self.view_store.has_view(&def.name) {
3030                    return Ok(());
3031                }
3032                self.view_store
3033                    .create_view(
3034                        def,
3035                        &mut self.props,
3036                        &build_topo_view(&self.topo, &self.base),
3037                        &self.ids,
3038                        &self.syms,
3039                        &self.labels,
3040                    )
3041                    .map_err(|e| GraphError::RuleInvalid { detail: e })?;
3042            }
3043            WalRecord::DeleteView { name } => {
3044                // Replay-over-snapshot idempotency: view already absent → skip.
3045                if !self.view_store.has_view(name) {
3046                    return Ok(());
3047                }
3048                self.view_store
3049                    .delete_view(name, &mut self.props, &self.ids, &self.labels, &self.syms)
3050                    .map_err(|_| GraphError::RuleNotFound { name: name.clone() })?;
3051            }
3052            WalRecord::EnableFulltext { label, field } => {
3053                // Replay-over-snapshot idempotency: already enabled → skip.
3054                if self.fulltext.is_enabled(label, field) {
3055                    return Ok(());
3056                }
3057                self.fulltext.enable(label, field);
3058                // Backfill: index all live nodes of this label that have the field.
3059                let n = self.ids.len() as u32;
3060                for id in 0..n {
3061                    let Some(&sym) = self.labels.get(id as usize) else {
3062                        continue;
3063                    };
3064                    if sym == u32::MAX {
3065                        continue; // tombstoned
3066                    }
3067                    let Some(lbl) = self.syms.resolve(sym) else {
3068                        continue;
3069                    };
3070                    if lbl != label {
3071                        continue;
3072                    }
3073                    if let Some(value) = build_props_view(&self.props, &self.base)
3074                        .get(id, field)
3075                        .map(|vr| vr.into_value())
3076                    {
3077                        self.fulltext.add_tokens(id, field, &value);
3078                    }
3079                }
3080            }
3081            WalRecord::DisableFulltext { label, field } => {
3082                // Replay-over-snapshot idempotency: already disabled → skip.
3083                if !self.fulltext.is_enabled(label, field) {
3084                    return Ok(());
3085                }
3086                // If another label still indexes this field, the postings column
3087                // is kept — but it must not contain node_ids from the now-disabled
3088                // label.  Remove them before calling disable() so the field_indexed
3089                // guard inside disable() sees the correct post-removal state.
3090                if self.fulltext.field_indexed_by_other(label, field) {
3091                    if let Some(label_sym) = self.syms.get(label) {
3092                        for (node_id, &lsym) in self.labels.iter().enumerate() {
3093                            if lsym == label_sym {
3094                                self.fulltext.remove_node_field(node_id as u32, field);
3095                            }
3096                        }
3097                    }
3098                }
3099                self.fulltext.disable(label, field);
3100            }
3101            WalRecord::EnableIndex { label, field } => {
3102                // Replay-over-snapshot idempotency: already enabled → skip.
3103                if self.prop_index.is_enabled(label, field) {
3104                    return Ok(());
3105                }
3106                self.prop_index.enable(label, field);
3107                // Backfill: index all live nodes of this label that have the field.
3108                let n = self.ids.len() as u32;
3109                for id in 0..n {
3110                    let Some(&sym) = self.labels.get(id as usize) else {
3111                        continue;
3112                    };
3113                    if sym == u32::MAX {
3114                        continue; // tombstoned
3115                    }
3116                    let Some(lbl) = self.syms.resolve(sym) else {
3117                        continue;
3118                    };
3119                    if lbl != label {
3120                        continue;
3121                    }
3122                    if let Some(value) = build_props_view(&self.props, &self.base)
3123                        .get(id, field)
3124                        .map(|vr| vr.into_value())
3125                    {
3126                        self.prop_index.set(label, field, id, &value);
3127                    }
3128                }
3129            }
3130            WalRecord::DisableIndex { label, field } => {
3131                self.prop_index.disable(label, field);
3132            }
3133            // History markers carry no replay state — rules re-derive edges
3134            // deterministically on open/replay. Skip unconditionally.
3135            WalRecord::DerivedEdgeAdded { .. } | WalRecord::DerivedEdgeRetracted { .. } => {}
3136            // ── rename_node ──────────────────────────────────────────────────
3137            WalRecord::RenameNode { old_key, new_key } => {
3138                // Recovery-safe: if old_key is already gone (key was renamed
3139                // by a snapshot or a prior replay frame), skip cleanly.
3140                if self.ids.get(old_key).is_none() {
3141                    return Ok(());
3142                }
3143                // The rename only updates the key-table; the dense id, all
3144                // topo edges, props, labels, and rule state are id-indexed and
3145                // require no change.
3146                self.ids
3147                    .rename(old_key, new_key)
3148                    .map_err(|e| GraphError::Corrupt {
3149                        detail: format!("wal replay RenameNode {old_key}→{new_key}: {e}"),
3150                    })?;
3151            }
3152        }
3153        Ok(())
3154    }
3155
3156    /// Intern `s` in `syms` and emit a WAL `Intern` record so `*Id` records
3157    /// replay on WAL-only `open_at` (no snapshot intern table). Apply is
3158    /// idempotent when the string is already bound. Always emit: after
3159    /// `snapshot()` the WAL is truncated and live intern is not on disk.
3160    fn intern_wal(&mut self, s: &str) -> (u32, WalRecord) {
3161        let id = if let Some(id) = self.syms.get(s) {
3162            id
3163        } else {
3164            self.syms.intern(s)
3165        };
3166        (
3167            id,
3168            WalRecord::Intern {
3169                id,
3170                text: s.to_string(),
3171            },
3172        )
3173    }
3174
3175    /// Rewrite user-facing records into dense-id records. On `Err`, no live
3176    /// state is left mutated: speculative interns made while building the
3177    /// output are rolled back, so a later successful mutation cannot log an
3178    /// `Intern` record whose id replay would never reproduce.
3179    fn rewrite_wal_dense(&mut self, recs: Vec<WalRecord>) -> Result<Vec<WalRecord>> {
3180        let syms_checkpoint = self.syms.len();
3181        let result = self.rewrite_wal_dense_inner(recs);
3182        if result.is_err() {
3183            self.syms.truncate(syms_checkpoint);
3184        }
3185        result
3186    }
3187
3188    fn rewrite_wal_dense_inner(&mut self, recs: Vec<WalRecord>) -> Result<Vec<WalRecord>> {
3189        let mut out = Vec::with_capacity(recs.len());
3190        // Node ids allocated by later apply(InsertNodeId) in this same batch.
3191        let mut pending: std::collections::HashMap<String, u32> = std::collections::HashMap::new();
3192        let mut interned = std::collections::HashSet::<u32>::new();
3193        let mut next = u32::try_from(self.ids.len()).map_err(|_| GraphError::Corrupt {
3194            detail: "id space exhausted".into(),
3195        })?;
3196        let lookup = |ids: &IdMap,
3197                      pending: &std::collections::HashMap<String, u32>,
3198                      key: &str|
3199         -> Option<u32> { ids.get(key).or_else(|| pending.get(key).copied()) };
3200        for rec in recs {
3201            match rec {
3202                WalRecord::InsertNode { label, key, props } => {
3203                    let (label_id, intern) = self.intern_wal(&label);
3204                    if interned.insert(label_id) {
3205                        out.push(intern);
3206                    }
3207                    let mut props_id = Vec::with_capacity(props.len());
3208                    for (field, value) in props {
3209                        let (field_id, intern) = self.intern_wal(&field);
3210                        if interned.insert(field_id) {
3211                            out.push(intern);
3212                        }
3213                        props_id.push((field_id, value));
3214                    }
3215                    if lookup(&self.ids, &pending, &key).is_none() {
3216                        pending.insert(key.clone(), next);
3217                        next = next.checked_add(1).ok_or_else(|| GraphError::Corrupt {
3218                            detail: "id space exhausted".into(),
3219                        })?;
3220                    }
3221                    out.push(WalRecord::InsertNodeId {
3222                        label: label_id,
3223                        key,
3224                        props: props_id,
3225                    });
3226                }
3227                WalRecord::SetProp { key, field, value } => {
3228                    let id =
3229                        lookup(&self.ids, &pending, &key).ok_or_else(|| GraphError::Corrupt {
3230                            detail: format!("dense WAL rewrite missing key {key}"),
3231                        })?;
3232                    let (field_id, intern) = self.intern_wal(&field);
3233                    if interned.insert(field_id) {
3234                        out.push(intern);
3235                    }
3236                    out.push(WalRecord::SetPropId {
3237                        id,
3238                        field: field_id,
3239                        value,
3240                    });
3241                }
3242                WalRecord::InsertEdge {
3243                    edge_type,
3244                    src_key,
3245                    dst_key,
3246                } => {
3247                    let (etype, intern) = self.intern_wal(&edge_type);
3248                    if interned.insert(etype) {
3249                        out.push(intern);
3250                    }
3251                    let src = lookup(&self.ids, &pending, &src_key).ok_or_else(|| {
3252                        GraphError::Corrupt {
3253                            detail: format!("dense WAL rewrite missing src {src_key}"),
3254                        }
3255                    })?;
3256                    let dst = lookup(&self.ids, &pending, &dst_key).ok_or_else(|| {
3257                        GraphError::Corrupt {
3258                            detail: format!("dense WAL rewrite missing dst {dst_key}"),
3259                        }
3260                    })?;
3261                    out.push(WalRecord::InsertEdgeId { etype, src, dst });
3262                }
3263                WalRecord::RenameNode {
3264                    ref old_key,
3265                    ref new_key,
3266                } => {
3267                    // Track the rename in `pending` so subsequent InsertEdge /
3268                    // SetProp records in this batch can resolve the new key.
3269                    let id = lookup(&self.ids, &pending, old_key).ok_or_else(|| {
3270                        GraphError::Corrupt {
3271                            detail: format!(
3272                                "dense WAL rewrite: RenameNode old key {old_key} not found"
3273                            ),
3274                        }
3275                    })?;
3276                    pending.remove(old_key.as_str());
3277                    pending.insert(new_key.clone(), id);
3278                    out.push(rec);
3279                }
3280                other => out.push(other),
3281            }
3282        }
3283        Ok(out)
3284    }
3285
3286    fn log_dense(&mut self, recs: Vec<WalRecord>) -> Result<()> {
3287        let recs = self.rewrite_wal_dense(recs)?;
3288        match recs.len() {
3289            0 => Ok(()),
3290            1 => self.log_then_apply(recs.into_iter().next().unwrap()),
3291            _ => self.log_then_apply(WalRecord::Batch(recs)),
3292        }
3293    }
3294
3295    /// Durable write, then notify the event sink. Replay (`apply` during
3296    /// `open`) never enters this function, so it is the replay-silent seam.
3297    fn log_then_apply(&mut self, rec: WalRecord) -> Result<()> {
3298        self.log_then_apply_with(rec, None, self.fsync)
3299    }
3300
3301    /// Whether this frame must fsync under `policy`.
3302    ///
3303    /// Batched contract: user-visible batches (>1 mutation) fsync; single
3304    /// mutations do not. The dense rewrite wraps a single mutation in a
3305    /// `Batch([Intern.., <one *Id record>])`, so `Intern` records are excluded
3306    /// from the count — removing that filter would make every single-op write
3307    /// fsync under Batched (or, if the threshold were raised instead, skip a
3308    /// needed fsync for real two-op batches).
3309    fn wal_needs_sync(policy: FsyncPolicy, rec: &WalRecord) -> bool {
3310        match policy {
3311            FsyncPolicy::Relaxed => false,
3312            FsyncPolicy::Strict => true,
3313            FsyncPolicy::Batched => match rec {
3314                // Intern + one mutation is the single-op rewrite, not a user batch.
3315                WalRecord::Batch(inner) => {
3316                    inner
3317                        .iter()
3318                        .filter(|r| !matches!(r, WalRecord::Intern { .. }))
3319                        .count()
3320                        > 1
3321                }
3322                _ => false,
3323            },
3324        }
3325    }
3326
3327    /// # Apply-infallibility invariant (load-bearing)
3328    ///
3329    /// The ordering is: WAL append → fsync → apply. If `apply` returned `Err`
3330    /// for a `Batch` frame after a successful WAL write, the WAL would contain
3331    /// the full frame while in-memory state would reflect only the ops before
3332    /// the failure. On reopen, WAL replay would then apply the entire batch —
3333    /// diverging permanently from what the pre-crash process had in memory.
3334    ///
3335    /// For `Batch` frames this situation cannot arise because:
3336    /// - All validation runs via `commit_logged_batch`/`MutPreview` **before**
3337    ///   the WAL write. `MutPreview` uses the same `&mut self` that apply will
3338    ///   use, with no concurrent mutation between validation exit and apply entry.
3339    /// - Every `apply` arm for a validated op is either infallible by construction
3340    ///   (`InsertNode`, `RemoveProp`, `DeleteEdge`, `DeleteNode`), has idempotency
3341    ///   guards that return `Ok(())` (`CreateRule`, `DeleteRule`), or is
3342    ///   guaranteed-present by validation (`InsertEdge`/`SetProp` key lookups).
3343    /// - `on_node_changed` and `on_node_removed` return `()` — never `Err`.
3344    ///
3345    /// A `debug_assert!` below fires in debug builds if `apply` ever returns
3346    /// `Err` for a `Batch` frame, making any future regression immediately visible
3347    /// in tests rather than silently diverging crash-recovery behaviour.
3348    fn log_then_apply_with(
3349        &mut self,
3350        rec: WalRecord,
3351        ingest: Option<(String, usize)>,
3352        policy: FsyncPolicy,
3353    ) -> Result<()> {
3354        // Read-only guard: as-of instances must never write the WAL.
3355        if self.read_only {
3356            return Err(GraphError::ReadOnly);
3357        }
3358        // Degraded guard: fsync failure left WAL truncated; in-memory state
3359        // is ahead of the on-disk WAL, so further mutations would deepen the
3360        // divergence.  Reopen the database to recover.
3361        if self.degraded {
3362            return Err(GraphError::Io(std::io::Error::other(
3363                "database degraded after group-commit fsync failure; reopen required",
3364            )));
3365        }
3366        // Ensure retained provenance bytes are decoded into the live mutable
3367        // fields before any mutation touches self.engine.provenance.  This is a
3368        // no-op if provenance was never stored (fresh store) or has already been
3369        // consumed (subsequent mutations).  WAL replay calls apply() directly
3370        // and is covered by consume_retained_state_eager before replay.
3371        self.ensure_v8_base_sections_loaded();
3372        self.engine.ensure_provenance_loaded_mut();
3373        // Invariant (I-1): no stale deltas may enter from a previous apply.
3374        // If any engine method ever accumulates deltas before erroring, they would
3375        // contaminate the *next* commit's event stream. This assert fires in debug
3376        // builds, making any future regression visible at the earliest point.
3377        debug_assert_eq!(
3378            self.engine.pending_delta_count(),
3379            0,
3380            "stale engine deltas at log_then_apply_with entry — \
3381             a previous apply arm may have accumulated deltas before erroring; \
3382             the caller must drain_deltas() on any error path before returning"
3383        );
3384        self.fs.append(FileId::Wal, &encode_record(&rec))?;
3385        if Self::wal_needs_sync(policy, &rec) {
3386            self.fs.sync(FileId::Wal)?;
3387        }
3388        // Marker writing always needs the engine deltas, but the engine only
3389        // accumulates them when emit_deltas is true (normally gated on subscribers
3390        // or views being present).  Enable emission for this apply if it is
3391        // currently off, then restore the original state unconditionally via an
3392        // RAII guard — this prevents a panic in apply() from leaking the flag.
3393        struct RestoreEmitDeltas(*mut RuleEngine, bool);
3394        impl Drop for RestoreEmitDeltas {
3395            fn drop(&mut self) {
3396                // SAFETY: pointer into self (GraphDb); guard is dropped within
3397                // this frame before log_then_apply_with returns.
3398                unsafe { (*self.0).set_emit_deltas(self.1) };
3399            }
3400        }
3401        let original_emit = self.engine.emit_deltas();
3402        if !original_emit {
3403            self.engine.set_emit_deltas(true);
3404        }
3405        // SAFETY: raw pointer into self; guard dropped within this frame.
3406        let _emit_guard = RestoreEmitDeltas(&mut self.engine as *mut _, original_emit);
3407
3408        let apply_result = self.apply(&rec);
3409        // For Batch frames, post-validation apply must be infallible (see above).
3410        // A debug_assert here catches any future change that makes apply fallible
3411        // before the caller notices via silent WAL/memory divergence.
3412        if matches!(&rec, WalRecord::Batch(_)) {
3413            debug_assert!(
3414                apply_result.is_ok(),
3415                "Batch apply returned Err after successful WAL write — \
3416                 the validate-then-apply invariant has been violated; \
3417                 see log_then_apply_with invariant doc"
3418            );
3419        }
3420        if apply_result.is_err() {
3421            // Discard any partial deltas accumulated by the failed apply.
3422            // They must not ride the next commit's event stream (I-1).
3423            // _emit_guard restores emit_deltas on drop automatically.
3424            let _ = self.engine.drain_deltas();
3425            let _ = self.engine.take_rebuild_needed();
3426            apply_result?;
3427        }
3428        self.commit_seq += 1;
3429        let seq = self.commit_seq;
3430        // Update per-node last-change map for the committed record.
3431        // Must happen after commit_seq is incremented so the seq is correct.
3432        self.update_last_change_from_rec(&rec, seq);
3433        // Drain engine deltas and distribute to subscribers before the existing
3434        // MutationEvent sink fires — both happen post-fsync, post-apply.
3435        // _emit_guard restores emit_deltas after this line when it drops.
3436        let engine_deltas = self.engine.drain_deltas();
3437
3438        // Append history-marker WAL records for any derived-edge changes so
3439        // that `edge_history` and `was_linked` can surface rule-attributed
3440        // events. Markers are STATE NO-OPS during replay; they are written
3441        // without an additional fsync (the triggering commit's sync already
3442        // happened; the next commit's sync covers these lazily).
3443        if !engine_deltas.is_empty() {
3444            let markers: Vec<WalRecord> = engine_deltas
3445                .iter()
3446                .map(|d| {
3447                    if d.fired {
3448                        WalRecord::DerivedEdgeAdded {
3449                            rule: d.rule.clone(),
3450                            edge_type: d.edge_type.clone(),
3451                            src_key: d.src_key.clone(),
3452                            dst_key: d.dst_key.clone(),
3453                        }
3454                    } else {
3455                        WalRecord::DerivedEdgeRetracted {
3456                            rule: d.rule.clone(),
3457                            edge_type: d.edge_type.clone(),
3458                            src_key: d.src_key.clone(),
3459                            dst_key: d.dst_key.clone(),
3460                        }
3461                    }
3462                })
3463                .collect();
3464            let marker_frame = if markers.len() == 1 {
3465                markers.into_iter().next().unwrap()
3466            } else {
3467                WalRecord::Batch(markers)
3468            };
3469            // Ignore append errors: markers are best-effort history
3470            // annotations. Losing them does not affect state correctness.
3471            let _ = self.fs.append(FileId::Wal, &encode_record(&marker_frame));
3472        }
3473
3474        // Record MVCC CommitDelta for the epoch reader.  The WAL record is
3475        // stored as-is (including any nested Batch / Intern records); the
3476        // ReaderSnapshot's apply_one function handles all variants.
3477        {
3478            let derived_inserts = engine_deltas
3479                .iter()
3480                .filter(|d| d.fired)
3481                .map(|d| (d.etype_sym, d.src_id, d.dst_id))
3482                .collect();
3483            let derived_deletes = engine_deltas
3484                .iter()
3485                .filter(|d| !d.fired)
3486                .map(|d| (d.etype_sym, d.src_id, d.dst_id))
3487                .collect();
3488            let delta = Arc::new(crate::reader::CommitDelta {
3489                records: vec![rec.clone()],
3490                derived_inserts,
3491                derived_deletes,
3492            });
3493            self.delta_tail.push(delta);
3494            self.commits_since_fold += 1;
3495            if self.commits_since_fold >= crate::reader::FOLD_EVERY_K {
3496                self.fold_now();
3497            }
3498        }
3499
3500        if self.defer_events {
3501            // Group-commit drain thread: hold events until after the group
3502            // fsync so subscribers only observe durable data (R2).
3503            self.deferred_events.push(DeferredEvent {
3504                rec: rec.clone(),
3505                engine_deltas,
3506                seq,
3507                ingest,
3508            });
3509        } else {
3510            self.distribute_events(&rec, &engine_deltas, seq);
3511            self.emit_committed(&rec, ingest);
3512        }
3513        // Drift is only known after apply, so auto-rebuild cannot join the
3514        // triggering op's WAL frame. Issue RebuildRule as a second commit.
3515        // Skip when `rec` is itself RebuildRule: rebuild resets drift, so a
3516        // retrigger loop is impossible if the fit succeeded, but we still
3517        // drain the flag so a leftover cannot re-enter.
3518        let rebuilds = self.engine.take_rebuild_needed();
3519        if !matches!(&rec, WalRecord::RebuildRule { .. }) {
3520            let mut failed = Vec::new();
3521            for name in rebuilds {
3522                if self.engine.rules().any(|r| r.name == name) {
3523                    // User op is already durable. A failed second commit must
3524                    // not surface as the caller's error.
3525                    if let Err(e) =
3526                        self.log_then_apply(WalRecord::RebuildRule { name: name.clone() })
3527                    {
3528                        eprintln!(
3529                            "auto-rebuild of rule {name:?} failed after durable user commit: {e}"
3530                        );
3531                        failed.push(name);
3532                    }
3533                }
3534            }
3535            for name in failed {
3536                self.engine.queue_rebuild_needed(name);
3537            }
3538        }
3539        Ok(())
3540    }
3541
3542    /// Install a post-commit hook. Replaces any previous sink.
3543    ///
3544    /// The sink runs inside `log_then_apply` after a successful
3545    /// durable commit, while the caller still holds `&mut self`. When this
3546    /// database is behind a [`crate::SharedDb`], that means the **write
3547    /// guard is held**. The sink must never call `read` / `write` (or any
3548    /// other method) on the same `SharedDb` — the `RwLock` is not
3549    /// re-entrant and doing so deadlocks. The sink is `Send + Sync`;
3550    /// `std::sync::mpsc::Sender` is not `Sync` and will not type-check.
3551    /// Intended examples: `std::sync::mpsc::SyncSender`,
3552    /// `tokio::sync::mpsc::Sender`, `tokio::sync::broadcast::Sender`
3553    /// (non-blocking `send`), or `Arc<Mutex<Vec<MutationEvent>>>`.
3554    pub fn set_event_sink(&mut self, sink: Box<dyn Fn(MutationEvent) + Send + Sync>) {
3555        self.event_sink = Some(sink);
3556    }
3557
3558    /// Whether a post-commit event sink is currently installed.
3559    pub fn has_event_sink(&self) -> bool {
3560        self.event_sink.is_some()
3561    }
3562
3563    /// Set WAL fsync cadence. Default [`FsyncPolicy::Strict`].
3564    pub fn set_fsync_policy(&mut self, p: FsyncPolicy) {
3565        self.fsync = p;
3566    }
3567
3568    /// Return the current WAL fsync cadence.
3569    pub fn fsync_policy(&self) -> FsyncPolicy {
3570        self.fsync
3571    }
3572
3573    // ── Group-commit event deferral ───────────────────────────────────────────
3574
3575    /// Enable or disable deferred event mode.
3576    ///
3577    /// When `true`, event notifications (subscription `DbEvent`s and legacy
3578    /// `MutationEvent` sink calls) are buffered rather than fired immediately.
3579    /// Call [`flush_deferred_events`] after the group fsync to deliver them,
3580    /// or [`discard_deferred_events`] if the fsync failed and the group must
3581    /// be treated as lost.
3582    pub fn set_deferred_events_mode(&mut self, defer: bool) {
3583        self.defer_events = defer;
3584    }
3585
3586    /// Fire all buffered events accumulated since [`set_deferred_events_mode`]
3587    /// was set to true.  Clears the buffer.
3588    ///
3589    /// Called by the drain thread AFTER a successful group fsync, so
3590    /// subscribers observe only data that is durably on disk.
3591    pub fn flush_deferred_events(&mut self) {
3592        let events = std::mem::take(&mut self.deferred_events);
3593        for de in events {
3594            self.distribute_events(&de.rec, &de.engine_deltas, de.seq);
3595            self.emit_committed(&de.rec, de.ingest);
3596        }
3597    }
3598
3599    /// Discard all buffered events without firing them.
3600    ///
3601    /// Called by the drain thread when a group fsync fails: the WAL has been
3602    /// truncated back to the pre-group offset, so the committed-but-unsynced
3603    /// ops must not be observable to subscribers.
3604    pub fn discard_deferred_events(&mut self) {
3605        self.deferred_events.clear();
3606    }
3607
3608    // ── Degraded state ────────────────────────────────────────────────────────
3609
3610    /// Mark this database as degraded.
3611    ///
3612    /// Called by the group-commit drain thread after a group fsync failure and
3613    /// WAL truncation: the in-memory state is now ahead of the on-disk WAL, so
3614    /// further mutations would deepen the divergence.  All subsequent calls to
3615    /// [`log_then_apply_with`] return `Err` until the database is reopened.
3616    pub fn set_degraded(&mut self) {
3617        self.degraded = true;
3618    }
3619
3620    fn emit(&self, ev: MutationEvent) {
3621        if let Some(sink) = &self.event_sink {
3622            sink(ev);
3623        }
3624    }
3625
3626    fn emit_committed(&self, rec: &WalRecord, ingest: Option<(String, usize)>) {
3627        match rec {
3628            WalRecord::Batch(inner) => {
3629                for r in inner {
3630                    if let Some(ev) = event_from_record(r, &self.syms, &self.ids) {
3631                        self.emit(ev);
3632                    }
3633                }
3634                match ingest {
3635                    Some((label, inserted)) => {
3636                        self.emit(MutationEvent::Ingested { label, inserted })
3637                    }
3638                    None => {
3639                        let ops = inner
3640                            .iter()
3641                            .filter(|r| !matches!(r, WalRecord::Intern { .. }))
3642                            .count();
3643                        if ops > 1 {
3644                            self.emit(MutationEvent::BatchApplied { ops });
3645                        }
3646                    }
3647                }
3648            }
3649            other => {
3650                if let Some(ev) = event_from_record(other, &self.syms, &self.ids) {
3651                    self.emit(ev);
3652                }
3653            }
3654        }
3655    }
3656
3657    // -----------------------------------------------------------------------
3658    // Subscription API
3659    // -----------------------------------------------------------------------
3660
3661    /// Distribute post-commit events to all live subscribers.
3662    ///
3663    /// Build a row-key → row-data map from a [`ResultSet`].
3664    ///
3665    /// Each row is serialized to JSON to form its key; a debug fallback is used
3666    /// if serialization fails. Used by both the initial-seed path in
3667    /// [`Self::subscribe_query`] and the per-commit diff path in
3668    /// [`Self::distribute_events`] to keep the two in sync.
3669    fn result_to_row_map(
3670        result: &core_query::ResultSet,
3671    ) -> std::collections::HashMap<String, Vec<Option<Value>>> {
3672        (0..result.len())
3673            .map(|i| {
3674                let row = result.row(i).to_vec();
3675                let key = serde_json::to_string(&row).unwrap_or_else(|_| format!("{row:?}"));
3676                (key, row)
3677            })
3678            .collect()
3679    }
3680
3681    /// Distribute post-commit events to all live subscribers.
3682    ///
3683    /// Called from `log_then_apply_with` after apply + fsync, before the
3684    /// legacy MutationEvent sink. Prunes dead `Weak` entries in-place.
3685    ///
3686    /// Query subscriptions (subscribe_query) re-execute their plan on every
3687    /// call and diff the result against the previous run. Zero overhead when
3688    /// no query subscriptions are active.
3689    fn distribute_events(&mut self, rec: &WalRecord, engine_deltas: &[EngineEdgeDelta], seq: u64) {
3690        if self.subscriptions.is_empty() && self.query_subscriptions.is_empty() {
3691            return;
3692        }
3693
3694        if !self.subscriptions.is_empty() {
3695            // Build write events from the WAL record.
3696            let write_events: Vec<DbEvent> =
3697                Self::write_events_from_record(rec, seq, &self.syms, &self.ids);
3698
3699            // Build edge events from engine deltas.  Weight is looked up from
3700            // edge_props at distribution time (after apply), so it's always fresh.
3701            let edge_events: Vec<DbEvent> = engine_deltas
3702                .iter()
3703                .map(|d| {
3704                    if d.fired {
3705                        let weight = self
3706                            .edge_props
3707                            .get(d.etype_sym, d.src_id, d.dst_id, "weight")
3708                            .and_then(|v| {
3709                                if let core_storage::Value::Float(f) = v {
3710                                    Some(*f)
3711                                } else {
3712                                    None
3713                                }
3714                            });
3715                        DbEvent::EdgeFired {
3716                            rule: d.rule.clone(),
3717                            src_key: d.src_key.clone(),
3718                            dst_key: d.dst_key.clone(),
3719                            edge_type: d.edge_type.clone(),
3720                            weight,
3721                            commit_seq: seq,
3722                        }
3723                    } else {
3724                        DbEvent::EdgeRetracted {
3725                            rule: d.rule.clone(),
3726                            src_key: d.src_key.clone(),
3727                            dst_key: d.dst_key.clone(),
3728                            edge_type: d.edge_type.clone(),
3729                            commit_seq: seq,
3730                        }
3731                    }
3732                })
3733                .collect();
3734
3735            // Prune dead entries; push matching events to live ones.
3736            self.subscriptions.retain(|entry| {
3737                let Some(inner) = entry.inner.upgrade() else {
3738                    return false;
3739                };
3740                for ev in &write_events {
3741                    if event_matches(ev, &entry.filter) {
3742                        inner.push(ev.clone());
3743                    }
3744                }
3745                for ev in &edge_events {
3746                    if event_matches(ev, &entry.filter) {
3747                        inner.push(ev.clone());
3748                    }
3749                }
3750                true
3751            });
3752
3753            // Turn off delta accumulation if all subscribers dropped and no views remain.
3754            if self.subscriptions.is_empty() && self.view_store.is_empty() {
3755                self.engine.set_emit_deltas(false);
3756            }
3757        }
3758
3759        // Query subscriptions: full re-run per commit, then diff rows.
3760        // IMPORTANT: full re-execution on every commit — use LIMIT to bound cost.
3761        // Differential evaluation is roadmap / Phase 5.
3762        if !self.query_subscriptions.is_empty() {
3763            // Take the list out so we can call self.view() without borrow conflict.
3764            let mut query_subs = std::mem::take(&mut self.query_subscriptions);
3765            let empty_params = BTreeMap::new();
3766            query_subs.retain_mut(|entry| {
3767                let Some(inner) = entry.inner.upgrade() else {
3768                    return false; // subscriber dropped — prune
3769                };
3770                let result = match execute(&self.view(), &entry.ops, &Params(&empty_params)) {
3771                    Ok(r) => r,
3772                    Err(e) => {
3773                        // Keep the subscription alive; skip the diff for this commit.
3774                        // Re-run errors are transient (e.g., planner change) and
3775                        // self-heal when the next commit succeeds.
3776                        eprintln!("[mushroomdb] subscribe_query re-run failed: {e}");
3777                        return true;
3778                    }
3779                };
3780                // Build new row map: serialized-key → row data.
3781                let new_row_map = Self::result_to_row_map(&result);
3782                // Removed rows: in prev but not in new.
3783                for (key, row) in &entry.prev_row_map {
3784                    if !new_row_map.contains_key(key) {
3785                        inner.push(DbEvent::QueryRowRemoved {
3786                            columns: entry.columns.clone(),
3787                            row: row.clone(),
3788                        });
3789                    }
3790                }
3791                // Added rows: in new but not in prev.
3792                for (key, row) in &new_row_map {
3793                    if !entry.prev_row_map.contains_key(key) {
3794                        inner.push(DbEvent::QueryRowAdded {
3795                            columns: entry.columns.clone(),
3796                            row: row.clone(),
3797                        });
3798                    }
3799                }
3800                entry.prev_row_map = new_row_map;
3801                true
3802            });
3803            self.query_subscriptions = query_subs;
3804        }
3805    }
3806
3807    /// Returns `true` if any live subscriber or view definition requires delta
3808    /// accumulation. Used to set `engine.emit_deltas` on subscribe/view DDL.
3809    fn needs_emit_deltas(&self) -> bool {
3810        !self.view_store.is_empty()
3811            || self
3812                .subscriptions
3813                .iter()
3814                .any(|e| e.inner.upgrade().is_some())
3815    }
3816
3817    /// Convert a WAL record into `DbEvent` write events with the given seq.
3818    fn write_events_from_record(
3819        rec: &WalRecord,
3820        seq: u64,
3821        intern: &Interner,
3822        ids: &IdMap,
3823    ) -> Vec<DbEvent> {
3824        match rec {
3825            WalRecord::InsertNode { label, key, .. } => vec![DbEvent::NodeInserted {
3826                label: label.clone(),
3827                key: key.clone(),
3828                commit_seq: seq,
3829            }],
3830            // *Id arms run after a successful apply, so resolution can only
3831            // fail on a programming error. Skip the event rather than emit a
3832            // fabricated "" that clients can't tell from a real empty value
3833            // (mirrors event_from_record returning None).
3834            WalRecord::InsertNodeId { label, key, .. } => intern
3835                .resolve(*label)
3836                .map(|label| DbEvent::NodeInserted {
3837                    label: label.to_string(),
3838                    key: key.clone(),
3839                    commit_seq: seq,
3840                })
3841                .into_iter()
3842                .collect(),
3843            WalRecord::SetProp { key, field, .. } => vec![DbEvent::PropSet {
3844                key: key.clone(),
3845                field: field.clone(),
3846                commit_seq: seq,
3847            }],
3848            WalRecord::SetPropId { id, field, .. } => ids
3849                .key_of(*id)
3850                .zip(intern.resolve(*field))
3851                .map(|(key, field)| DbEvent::PropSet {
3852                    key: key.to_string(),
3853                    field: field.to_string(),
3854                    commit_seq: seq,
3855                })
3856                .into_iter()
3857                .collect(),
3858            WalRecord::RemoveProp { key, field } => vec![DbEvent::PropRemoved {
3859                key: key.clone(),
3860                field: field.clone(),
3861                commit_seq: seq,
3862            }],
3863            WalRecord::InsertEdge {
3864                edge_type,
3865                src_key,
3866                dst_key,
3867            } => vec![DbEvent::EdgeInserted {
3868                edge_type: edge_type.clone(),
3869                src: src_key.clone(),
3870                dst: dst_key.clone(),
3871                commit_seq: seq,
3872            }],
3873            WalRecord::InsertEdgeId { etype, src, dst } => (|| {
3874                Some(DbEvent::EdgeInserted {
3875                    edge_type: intern.resolve(*etype)?.to_string(),
3876                    src: ids.key_of(*src)?.to_string(),
3877                    dst: ids.key_of(*dst)?.to_string(),
3878                    commit_seq: seq,
3879                })
3880            })()
3881            .into_iter()
3882            .collect(),
3883            WalRecord::DeleteEdge {
3884                edge_type,
3885                src_key,
3886                dst_key,
3887            } => vec![DbEvent::EdgeDeleted {
3888                edge_type: edge_type.clone(),
3889                src: src_key.clone(),
3890                dst: dst_key.clone(),
3891                commit_seq: seq,
3892            }],
3893            WalRecord::DeleteNode { key } => vec![DbEvent::NodeDeleted {
3894                key: key.clone(),
3895                commit_seq: seq,
3896            }],
3897            WalRecord::Batch(inner) => inner
3898                .iter()
3899                .flat_map(|r| Self::write_events_from_record(r, seq, intern, ids))
3900                .collect(),
3901            WalRecord::CreateRule { .. }
3902            | WalRecord::DeleteRule { .. }
3903            | WalRecord::RebuildRule { .. }
3904            | WalRecord::CreateView { .. }
3905            | WalRecord::DeleteView { .. }
3906            | WalRecord::EnableFulltext { .. }
3907            | WalRecord::DisableFulltext { .. }
3908            | WalRecord::EnableIndex { .. }
3909            | WalRecord::DisableIndex { .. }
3910            | WalRecord::Intern { .. }
3911            // History markers produce no DbEvent — the engine delta already
3912            // fired the EdgeFired/EdgeRetracted subscription events.
3913            | WalRecord::DerivedEdgeAdded { .. }
3914            | WalRecord::DerivedEdgeRetracted { .. }
3915            | WalRecord::RenameNode { .. } => vec![],
3916        }
3917    }
3918
3919    /// Subscribe to edge-fire and edge-retract events for one named rule.
3920    ///
3921    /// Returns `Err(GraphError::RuleNotFound)` if `rule_name` is not
3922    /// currently registered. Dropping the returned [`Subscription`] handle
3923    /// unregisters the subscriber — no further events are queued, no
3924    /// resources leak.
3925    pub fn subscribe_rule(&mut self, rule_name: &str) -> core_storage::Result<Subscription> {
3926        if self.read_only {
3927            return Err(core_storage::GraphError::ReadOnly);
3928        }
3929        if !self.engine.rules().any(|r| r.name == rule_name) {
3930            return Err(core_storage::GraphError::RuleNotFound {
3931                name: rule_name.to_string(),
3932            });
3933        }
3934        let inner = SubInner::new(self.sub_capacity());
3935        self.subscriptions.push(SubEntry {
3936            filter: SubFilter::Rule(rule_name.to_string()),
3937            inner: std::sync::Arc::downgrade(&inner),
3938        });
3939        self.engine.set_emit_deltas(true);
3940        Ok(Subscription(inner))
3941    }
3942
3943    /// Subscribe to edge-fire and edge-retract events for **all** rules.
3944    ///
3945    /// Returns `Err(GraphError::ReadOnly)` if called on an as-of instance —
3946    /// as-of instances never commit, so `distribute_events` never runs and the
3947    /// subscription would never deliver events.
3948    pub fn subscribe_all_rules(&mut self) -> core_storage::Result<Subscription> {
3949        if self.read_only {
3950            return Err(core_storage::GraphError::ReadOnly);
3951        }
3952        let inner = SubInner::new(self.sub_capacity());
3953        self.subscriptions.push(SubEntry {
3954            filter: SubFilter::AllRules,
3955            inner: std::sync::Arc::downgrade(&inner),
3956        });
3957        self.engine.set_emit_deltas(true);
3958        Ok(Subscription(inner))
3959    }
3960
3961    /// Subscribe to write events: node insert/delete, prop set/remove.
3962    ///
3963    /// Does not include edge-fire / edge-retract (rule-derived edge events).
3964    ///
3965    /// Returns `Err(GraphError::ReadOnly)` if called on an as-of instance —
3966    /// as-of instances never commit, so `distribute_events` never runs and the
3967    /// subscription would never deliver events.
3968    pub fn subscribe_writes(&mut self) -> core_storage::Result<Subscription> {
3969        if self.read_only {
3970            return Err(core_storage::GraphError::ReadOnly);
3971        }
3972        let inner = SubInner::new(self.sub_capacity());
3973        self.subscriptions.push(SubEntry {
3974            filter: SubFilter::Writes,
3975            inner: std::sync::Arc::downgrade(&inner),
3976        });
3977        self.engine.set_emit_deltas(true);
3978        Ok(Subscription(inner))
3979    }
3980
3981    /// Subscribe to incremental Cypher query results.
3982    ///
3983    /// Parses and plans `cypher`; rejects the query if the plan is not in the
3984    /// allowlisted subset (see [`core_query::cypher::is_subscribable`]):
3985    ///   - `MATCH (n:Label) WHERE … RETURN … [LIMIT n]`
3986    ///   - `MATCH (a)-[r:TYPE]->(b) RETURN … [LIMIT n]`  (exactly one hop)
3987    ///
3988    /// SKIP is not supported — it shifts the result window on every commit,
3989    /// causing spurious Added/Removed churn for rows whose data never changed.
3990    /// Multi-hop Expand chains are not supported; each additional MATCH clause
3991    /// widens scope beyond the documented single-scan / single-hop subset.
3992    ///
3993    /// After each successful commit, the plan is **fully re-executed** and the
3994    /// result is diffed against the previous run. Added rows produce
3995    /// [`DbEvent::QueryRowAdded`]; removed rows produce
3996    /// [`DbEvent::QueryRowRemoved`].
3997    ///
3998    /// **Full re-run per commit; use LIMIT to bound execution cost.**
3999    /// The existing 1 M intermediate-row cap applies. Differential evaluation
4000    /// is roadmap / Phase 5.
4001    ///
4002    /// Returns `Err(GraphError::ReadOnly)` if called on an as-of instance —
4003    /// as-of instances never commit, so `distribute_events` never runs and the
4004    /// subscription would never deliver events.
4005    ///
4006    /// Returns `Err(GraphError::QueryError)` if the query fails to parse, plan,
4007    /// or if the plan shape is not in the allowlist.
4008    pub fn subscribe_query(&mut self, cypher: &str) -> Result<Subscription> {
4009        if self.read_only {
4010            return Err(GraphError::ReadOnly);
4011        }
4012        let tokens = lex(cypher).map_err(|e| GraphError::QueryError {
4013            detail: format!("lex: {e}"),
4014        })?;
4015        let ast = parse(&tokens).map_err(|e| GraphError::QueryError {
4016            detail: format!("parse: {e}"),
4017        })?;
4018        let ops = plan(&ast).map_err(|e| GraphError::QueryError {
4019            detail: format!("plan: {e}"),
4020        })?;
4021        if !is_subscribable(&ops) {
4022            return Err(GraphError::QueryError {
4023                detail: "subscribe_query only supports allowlisted plan shapes: \
4024                         MATCH (n:Label) WHERE … RETURN … [LIMIT n] or \
4025                         MATCH (a)-[r:TYPE]->(b) RETURN … [LIMIT n] (exactly one hop). \
4026                         Not supported: multi-hop Expand chains, SKIP (creates \
4027                         unstable offset windows), ORDER BY, DISTINCT, aggregates, \
4028                         variable-length paths, OPTIONAL MATCH, WITH, UNWIND. \
4029                         Use LIMIT to bound re-execution cost."
4030                    .to_string(),
4031            });
4032        }
4033        // Execute once to capture initial state (initial rows are not emitted as
4034        // events — the subscriber learns the baseline via the first query call).
4035        let empty_params = BTreeMap::new();
4036        let initial = execute(&self.view(), &ops, &Params(&empty_params)).map_err(|e| {
4037            GraphError::QueryError {
4038                detail: format!("execute: {e}"),
4039            }
4040        })?;
4041        let columns = initial.columns().to_vec();
4042        let prev_row_map = Self::result_to_row_map(&initial);
4043        let inner = SubInner::new(self.sub_capacity());
4044        self.query_subscriptions.push(QuerySubEntry {
4045            ops,
4046            columns,
4047            prev_row_map,
4048            inner: std::sync::Arc::downgrade(&inner),
4049        });
4050        Ok(Subscription(inner))
4051    }
4052
4053    /// Queue capacity used for new subscriptions.
4054    fn sub_capacity(&self) -> usize {
4055        self.sub_capacity
4056    }
4057
4058    /// Override per-subscriber queue capacity for subsequently created
4059    /// subscriptions on this db instance.
4060    ///
4061    /// Default is [`DEFAULT_SUB_CAPACITY`] (65,536 events). Use a smaller
4062    /// value in tests to exercise the [`DbEvent::Lagged`] path without
4063    /// generating tens of thousands of events.
4064    ///
4065    /// This is a test-support escape hatch. Calling it in production reduces
4066    /// subscriber reliability (more Lagged events). It is hidden from rustdoc
4067    /// to discourage accidental production use.
4068    #[doc(hidden)]
4069    pub fn set_sub_capacity(&mut self, capacity: usize) {
4070        self.sub_capacity = capacity;
4071    }
4072
4073    // -----------------------------------------------------------------------
4074
4075    /// Start an atomic batch.
4076    ///
4077    /// The returned [`BatchBuilder`] borrows `self` mutably until
4078    /// [`BatchBuilder::commit`]. Builder methods queue ops only — no
4079    /// validation, no WAL I/O. `commit` validates every queued op against
4080    /// live state plus preceding ops in this batch (duplicate key inside
4081    /// the batch is `Err`; an edge between two nodes created earlier in
4082    /// the batch is valid; `delete_node` then insert of the same key is a
4083    /// fresh identity). Validation never mutates the database. Any failure
4084    /// leaves WAL bytes and in-memory state identical to before `commit`.
4085    /// On success, one `WalRecord::Batch` frame is appended (one fsync)
4086    /// and each inner record is applied in order so rules fire per record.
4087    /// An empty batch, or a batch of only no-ops, writes zero WAL bytes.
4088    ///
4089    /// **Rule-window limitation:** batch validation cannot see edges that a
4090    /// rule created earlier in the *same* batch will derive at apply time, so
4091    /// a `delete_edge` / `insert_edge` in that window is silently no-oped
4092    /// where sequential calls would return `Err(RuleOwned)`. State integrity
4093    /// is unaffected (idempotent apply, provenance intact). Create rules in
4094    /// their own batch, or sequentially, when later ops may touch derived
4095    /// edges.
4096    pub fn batch(&mut self) -> BatchBuilder<'_, F> {
4097        BatchBuilder {
4098            db: self,
4099            ops: Vec::new(),
4100        }
4101    }
4102
4103    /// Closure-style atomic write batch.
4104    ///
4105    /// Equivalent to calling [`GraphDb::batch`], invoking `build` to queue ops,
4106    /// then committing. All ops queued inside `build` are validated in order and
4107    /// committed as a single `WalRecord::Batch` frame (one fsync). Rules fire
4108    /// once per inner record, in order, after commit — semantically identical to
4109    /// sequential single-op writes.
4110    ///
4111    /// **Error semantics — validate-then-apply.** `build` queues ops without
4112    /// touching the database. [`BatchBuilder::commit`] validates every op against
4113    /// live state plus earlier ops in this batch before writing anything. If op N
4114    /// fails validation (duplicate key, unknown key, rule-owned edge, …) the
4115    /// entire batch is rejected: no WAL bytes are written and no in-memory state
4116    /// changes. The database is identical to its state before `write_batch` was
4117    /// called.
4118    ///
4119    /// **Atomicity is crash-level, NOT isolation-level.** On replay after a crash,
4120    /// a partial (torn) `Batch` frame applies NONE of its ops — the frame is
4121    /// either fully applied or not at all. However, while applying a committed
4122    /// batch, concurrent readers may observe intermediate states as ops are applied
4123    /// sequentially in memory. There is no interactive transaction isolation in v1.
4124    /// This is documented as "crash-atomic write batches; no interactive
4125    /// transactions or read isolation."
4126    ///
4127    /// **Returns** `(nodes_inserted, edges_inserted)`. An empty or all-noop batch
4128    /// writes zero WAL bytes and returns `(0, 0)`.
4129    ///
4130    /// # Example
4131    ///
4132    /// ```rust,ignore
4133    /// let (nodes, edges) = db.write_batch(|b| {
4134    ///     b.insert_node("Person", "alice", vec![("age".into(), Value::Int(30))]);
4135    ///     b.insert_node("Person", "bob", vec![]);
4136    ///     b.insert_edge("KNOWS", "alice", "bob");
4137    ///     b.set_prop("alice", "role", Value::Str("admin".into()));
4138    ///     b.delete_node("old_key");
4139    /// })?;
4140    /// // One fsync; on crash replay: all five ops land or none do.
4141    /// ```
4142    pub fn write_batch<C>(&mut self, build: C) -> Result<(usize, usize)>
4143    where
4144        C: FnOnce(&mut BatchBuilder<'_, F>),
4145    {
4146        let mut b = self.batch();
4147        build(&mut b);
4148        b.commit()
4149    }
4150
4151    /// Insert `rows` as nodes of `label`. One call is one atomic batch:
4152    /// auto-declared KeyMatch rules (if any) first, then the accepted node
4153    /// inserts, so incremental fire sees the new rules. Per-row key problems
4154    /// are collected in [`IngestReport::row_errors`] and skipped; a commit
4155    /// `Err` means nothing was applied.
4156    ///
4157    /// Auto-FK rule names are `auto_fk_<src_label_lowercase>_<field>` so
4158    /// distinct source labels sharing an FK field each get their own rule.
4159    pub fn ingest(
4160        &mut self,
4161        label: &str,
4162        rows: Vec<BTreeMap<String, Value>>,
4163        opts: &IngestOptions,
4164    ) -> Result<IngestReport> {
4165        self.ingest_with_edges(label, rows, opts, &[])
4166    }
4167
4168    /// [`ingest`] plus user edges in the **same** previewed WAL batch.
4169    /// A failing edge rejects the whole request; nothing is applied.
4170    pub fn ingest_with_edges(
4171        &mut self,
4172        label: &str,
4173        rows: Vec<BTreeMap<String, Value>>,
4174        opts: &IngestOptions,
4175        edges: &[(String, String, String)],
4176    ) -> Result<IngestReport> {
4177        crate::ingest::run(self, label, rows, opts, edges)
4178    }
4179
4180    /// Parse `json` as an array of objects and ingest via [`GraphDb::ingest`].
4181    ///
4182    /// JSON `null` fields are silently omitted (not stored, not a row error).
4183    /// Nested objects and arrays-of-objects are a per-row error (row skipped).
4184    /// Parse failures and a top-level value that is not an array of objects
4185    /// return [`GraphError::IngestError`].
4186    pub fn ingest_json(
4187        &mut self,
4188        label: &str,
4189        json: &str,
4190        opts: &IngestOptions,
4191    ) -> Result<IngestReport> {
4192        crate::ingest::run_json(self, label, json, opts)
4193    }
4194
4195    fn commit_logged_batch(
4196        &mut self,
4197        ops: Vec<BatchOp>,
4198        ingest: Option<(String, usize)>,
4199        // Two-source rule: write_batch_authz threads authz here directly (never
4200        // touches pending_write_authz); query_write_authz sets the field instead
4201        // and passes None.  Only one source is non-None per call.
4202        param_authz: Option<WriteAuthz>,
4203    ) -> Result<(usize, usize)> {
4204        // Read-only guard: catches empty-batch calls before the early-return
4205        // that skips log_then_apply_with, ensuring all mutation entry points fail.
4206        if self.read_only {
4207            return Err(GraphError::ReadOnly);
4208        }
4209        // Ensure provenance is decoded before MutPreview accesses it
4210        // (note_delete_rule / is_rule_owned may call engine.provenance()).
4211        self.engine.ensure_provenance_loaded_mut();
4212
4213        // ── Authz pre-check ──────────────────────────────────────────────────
4214        // Evaluate the decision table per-op BEFORE MutPreview so that a denial
4215        // produces no WAL frame (all-or-nothing at the authz boundary extends
4216        // the existing validate-then-apply contract to role-scope checks).
4217        //
4218        // `batch_created` tracks key→label for nodes created by earlier ops in
4219        // THIS batch, so InsertEdgeUpsert can count same-batch placeholder nodes
4220        // as visible without needing to call `self.ids.get` on not-yet-committed
4221        // keys (they won't be there yet).
4222        //
4223        // Two-source rule: param_authz (write_batch_authz path) takes precedence;
4224        // fall back to self.pending_write_authz (query_write_authz/Cypher path).
4225        // Cloning the field copy avoids a simultaneous borrow of self.ids below.
4226        let authz_opt = param_authz.or_else(|| self.pending_write_authz.clone());
4227        if let Some(ref authz) = authz_opt {
4228            let mut batch_created: BTreeMap<String, String> = BTreeMap::new();
4229            for op in &ops {
4230                self.check_single_op_authz(authz, op, &batch_created)?;
4231                // Update batch_created after a passing authz check so that
4232                // subsequent ops in this batch see the nodes as "about to exist".
4233                match op {
4234                    BatchOp::InsertNode { label, key, .. } => {
4235                        // Only track genuinely new nodes (absent from the
4236                        // snapshot at authz-check time). A pre-existing visible
4237                        // key would be a DuplicateKey — not a real creation —
4238                        // so MutPreview handles it. Letting it into batch_created
4239                        // would allow a later SetProp to bypass update_labels
4240                        // via the "batch-created → always updatable" ruling
4241                        // (delete+recreate exploit, fix for I1 review round 2).
4242                        //
4243                        // Accepted edge: for a delete+recreate-with-different-
4244                        // label batch, node_status resolves the pre-delete
4245                        // (store) label for any subsequent update checks. This
4246                        // grants no net-new capability — a role that can delete+
4247                        // create can already place arbitrary props via
4248                        // InsertNode's own props field.
4249                        if self.ids.get(key.as_str()).is_none() {
4250                            batch_created.insert(key.clone(), label.clone());
4251                        }
4252                    }
4253                    BatchOp::InsertEdgeUpsert {
4254                        placeholder_label,
4255                        src_key,
4256                        dst_key,
4257                        ..
4258                    } => {
4259                        // Both endpoints will be created if not already in store.
4260                        for ep_key in [src_key, dst_key] {
4261                            if self.ids.get(ep_key.as_str()).is_none()
4262                                && !batch_created.contains_key(ep_key.as_str())
4263                            {
4264                                batch_created.insert(ep_key.clone(), placeholder_label.clone());
4265                            }
4266                        }
4267                    }
4268                    _ => {}
4269                }
4270            }
4271        }
4272
4273        let recs = {
4274            let mut preview = MutPreview::new(self);
4275            let mut recs = Vec::with_capacity(ops.len());
4276            for op in ops {
4277                match op {
4278                    BatchOp::InsertNode { label, key, props } => {
4279                        preview.check_insert_node(&key)?;
4280                        preview.note_insert_node(&key, &props);
4281                        recs.push(WalRecord::InsertNode { label, key, props });
4282                    }
4283                    BatchOp::InsertEdge {
4284                        edge_type,
4285                        src_key,
4286                        dst_key,
4287                    } => {
4288                        if preview.prepare_insert_edge(&edge_type, &src_key, &dst_key)? {
4289                            preview.note_insert_edge(&edge_type, &src_key, &dst_key);
4290                            recs.push(WalRecord::InsertEdge {
4291                                edge_type,
4292                                src_key,
4293                                dst_key,
4294                            });
4295                        }
4296                    }
4297                    BatchOp::SetProp { key, field, value } => {
4298                        preview.check_live_key(&key)?;
4299                        preview.note_set_prop(&key, &field, &value);
4300                        recs.push(WalRecord::SetProp { key, field, value });
4301                    }
4302                    BatchOp::RemoveProp { key, field } => {
4303                        if preview.prepare_remove_prop(&key, &field)? {
4304                            preview.note_remove_prop(&key, &field);
4305                            recs.push(WalRecord::RemoveProp { key, field });
4306                        }
4307                    }
4308                    BatchOp::DeleteEdge {
4309                        edge_type,
4310                        src_key,
4311                        dst_key,
4312                    } => {
4313                        if preview.prepare_delete_edge(&edge_type, &src_key, &dst_key)? {
4314                            preview.note_delete_edge(&edge_type, &src_key, &dst_key);
4315                            recs.push(WalRecord::DeleteEdge {
4316                                edge_type,
4317                                src_key,
4318                                dst_key,
4319                            });
4320                        }
4321                    }
4322                    BatchOp::DeleteNode { key } => {
4323                        preview.check_live_key(&key)?;
4324                        preview.note_delete_node(&key);
4325                        recs.push(WalRecord::DeleteNode { key });
4326                    }
4327                    BatchOp::CreateRule(def) => {
4328                        preview.check_create_rule(&def)?;
4329                        let def_bytes =
4330                            bincode::serialize(&def).map_err(|e| GraphError::Corrupt {
4331                                detail: format!("serialize rule: {e}"),
4332                            })?;
4333                        preview.note_create_rule(&def.name);
4334                        recs.push(WalRecord::CreateRule { def_bytes });
4335                    }
4336                    BatchOp::DeleteRule { name } => {
4337                        preview.check_delete_rule(&name)?;
4338                        preview.note_delete_rule(&name);
4339                        recs.push(WalRecord::DeleteRule { name });
4340                    }
4341                    BatchOp::RenameNode { old_key, new_key } => {
4342                        preview.check_rename_node(&old_key, &new_key)?;
4343                        preview.note_rename_node(&old_key, &new_key);
4344                        recs.push(WalRecord::RenameNode { old_key, new_key });
4345                    }
4346                    BatchOp::InsertEdgeUpsert {
4347                        edge_type,
4348                        src_key,
4349                        dst_key,
4350                        placeholder_label,
4351                    } => {
4352                        // Auto-create any missing endpoints as plain InsertNode ops.
4353                        // Rules fire and last-change is updated for each created node.
4354                        for key in [&src_key, &dst_key] {
4355                            if !preview.has_key(key) {
4356                                preview.check_insert_node(key)?;
4357                                preview.note_insert_node(key, &[]);
4358                                recs.push(WalRecord::InsertNode {
4359                                    label: placeholder_label.clone(),
4360                                    key: key.clone(),
4361                                    props: vec![],
4362                                });
4363                            }
4364                        }
4365                        if preview.prepare_insert_edge(&edge_type, &src_key, &dst_key)? {
4366                            preview.note_insert_edge(&edge_type, &src_key, &dst_key);
4367                            recs.push(WalRecord::InsertEdge {
4368                                edge_type,
4369                                src_key,
4370                                dst_key,
4371                            });
4372                        }
4373                    }
4374                }
4375            }
4376            recs
4377        };
4378        if recs.is_empty() {
4379            return Ok((0, 0));
4380        }
4381        // rewrite_wal_dense converts every InsertNode/InsertEdge into its
4382        // *Id form, so only the dense variants can appear in `recs` here.
4383        let recs = self.rewrite_wal_dense(recs)?;
4384        let nodes_inserted = recs
4385            .iter()
4386            .filter(|r| matches!(r, WalRecord::InsertNodeId { .. }))
4387            .count();
4388        let edges_inserted = recs
4389            .iter()
4390            .filter(|r| matches!(r, WalRecord::InsertEdgeId { .. }))
4391            .count();
4392        // Ingest / write_batch / query_write: one Batch frame, one fsync per call
4393        // under Strict.  Pass self.fsync directly so Strict stays Strict —
4394        // wal_needs_sync(Strict, _) always returns true regardless of op count.
4395        // Mapping Strict → Batched (the prior bug) caused wal_needs_sync to
4396        // short-circuit on single-op batches and silently skip the fsync.
4397        // Batched fsyncs only for multi-op batches; Relaxed always skips.
4398        self.log_then_apply_with(WalRecord::Batch(recs), ingest, self.fsync)?;
4399        Ok((nodes_inserted, edges_inserted))
4400    }
4401
4402    fn commit_batch(&mut self, ops: Vec<BatchOp>) -> Result<(usize, usize)> {
4403        self.commit_logged_batch(ops, None, None)
4404    }
4405
4406    /// Commit one submission WITHOUT an fsync — for use inside `commit_group`
4407    /// and the group-commit drain thread, which do a single group fsync later.
4408    fn commit_batch_nosync(&mut self, ops: Vec<BatchOp>) -> Result<(usize, usize)> {
4409        // Restore fsync policy even on panic via a raw-pointer drop guard.
4410        // A panic here would poison the RwLock anyway, but the correct policy
4411        // must be in place if the guard is ever unwrapped.
4412        struct RestoreFsync(*mut FsyncPolicy, FsyncPolicy);
4413        impl Drop for RestoreFsync {
4414            fn drop(&mut self) {
4415                // SAFETY: the pointer is valid for the full duration of
4416                // commit_batch_nosync; the guard is dropped before the frame
4417                // returns, and GraphDb outlives this frame.
4418                unsafe {
4419                    *self.0 = self.1;
4420                }
4421            }
4422        }
4423        let saved = self.fsync;
4424        // SAFETY: raw pointer into self; guard dropped within this frame.
4425        let _g = RestoreFsync(&mut self.fsync as *mut FsyncPolicy, saved);
4426        self.fsync = FsyncPolicy::Relaxed;
4427        self.commit_logged_batch(ops, None, None)
4428    }
4429
4430    /// Commit multiple op-batches as a **group**: each submission gets its own
4431    /// WAL `Batch` frame, but there is exactly **one** `Fs::sync` for the whole
4432    /// group (under `Strict` / `Batched` policy; `Relaxed` skips all syncs).
4433    ///
4434    /// # Durability semantics
4435    ///
4436    /// A crash before the group fsync may lose **all** submissions in the group.
4437    /// A crash after the group fsync preserves all of them.  No submission is
4438    /// ever torn: each WAL frame is either fully applied on replay or dropped
4439    /// in its entirety (CRC-protected frame boundaries).
4440    ///
4441    /// Events and subscription notifications fire per-submission immediately
4442    /// after apply, which may be before the group fsync.  From a subscriber's
4443    /// perspective this is equivalent to the `Relaxed` durability window.
4444    /// Submitters using [`SharedDb::submit_batch`] only unblock after the group
4445    /// fsync, so from their perspective durability is fully guaranteed.
4446    ///
4447    /// # MVCC interplay
4448    ///
4449    /// Each submission records its own `CommitDelta`; the fold-every-K counter
4450    /// increments per submission (not per group), preserving existing reader
4451    /// snapshot semantics.
4452    ///
4453    /// # Returns
4454    ///
4455    /// One `Result<(nodes_inserted, edges_inserted)>` per input group element,
4456    /// in order.  Failures are per-submission (validation errors); the group
4457    /// fsync error (if any) is returned as the second tuple element.
4458    pub fn commit_group(
4459        &mut self,
4460        groups: Vec<Vec<BatchOp>>,
4461    ) -> (Vec<Result<(usize, usize)>>, Option<GraphError>) {
4462        let mut results = Vec::with_capacity(groups.len());
4463        for ops in groups {
4464            results.push(self.commit_batch_nosync(ops));
4465        }
4466        let any_ok = results.iter().any(|r| r.is_ok());
4467        let sync_err = if self.fsync != FsyncPolicy::Relaxed && any_ok {
4468            self.fs
4469                .sync(core_storage::fs::FileId::Wal)
4470                .map_err(GraphError::Io)
4471                .err()
4472        } else {
4473            None
4474        };
4475        (results, sync_err)
4476    }
4477
4478    /// Like [`commit_group`] but skips the group fsync entirely.
4479    ///
4480    /// Used by the drain thread to apply submissions under the write lock and
4481    /// then perform the single fsync OUTSIDE the lock (via
4482    /// `core_storage::sync_wal_at`), reducing the write-lock hold time visible
4483    /// to concurrent readers.
4484    pub fn commit_group_nosync(
4485        &mut self,
4486        groups: Vec<Vec<BatchOp>>,
4487    ) -> Vec<Result<(usize, usize)>> {
4488        let mut results = Vec::with_capacity(groups.len());
4489        for ops in groups {
4490            results.push(self.commit_batch_nosync(ops));
4491        }
4492        results
4493    }
4494
4495    pub fn insert_node(
4496        &mut self,
4497        label: &str,
4498        key: &str,
4499        props: Vec<(String, Value)>,
4500    ) -> Result<()> {
4501        if self.read_only {
4502            return Err(GraphError::ReadOnly);
4503        }
4504        MutPreview::new(self).check_insert_node(key)?;
4505        self.log_dense(vec![WalRecord::InsertNode {
4506            label: label.into(),
4507            key: key.into(),
4508            props,
4509        }])
4510    }
4511
4512    pub fn insert_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) -> Result<bool> {
4513        if self.read_only {
4514            return Err(GraphError::ReadOnly);
4515        }
4516        if !MutPreview::new(self).prepare_insert_edge(edge_type, src_key, dst_key)? {
4517            return Ok(false);
4518        }
4519        self.log_dense(vec![WalRecord::InsertEdge {
4520            edge_type: edge_type.into(),
4521            src_key: src_key.into(),
4522            dst_key: dst_key.into(),
4523        }])?;
4524        Ok(true)
4525    }
4526
4527    pub fn set_prop(&mut self, key: &str, field: &str, value: Value) -> Result<()> {
4528        if self.read_only {
4529            return Err(GraphError::ReadOnly);
4530        }
4531        if let Some(view_name) = self.view_store.view_for_prop(field) {
4532            return Err(GraphError::ViewPropReadOnly {
4533                view_name: view_name.to_string(),
4534            });
4535        }
4536        MutPreview::new(self).check_live_key(key)?;
4537        self.log_dense(vec![WalRecord::SetProp {
4538            key: key.into(),
4539            field: field.into(),
4540            value,
4541        }])
4542    }
4543
4544    /// Remove a property. Returns `Ok(false)` (and does not log) if the field
4545    /// is already absent. Unknown or tombstoned keys are `Err(KeyNotFound)`.
4546    pub fn remove_prop(&mut self, key: &str, field: &str) -> Result<bool> {
4547        if self.read_only {
4548            return Err(GraphError::ReadOnly);
4549        }
4550        if let Some(view_name) = self.view_store.view_for_prop(field) {
4551            return Err(GraphError::ViewPropReadOnly {
4552                view_name: view_name.to_string(),
4553            });
4554        }
4555        if !MutPreview::new(self).prepare_remove_prop(key, field)? {
4556            return Ok(false);
4557        }
4558        self.log_then_apply(WalRecord::RemoveProp {
4559            key: key.into(),
4560            field: field.into(),
4561        })?;
4562        Ok(true)
4563    }
4564
4565    /// Delete a user edge. Returns `Ok(false)` (and does not log) if the edge
4566    /// is absent. Unknown keys are `Err(KeyNotFound)`. Rule-owned edges — in
4567    /// provenance, or a pair a live rule would derive — are `Err(RuleOwned)`
4568    /// (the rule would just put the edge back; delete or change the rule).
4569    pub fn delete_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) -> Result<bool> {
4570        if self.read_only {
4571            return Err(GraphError::ReadOnly);
4572        }
4573        if !MutPreview::new(self).prepare_delete_edge(edge_type, src_key, dst_key)? {
4574            return Ok(false);
4575        }
4576        self.log_then_apply(WalRecord::DeleteEdge {
4577            edge_type: edge_type.into(),
4578            src_key: src_key.into(),
4579            dst_key: dst_key.into(),
4580        })?;
4581        Ok(true)
4582    }
4583
4584    /// Delete a live node. Unknown or already-tombstoned keys are
4585    /// `Err(KeyNotFound)` and are not logged. Validation runs before the WAL
4586    /// write; `apply` of a logged `DeleteNode` for an already-tombstoned key
4587    /// (crash window) is a clean no-op.
4588    ///
4589    /// Returns a [`DeleteReport`] with counts of manual and derived edges
4590    /// removed (computed from live state before the deletion is applied).
4591    pub fn delete_node(&mut self, key: &str) -> Result<DeleteReport> {
4592        if self.read_only {
4593            return Err(GraphError::ReadOnly);
4594        }
4595        // Provenance must be loaded before we query provenance_touching.
4596        self.engine.ensure_provenance_loaded_mut();
4597        let id = self
4598            .ids
4599            .get(key)
4600            .ok_or_else(|| GraphError::KeyNotFound { key: key.into() })?;
4601
4602        // Count edges before the delete is applied so we can report counts.
4603        let derived_set: BTreeSet<(u32, u32, u32)> = self
4604            .engine
4605            .provenance_touching(id)
4606            .map(|(_, etype, src, dst)| (etype, src, dst))
4607            .collect();
4608        let derived_edges = derived_set.len() as u64;
4609
4610        let mut total_topo = 0u64;
4611        let tv = self.topo_view();
4612        for et in tv.etypes() {
4613            total_topo += tv.neighbors(et, Direction::Out, id).len() as u64
4614                + tv.neighbors(et, Direction::In, id).len() as u64;
4615        }
4616        // For symmetric rules (e.g. Overlap), a→b and b→a are two separate directed
4617        // triples in both the topo scan (Out and In from id) and in provenance_touching.
4618        // The subtraction remains correct because both counts include both directions.
4619        let manual_edges = total_topo.saturating_sub(derived_edges);
4620
4621        self.log_then_apply(WalRecord::DeleteNode { key: key.into() })?;
4622        Ok(DeleteReport {
4623            manual_edges,
4624            derived_edges,
4625        })
4626    }
4627
4628    /// Rename a live node's key.  The dense id (and therefore all edges,
4629    /// props, history, and last-change tracking) is unaffected.
4630    ///
4631    /// Returns `Err(KeyNotFound)` if `old` is not a live key.
4632    /// Returns `Err(DuplicateKey)` if `new` is already live.
4633    pub fn rename_node(&mut self, old: &str, new: &str) -> Result<()> {
4634        if self.read_only {
4635            return Err(GraphError::ReadOnly);
4636        }
4637        MutPreview::new(self).check_rename_node(old, new)?;
4638        self.log_then_apply(WalRecord::RenameNode {
4639            old_key: old.into(),
4640            new_key: new.into(),
4641        })
4642    }
4643
4644    /// Return the IVF drift counter for the dst-side candidate index of `rule`.
4645    /// `None` if the rule does not exist or is not approximate.
4646    ///
4647    /// The drift counter increments on IVF insert/remove after the last fit.
4648    /// When dst-side drift exceeds [`core_rules::IVF_DRIFT_REBUILD`], apply
4649    /// WAL-logs `RebuildRule` as a second commit (rebuild resets the counter).
4650    pub fn ivf_dst_drift(&self, rule: &str) -> Option<u64> {
4651        // SideIvfExport = (centroids, node→cluster, drift)
4652        self.engine
4653            .export_ivf_state()
4654            .remove(rule)
4655            .map(|(_src, dst)| dst.2)
4656    }
4657
4658    /// Validate and WAL-log a new rule, then backfill derived edges inside apply.
4659    /// Validation and duplicate-name check run before logging so invalid rules
4660    /// never enter the WAL.
4661    pub fn create_rule(&mut self, def: RuleDef) -> Result<()> {
4662        if self.read_only {
4663            return Err(GraphError::ReadOnly);
4664        }
4665        MutPreview::new(self).check_create_rule(&def)?;
4666        let def_bytes = bincode::serialize(&def).map_err(|e| GraphError::Corrupt {
4667            detail: format!("serialize rule: {e}"),
4668        })?;
4669        self.log_then_apply(WalRecord::CreateRule { def_bytes })
4670    }
4671
4672    /// WAL-log rule deletion. Returns RuleNotFound if the rule does not exist.
4673    pub fn delete_rule(&mut self, name: &str) -> Result<()> {
4674        if self.read_only {
4675            return Err(GraphError::ReadOnly);
4676        }
4677        MutPreview::new(self).check_delete_rule(name)?;
4678        self.log_then_apply(WalRecord::DeleteRule { name: name.into() })
4679    }
4680
4681    /// Return a snapshot of all registered rules.
4682    pub fn rules(&self) -> Vec<RuleDef> {
4683        self.engine.rules().cloned().collect()
4684    }
4685
4686    // -----------------------------------------------------------------------
4687    // Rule suggestion API
4688    // -----------------------------------------------------------------------
4689
4690    /// Profile the database and suggest linking rules with previewed edge counts.
4691    ///
4692    /// Uses the default seed ([`core_rules::SUGGEST_DEFAULT_SEED`]) for deterministic
4693    /// sampling. Suggestions are sorted by estimated edge count (descending).
4694    /// **NO auto-accept** — call [`GraphDb::create_rule`] explicitly to apply.
4695    pub fn suggest_rules(&self) -> Vec<core_rules::RuleSuggestion> {
4696        self.suggest_rules_seeded(core_rules::SUGGEST_DEFAULT_SEED)
4697    }
4698
4699    /// Like [`suggest_rules`] but with a caller-supplied RNG seed for
4700    /// reproducibility. Same seed + same data = identical output.
4701    pub fn suggest_rules_seeded(&self, seed: u64) -> Vec<core_rules::RuleSuggestion> {
4702        self.suggest_rules_with_config(&core_rules::suggest::SuggestConfig::default(), seed)
4703            .suggestions
4704    }
4705
4706    /// [`suggest_rules_seeded`] with a fully custom [`SuggestConfig`].
4707    ///
4708    /// Returns a [`core_rules::SuggestReport`] that includes both the candidate list
4709    /// and a `truncated` flag indicating whether the global budget fired before all
4710    /// candidates were evaluated.
4711    pub fn suggest_rules_with_config(
4712        &self,
4713        config: &core_rules::suggest::SuggestConfig,
4714        seed: u64,
4715    ) -> core_rules::SuggestReport {
4716        use std::collections::BTreeMap;
4717
4718        // Collect (node_id, key) pairs per label, skipping tombstoned nodes.
4719        let mut label_nodes: BTreeMap<String, Vec<(u32, String)>> = BTreeMap::new();
4720        for id in 0..self.ids.len() as u32 {
4721            let Some(key) = self.ids.key_of(id) else {
4722                continue;
4723            };
4724            let Some(&sym) = self.labels.get(id as usize) else {
4725                continue;
4726            };
4727            if sym == u32::MAX {
4728                continue; // tombstoned
4729            }
4730            let Some(label) = self.syms.resolve(sym) else {
4731                continue;
4732            };
4733            label_nodes
4734                .entry(label.to_string())
4735                .or_default()
4736                .push((id, key.to_string()));
4737        }
4738
4739        let existing = self.rules();
4740        let pv = build_props_view(&self.props, &self.base);
4741        let all_fields: Vec<String> = pv.field_names();
4742
4743        core_rules::suggest::suggest_rules(
4744            &label_nodes,
4745            &|id, field| pv.get(id, field).map(|vr| vr.into_value()),
4746            &all_fields,
4747            &existing,
4748            config,
4749            seed,
4750        )
4751    }
4752
4753    /// Recompute a rule's derived edges from scratch. WAL-logged so un-trip
4754    /// plus later mutations replay identically (rebuild is a pure function
4755    /// of state).
4756    ///
4757    /// Only exit from the tripped latch: if the full desired set fits the
4758    /// budget, it is applied completely and `tripped` clears; if it still
4759    /// exceeds the budget, provenance is left untouched and `tripped` stays
4760    /// true. Counts as a fire evaluation (see [`RuleStats::fires`]).
4761    /// Unknown rule → `RuleNotFound`, nothing logged.
4762    pub fn rebuild_rule(&mut self, name: &str) -> Result<()> {
4763        if self.read_only {
4764            return Err(GraphError::ReadOnly);
4765        }
4766        if !self.engine.rules().any(|r| r.name == name) {
4767            return Err(GraphError::RuleNotFound { name: name.into() });
4768        }
4769        self.log_then_apply(WalRecord::RebuildRule { name: name.into() })
4770    }
4771
4772    // -----------------------------------------------------------------------
4773    // Materialized view API
4774    // -----------------------------------------------------------------------
4775
4776    /// Register a new materialized property view, backfill its values for all
4777    /// existing nodes, and WAL-log the definition.
4778    ///
4779    /// # Errors
4780    /// - `ReadOnly`: called on an as-of instance.
4781    /// - `RuleInvalid`: name collision, view_prop collision, or invalid def.
4782    pub fn create_view(&mut self, def: ViewDef) -> Result<()> {
4783        if self.read_only {
4784            return Err(GraphError::ReadOnly);
4785        }
4786        // Pre-validate before WAL write.
4787        def.validate()
4788            .map_err(|e| GraphError::RuleInvalid { detail: e })?;
4789        if self.view_store.has_view(&def.name) {
4790            return Err(GraphError::RuleInvalid {
4791                detail: format!("view {:?} already exists", def.name),
4792            });
4793        }
4794        if let Some(existing) = self.view_store.view_for_prop(&def.view_prop) {
4795            return Err(GraphError::RuleInvalid {
4796                detail: format!(
4797                    "view_prop {:?} is already used by view {:?}",
4798                    def.view_prop, existing
4799                ),
4800            });
4801        }
4802        let def_bytes = bincode::serialize(&def).map_err(|e| GraphError::Corrupt {
4803            detail: format!("serialize view: {e}"),
4804        })?;
4805        // Enable delta accumulation before the view is registered so subsequent
4806        // incremental edge events reach view maintenance from this point onward.
4807        // (The backfill inside create_view reads topo directly; it does not rely
4808        // on pending deltas.)
4809        self.engine.set_emit_deltas(true);
4810        self.log_then_apply(WalRecord::CreateView { def_bytes })
4811    }
4812
4813    /// Remove a named view and delete its values from every node.
4814    ///
4815    /// # Errors
4816    /// - `ReadOnly`: called on an as-of instance.
4817    /// - `RuleNotFound`: view does not exist.
4818    pub fn delete_view(&mut self, name: &str) -> Result<()> {
4819        if self.read_only {
4820            return Err(GraphError::ReadOnly);
4821        }
4822        if !self.view_store.has_view(name) {
4823            return Err(GraphError::RuleNotFound { name: name.into() });
4824        }
4825        let result = self.log_then_apply(WalRecord::DeleteView { name: name.into() });
4826        // After deletion, disable accumulation if no listeners remain.
4827        if !self.needs_emit_deltas() {
4828            self.engine.set_emit_deltas(false);
4829        }
4830        result
4831    }
4832
4833    /// Snapshot of all registered view definitions.
4834    pub fn views(&self) -> Vec<ViewDef> {
4835        self.view_store.views().cloned().collect()
4836    }
4837
4838    // -----------------------------------------------------------------------
4839    // Full-text-lite API
4840    // -----------------------------------------------------------------------
4841
4842    /// Enable full-text indexing for all nodes of `label` on property `field`.
4843    ///
4844    /// After this call, every subsequent write to `(label, field)` is reflected
4845    /// in the index incrementally.  Existing nodes are backfilled immediately.
4846    /// The declaration is persisted as a WAL record; the index itself is rebuilt
4847    /// from scratch on re-open (no snapshot format changes).
4848    ///
4849    /// # Errors
4850    /// - [`GraphError::ReadOnly`]: called on an as-of instance.
4851    /// - [`GraphError::RuleInvalid`]: `(label, field)` is already indexed.
4852    pub fn enable_fulltext(&mut self, label: &str, field: &str) -> Result<()> {
4853        if self.read_only {
4854            return Err(GraphError::ReadOnly);
4855        }
4856        if self.fulltext.is_enabled(label, field) {
4857            return Err(GraphError::RuleInvalid {
4858                detail: format!("full-text index for ({label:?}, {field:?}) already enabled"),
4859            });
4860        }
4861        self.log_then_apply(WalRecord::EnableFulltext {
4862            label: label.into(),
4863            field: field.into(),
4864        })
4865    }
4866
4867    /// Disable full-text indexing for `(label, field)` and drop its postings.
4868    ///
4869    /// # Errors
4870    /// - [`GraphError::ReadOnly`]: called on an as-of instance.
4871    /// - [`GraphError::RuleNotFound`]: `(label, field)` is not currently indexed.
4872    pub fn disable_fulltext(&mut self, label: &str, field: &str) -> Result<()> {
4873        if self.read_only {
4874            return Err(GraphError::ReadOnly);
4875        }
4876        if !self.fulltext.is_enabled(label, field) {
4877            return Err(GraphError::RuleNotFound {
4878                name: format!("fulltext({label},{field})"),
4879            });
4880        }
4881        self.log_then_apply(WalRecord::DisableFulltext {
4882            label: label.into(),
4883            field: field.into(),
4884        })
4885    }
4886
4887    /// Whether `(label, field)` is currently indexed for full-text search.
4888    pub fn is_fulltext_enabled(&self, label: &str, field: &str) -> bool {
4889        self.fulltext.is_enabled(label, field)
4890    }
4891
4892    /// Enable an equality index for all nodes of `label` on scalar property
4893    /// `field`. Subsequent `WHERE n.field = value` lookups become O(matches)
4894    /// instead of an O(N_label) scan. Existing nodes are backfilled; the
4895    /// declaration persists via WAL and the postings rebuild on re-open.
4896    ///
4897    /// # Errors
4898    /// - [`GraphError::ReadOnly`]: called on an as-of instance.
4899    /// - [`GraphError::RuleInvalid`]: `(label, field)` is already indexed.
4900    pub fn enable_index(&mut self, label: &str, field: &str) -> Result<()> {
4901        if self.read_only {
4902            return Err(GraphError::ReadOnly);
4903        }
4904        if self.prop_index.is_enabled(label, field) {
4905            return Err(GraphError::RuleInvalid {
4906                detail: format!("property index for ({label:?}, {field:?}) already enabled"),
4907            });
4908        }
4909        self.log_then_apply(WalRecord::EnableIndex {
4910            label: label.into(),
4911            field: field.into(),
4912        })
4913    }
4914
4915    /// Disable the equality index for `(label, field)` and drop its postings.
4916    ///
4917    /// # Errors
4918    /// - [`GraphError::ReadOnly`]: called on an as-of instance.
4919    /// - [`GraphError::RuleNotFound`]: `(label, field)` is not currently indexed.
4920    pub fn disable_index(&mut self, label: &str, field: &str) -> Result<()> {
4921        if self.read_only {
4922            return Err(GraphError::ReadOnly);
4923        }
4924        if !self.prop_index.is_enabled(label, field) {
4925            return Err(GraphError::RuleNotFound {
4926                name: format!("index({label},{field})"),
4927            });
4928        }
4929        self.log_then_apply(WalRecord::DisableIndex {
4930            label: label.into(),
4931            field: field.into(),
4932        })
4933    }
4934
4935    /// Whether `(label, field)` currently has an equality index.
4936    pub fn is_index_enabled(&self, label: &str, field: &str) -> bool {
4937        self.prop_index.is_enabled(label, field)
4938    }
4939
4940    /// Search a full-text-indexed field.
4941    ///
4942    /// Returns `(node_key, match_count)` pairs sorted by match_count descending,
4943    /// ties broken by key (lexicographic).  Tombstoned nodes are excluded.
4944    ///
4945    /// **Query syntax:**
4946    /// - Space-separated terms are AND'd: `"foo bar"` requires both.
4947    /// - `OR` between terms forms disjunction: `"foo OR bar"` matches either.
4948    /// - Trailing `*` on a term is a prefix match: `"rust*"` matches `rustlang`, `rusty`.
4949    /// - `AND` keyword is accepted explicitly and is the default.
4950    /// - Tokenization is unicode-alphanumeric (same as index time); case-insensitive.
4951    ///
4952    /// **Unindexed field:** returns `Ok(vec![])` if `field` is not indexed.
4953    /// Pin: this is the documented, tested, stable behavior for v1.
4954    ///
4955    /// **Memory / performance:** O(postings) lookup; no scan.  The index is
4956    /// in-memory and proportional to total indexed text across all enabled fields.
4957    ///
4958    /// **v2 grammar:** supports `"phrase"`, `-negation`, `prefix*`, `OR`, `AND`.
4959    /// Results are BM25-scored (k1=1.2, b=0.75) and sorted by score descending,
4960    /// key ascending for deterministic tiebreaking.
4961    pub fn search(&self, field: &str, query: &str) -> Vec<(String, f64)> {
4962        // Resolve node_ids to keys (excluding tombstones) then re-sort by
4963        // (score DESC, key ASC) to give a deterministic, key-lexicographic
4964        // tiebreak.  FulltextIndex::search sorts by (score DESC, node_id ASC)
4965        // which diverges from key order when nodes were not inserted in key-lex order.
4966        let mut results: Vec<(String, f64)> = self
4967            .fulltext
4968            .search(field, query, 0)
4969            .into_iter()
4970            .filter_map(|(id, score)| self.ids.key_of(id).map(|key| (key.to_string(), score)))
4971            .collect();
4972        results.sort_by(|a, b| {
4973            b.1.partial_cmp(&a.1)
4974                .unwrap_or(std::cmp::Ordering::Equal)
4975                .then(a.0.cmp(&b.0))
4976        });
4977        results
4978    }
4979
4980    /// Hybrid search: Reciprocal Rank Fusion (RRF) over fulltext + vector results.
4981    ///
4982    /// Takes up to `4*k` fulltext hits for `(text_field, query_text)` and up to
4983    /// `4*k` vector hits for `(vector_field, query_vec, min=0.0)`, then fuses
4984    /// them with RRF using a fixed constant of 60.
4985    ///
4986    /// ```text
4987    /// score(d) = Σ  1 / (60 + rank_i(d))    (rank 1-based per list)
4988    /// ```
4989    ///
4990    /// Returns the top `k` nodes by fused score, ties broken by node key
4991    /// ascending (deterministic).
4992    ///
4993    /// # Vector leg fallback
4994    ///
4995    /// When `query_vec` is empty the vector leg is skipped entirely and
4996    /// results are ranked by the text list alone through the same RRF path
4997    /// (each text result scores `1/(60 + rank)` from that single list).
4998    ///
4999    /// When `label` is `None`, the vector leg **always** returns empty results.
5000    /// Internally `label` is mapped to `""`, which does not match any rule-created
5001    /// HNSW index (all such indexes are keyed to a specific non-empty label), and
5002    /// the brute-force fallback finds no nodes with an empty label.  The fused
5003    /// ranking is therefore text-only in this case.
5004    pub fn search_hybrid(
5005        &self,
5006        text_field: &str,
5007        query_text: &str,
5008        vector_field: &str,
5009        query_vec: &[f64],
5010        label: Option<&str>,
5011        k: usize,
5012    ) -> Vec<(String, f64)> {
5013        use std::collections::HashMap;
5014
5015        const RRF_K: f64 = 60.0;
5016        let pool = 4 * k;
5017
5018        // Accumulate per-node RRF scores.
5019        let mut scores: HashMap<String, f64> = HashMap::new();
5020
5021        // Text leg.
5022        let text_hits = self.search(text_field, query_text);
5023        for (rank0, (key, _count)) in text_hits.into_iter().take(pool).enumerate() {
5024            let rank = (rank0 + 1) as f64;
5025            *scores.entry(key).or_insert(0.0) += 1.0 / (RRF_K + rank);
5026        }
5027
5028        // Vector leg (skipped when query_vec is empty).
5029        if !query_vec.is_empty() {
5030            let vec_hits = self.find_similar_vector(vector_field, label, query_vec, pool, 0.0);
5031            for (rank0, (key, _sim)) in vec_hits.into_iter().enumerate() {
5032                let rank = (rank0 + 1) as f64;
5033                *scores.entry(key).or_insert(0.0) += 1.0 / (RRF_K + rank);
5034            }
5035        }
5036
5037        // Sort: score DESC, then key ASC for deterministic tie-breaking.
5038        let mut ranked: Vec<(String, f64)> = scores.into_iter().collect();
5039        ranked.sort_by(|a, b| {
5040            b.1.partial_cmp(&a.1)
5041                .unwrap_or(std::cmp::Ordering::Equal)
5042                .then(a.0.cmp(&b.0))
5043        });
5044        ranked.truncate(k);
5045        ranked
5046    }
5047
5048    /// For DST/testing: scratch BM25 search over live nodes without the index.
5049    /// Walks every live node, re-stems field tokens, computes corpus stats, and
5050    /// returns BM25-ranked results.
5051    ///
5052    /// The oracle: the ordered key list of `search(field, q)` must equal that of
5053    /// `scratch_search(field, q)` at every quiescent state.
5054    #[doc(hidden)]
5055    pub fn scratch_search(&self, field: &str, query: &str) -> Vec<(String, f64)> {
5056        use core_storage::fulltext::{parse_query, value_tokens_stemmed_with_positions};
5057        use std::collections::BTreeMap;
5058
5059        let groups = parse_query(query);
5060        if groups.is_empty() {
5061            return vec![];
5062        }
5063
5064        // --- Pass 1: collect all live indexed nodes with stemmed token data ---
5065        struct NodeData {
5066            key: String,
5067            /// stemmed_token → positions (sorted)
5068            tokens: BTreeMap<String, Vec<u32>>,
5069            dl: u32,
5070        }
5071
5072        let mut nodes: Vec<NodeData> = Vec::new();
5073        for id in 0..self.ids.len() as u32 {
5074            let Some(key) = self.ids.key_of(id) else {
5075                continue;
5076            };
5077            let Some(&sym) = self.labels.get(id as usize) else {
5078                continue;
5079            };
5080            if sym == u32::MAX {
5081                continue;
5082            }
5083            let label = match self.syms.resolve(sym) {
5084                Some(l) => l,
5085                None => continue,
5086            };
5087            if !self.fulltext.is_enabled(label, field) {
5088                continue;
5089            }
5090            let Some(value) = self.props_view().get(id, field).map(|vr| vr.into_value()) else {
5091                continue;
5092            };
5093            // Use value_tokens_stemmed_with_positions so list elements are
5094            // separated by POSITION_GAP — identical to the index path, which
5095            // prevents phrase queries from matching across element boundaries.
5096            let stemmed_with_pos = match &value {
5097                Value::Str(_) | Value::List(_) => value_tokens_stemmed_with_positions(&value),
5098                _ => continue,
5099            };
5100            let dl = stemmed_with_pos.len() as u32;
5101            let mut tok_map: BTreeMap<String, Vec<u32>> = BTreeMap::new();
5102            for (tok, pos) in stemmed_with_pos {
5103                tok_map.entry(tok).or_default().push(pos);
5104            }
5105            nodes.push(NodeData {
5106                key: key.to_string(),
5107                tokens: tok_map,
5108                dl,
5109            });
5110        }
5111
5112        if nodes.is_empty() {
5113            return vec![];
5114        }
5115
5116        // --- BM25 corpus stats ---
5117        let n = nodes.len() as f64;
5118        let avg_dl: f64 = nodes.iter().map(|nd| nd.dl as f64).sum::<f64>() / n;
5119        // df per stemmed token across all live indexed nodes.
5120        let mut df_map: BTreeMap<&str, f64> = BTreeMap::new();
5121        for nd in &nodes {
5122            for tok in nd.tokens.keys() {
5123                *df_map.entry(tok.as_str()).or_insert(0.0) += 1.0;
5124            }
5125        }
5126
5127        const K1: f64 = 1.2;
5128        const B: f64 = 0.75;
5129
5130        // --- Pass 2: score each node against each OR-group ---
5131        let mut results: Vec<(String, f64)> = Vec::new();
5132        for nd in &nodes {
5133            let dl = nd.dl as f64;
5134            let mut total_score = 0.0f64;
5135
5136            'group: for group in &groups {
5137                let mut group_score = 0.0f64;
5138
5139                for term in group {
5140                    if term.negated {
5141                        // Negated: if doc has this stemmed token → group fails.
5142                        let present = if term.prefix {
5143                            nd.tokens.keys().any(|t| t.starts_with(term.token.as_str()))
5144                        } else {
5145                            nd.tokens.contains_key(term.token.as_str())
5146                        };
5147                        if present {
5148                            continue 'group;
5149                        }
5150                        continue;
5151                    }
5152                    if term.prefix {
5153                        // Prefix: sum BM25 for all matching stemmed tokens.
5154                        let mut prefix_matched = false;
5155                        for (tok, positions) in &nd.tokens {
5156                            if tok.starts_with(term.token.as_str()) {
5157                                let tf = positions.len() as f64;
5158                                let df = df_map.get(tok.as_str()).copied().unwrap_or(1.0);
5159                                let idf = ((n - df + 0.5) / (df + 0.5) + 1.0).ln();
5160                                let tf_norm =
5161                                    tf * (K1 + 1.0) / (tf + K1 * (1.0 - B + B * dl / avg_dl));
5162                                group_score += idf * tf_norm;
5163                                prefix_matched = true;
5164                            }
5165                        }
5166                        if !prefix_matched {
5167                            continue 'group;
5168                        }
5169                    } else {
5170                        // term.token is already stemmed by parse_query; use directly.
5171                        match nd.tokens.get(term.token.as_str()) {
5172                            None => continue 'group,
5173                            Some(positions) => {
5174                                let tf = positions.len() as f64;
5175                                let df = df_map.get(term.token.as_str()).copied().unwrap_or(1.0);
5176                                let idf = ((n - df + 0.5) / (df + 0.5) + 1.0).ln();
5177                                let tf_norm =
5178                                    tf * (K1 + 1.0) / (tf + K1 * (1.0 - B + B * dl / avg_dl));
5179                                group_score += idf * tf_norm;
5180                            }
5181                        }
5182                    }
5183                }
5184
5185                if group_score > 0.0 {
5186                    total_score += group_score;
5187                }
5188            }
5189
5190            if total_score > 0.0 {
5191                results.push((nd.key.clone(), total_score));
5192            }
5193        }
5194
5195        results.sort_by(|a, b| {
5196            b.1.partial_cmp(&a.1)
5197                .unwrap_or(std::cmp::Ordering::Equal)
5198                .then(a.0.cmp(&b.0))
5199        });
5200        results
5201    }
5202
5203    /// Return the current view-maintained value of `view_prop` for node `key`.
5204    /// Equivalent to `get_prop` but documents that it reads a view-managed column.
5205    pub fn get_view_prop(&self, key: &str, view_prop: &str) -> Option<Value> {
5206        let id = self.ids.get(key)?;
5207        self.props_view()
5208            .get(id, view_prop)
5209            .map(|vr| vr.into_value())
5210    }
5211
5212    /// For testing / DST oracle: scratch recompute of a view value for one node.
5213    ///
5214    /// Returns `None` if the node does not exist, the view does not exist, or
5215    /// the view has no result for the node (e.g. Avg with no qualifying neighbors).
5216    #[doc(hidden)]
5217    pub fn scratch_view_value(&self, key: &str, view_name: &str) -> Option<Value> {
5218        let node = self.ids.get(key)?;
5219        let def = self.view_store.views().find(|v| v.name == view_name)?;
5220        // Use TopologyView so that NeighborAgg sees base + overlay edges
5221        // without materialising a temporary Topology (I1).
5222        let topo_view = self.topo_view();
5223        core_rules::views::compute_view_value(
5224            def,
5225            node,
5226            self.props_view(),
5227            &topo_view,
5228            &self.ids,
5229            &self.syms,
5230            &self.labels,
5231        )
5232    }
5233
5234    // -----------------------------------------------------------------------
5235    // Graph algorithm API
5236    // -----------------------------------------------------------------------
5237
5238    /// Run PageRank over the unified topology (manual + derived edges).
5239    ///
5240    /// Returns a [`PageRankReport`] with scores sorted descending (ties: key
5241    /// ascending).  Set `config.edge_type` to restrict to one edge type.
5242    /// `config.converged` is `true` only when the power iteration converged
5243    /// within `config.max_iters` and within any time budget.
5244    pub fn pagerank(&self, config: &crate::algo::PageRankConfig) -> crate::algo::PageRankReport {
5245        let topo = build_topo_view(&self.topo, &self.base);
5246        crate::algo::pagerank(&topo, &self.ids, &self.syms, &self.labels, config)
5247    }
5248
5249    /// Weakly-connected components over the unified topology (treated as
5250    /// undirected regardless of how edges were inserted).
5251    ///
5252    /// Component IDs are the key of the smallest member in the component
5253    /// (deterministic).  Result sorted by (component_id, key).
5254    pub fn connected_components(&self, config: &crate::algo::WccConfig) -> crate::algo::WccReport {
5255        let topo = build_topo_view(&self.topo, &self.base);
5256        crate::algo::wcc(&topo, &self.ids, &self.syms, &self.labels, config)
5257    }
5258
5259    /// Degree centrality for every live node.
5260    ///
5261    /// `direction`: `AlgoDir::Out` = out-degree, `AlgoDir::In` = in-degree,
5262    /// `AlgoDir::Both` = out + in (total directed degree).
5263    ///
5264    /// For one-shot ranking use this; for a live property updated on every
5265    /// write, create a Degree materialized view instead (see `docs/site/algorithms.md`).
5266    pub fn degree_centrality(
5267        &self,
5268        config: &crate::algo::DegreeConfig,
5269    ) -> crate::algo::DegreeReport {
5270        let topo = build_topo_view(&self.topo, &self.base);
5271        crate::algo::degree_centrality(&topo, &self.ids, &self.syms, &self.labels, config)
5272    }
5273
5274    /// Write a vector of `(node_key, score)` pairs as `prop_name` on each node,
5275    /// atomically via a single write-batch (one WAL frame, one fsync).
5276    ///
5277    /// # Errors
5278    /// - [`GraphError::ReadOnly`]: called on an as-of instance.
5279    /// - [`GraphError::RuleInvalid`]: `prop_name` is managed by an existing view
5280    ///   (collision check mirrors `create_view`).
5281    /// - [`GraphError::KeyNotFound`]: a key in `scores` does not exist as a live node.
5282    pub fn write_scores(&mut self, prop_name: &str, scores: &[(String, f64)]) -> Result<()> {
5283        if self.read_only {
5284            return Err(GraphError::ReadOnly);
5285        }
5286        // Collision check: refuse if prop_name is view-managed.
5287        if let Some(view_name) = self.view_store.view_for_prop(prop_name) {
5288            return Err(GraphError::RuleInvalid {
5289                detail: format!(
5290                    "prop {:?} is managed by view {:?} and cannot be written as scores",
5291                    prop_name, view_name
5292                ),
5293            });
5294        }
5295        // Refuse if prop_name is a view name itself (confusing namespace collision).
5296        if self.view_store.has_view(prop_name) {
5297            return Err(GraphError::RuleInvalid {
5298                detail: format!(
5299                    "prop_name {:?} collides with an existing view name",
5300                    prop_name
5301                ),
5302            });
5303        }
5304        // Write all scores in a single crash-atomic batch.
5305        self.write_batch(|b| {
5306            for (key, score) in scores {
5307                b.set_prop(key, prop_name, Value::Float(*score));
5308            }
5309        })?;
5310        Ok(())
5311    }
5312
5313    /// Return the value of `field` for the node with key `key`, or `None` if
5314    /// the node or field is absent.  Reads through the overlay-over-base
5315    /// `ColumnsView`, materialising base values on demand (zero heap cost for
5316    /// overlay hits; one clone per base hit).
5317    pub fn get_prop(&self, key: &str, field: &str) -> Option<Value> {
5318        let id = self.ids.get(key)?;
5319        self.props_view().get(id, field).map(|vr| vr.into_value())
5320    }
5321
5322    pub fn has_node(&self, key: &str) -> bool {
5323        self.ids.get(key).is_some()
5324    }
5325
5326    /// Borrow the raw id map. Used by `NodeMask::from_keys` to resolve keys.
5327    pub(crate) fn ids(&self) -> &IdMap {
5328        &self.ids
5329    }
5330
5331    // -----------------------------------------------------------------------
5332    // RBAC role resolution
5333    // -----------------------------------------------------------------------
5334
5335    /// Parse `roles.json` bytes from `fs`.
5336    ///
5337    /// Return values:
5338    ///   `Ok(Some(roles))` — file absent (returns `vec![]`) **or** file present
5339    ///                       and valid; in both cases `mask_for_role` uses the
5340    ///                       list normally (an absent file means no roles defined).
5341    ///   `Ok(None)`        — file present but corrupt or unrecognised version
5342    ///                       → poisoned state; `mask_for_role` returns `Err` for
5343    ///                       any role name until the file is fixed and the DB
5344    ///                       re-opened (or `apply_schema` is called to repair it).
5345    ///
5346    /// Note: `None` signals corruption, not absence — the opposite of what an
5347    /// optional "file missing" convention would suggest.  The open path stores
5348    /// this result on `db.roles` directly.
5349    fn load_roles_from_fs(fs: &F) -> Result<Option<Vec<RoleDef>>> {
5350        let bytes = fs.read(FileId::Roles).map_err(GraphError::Io)?;
5351        if bytes.is_empty() {
5352            // Empty bytes means either the file is absent or zero-byte — both
5353            // are treated identically as "no roles defined".  A zero-byte
5354            // roles.json does NOT widen access: an absent file and a zero-byte
5355            // file both resolve to an empty role list (sees nothing by default).
5356            return Ok(Some(vec![]));
5357        }
5358        match serde_json::from_slice::<RolesFile>(&bytes) {
5359            Ok(f) if f.version == 1 || f.version == 2 => Ok(Some(f.roles)),
5360            // Corrupt or unrecognised version (>2): poison the roles state.
5361            _ => Ok(None),
5362        }
5363    }
5364
5365    /// Resolve a role to a node-visibility mask against the current graph state.
5366    ///
5367    /// Returns `Err` when:
5368    /// - `roles.json` was present but corrupt at open (poisoned state), or
5369    /// - `role` does not match any defined role name.
5370    ///
5371    /// The mask union is: explicit `keys` (unknown keys silently ignored) plus
5372    /// all live nodes carrying any label in `labels`.  Label resolution is live
5373    /// — new nodes of an allowed label are visible without re-applying the
5374    /// schema.  An empty union = empty mask = sees nothing.
5375    pub fn mask_for_role(&self, role: &str) -> Result<crate::mask::NodeMask> {
5376        let roles = self.roles.as_ref().ok_or_else(|| GraphError::Corrupt {
5377            detail:
5378                "roles.json was corrupt at open; fix the file and re-open to restore role access"
5379                    .into(),
5380        })?;
5381        let def = roles
5382            .iter()
5383            .find(|r| r.name == role)
5384            .ok_or_else(|| GraphError::KeyNotFound {
5385                key: format!("role:{role}"),
5386            })?;
5387
5388        let mut visible = std::collections::HashSet::new();
5389
5390        // Key leg: resolve explicit keys to dense ids (unknown keys ignored).
5391        for key in &def.keys {
5392            if let Some(id) = self.ids.get(key) {
5393                visible.insert(id);
5394            }
5395        }
5396
5397        // Label leg: live scan — iterate labels vec for matching symbol.
5398        for label_name in &def.labels {
5399            if let Some(sym) = self.syms.get(label_name) {
5400                for (i, &s) in self.labels.iter().enumerate() {
5401                    if s == sym {
5402                        visible.insert(i as u32);
5403                    }
5404                }
5405            }
5406        }
5407
5408        Ok(crate::mask::NodeMask::from_ids(visible))
5409    }
5410
5411    /// Return the current list of role definitions.
5412    ///
5413    /// Returns an empty list when no roles are defined or when `roles.json`
5414    /// was corrupt at open (check [`mask_for_role`](Self::mask_for_role) for
5415    /// the fail-loud error in that case).
5416    pub fn roles(&self) -> Vec<RoleDef> {
5417        self.roles.as_deref().unwrap_or(&[]).to_vec()
5418    }
5419
5420    // ── Role-scoped write authz ───────────────────────────────────────────────
5421
5422    /// Execute `ops` with optional role-scoped write authorization.
5423    ///
5424    /// - `None` → full authority, identical to [`write_batch`](Self::write_batch)
5425    ///   (zero-cost bypass of all authz checks).
5426    /// - `Some(authz)` → the decision table is evaluated per-op BEFORE any WAL
5427    ///   record is built.  A denial returns an error with no WAL frame written
5428    ///   (all-or-nothing at the authz boundary, then at the MutPreview boundary).
5429    ///
5430    /// See the plan's "authz decision table" section for the full semantics.
5431    pub fn write_batch_authz(
5432        &mut self,
5433        authz: Option<&WriteAuthz>,
5434        ops: Vec<BatchOp>,
5435    ) -> Result<(usize, usize)> {
5436        // Thread authz as a direct parameter — never touches pending_write_authz.
5437        self.commit_logged_batch(ops, None, authz.cloned())
5438    }
5439
5440    /// Execute a Cypher write statement with role-scoped write authorization.
5441    ///
5442    /// Resolves scope + mask from `self.roles` inside the call (same write-guard
5443    /// lifetime as execution, satisfying §5 lock discipline).  The resolved
5444    /// `WriteAuthz` is stored as `pending_write_authz` for the duration of the
5445    /// call so that all inner `batch.commit()` calls are authz-checked.
5446    ///
5447    /// MERGE is handled specially: the MERGE scope precondition (§3.3) is
5448    /// checked in `exec_merge` BEFORE `has_node` to close the §6.2
5449    /// timing-oracle item (hidden ≡ absent for unscoped roles).
5450    ///
5451    /// Roles with `write: None` (v1 behavior) → `RoleWriteDenied` with
5452    /// "this endpoint is not permitted".
5453    pub fn query_write_authz(
5454        &mut self,
5455        role: &str,
5456        cypher: &str,
5457        params: &BTreeMap<String, Value>,
5458    ) -> Result<ResultSet> {
5459        // Resolve scope (fails fast if role has no write scope).
5460        // write:None → byte-identical v1 blanket-403 body (plan §v1-sidecar mandate).
5461        let scope =
5462            {
5463                let roles = self.roles.as_deref().ok_or_else(|| GraphError::Corrupt {
5464                    detail: "roles.json was corrupt at open; re-open to restore role access".into(),
5465                })?;
5466                let def = roles.iter().find(|r| r.name == role).ok_or_else(|| {
5467                    GraphError::KeyNotFound {
5468                        key: format!("role:{role}"),
5469                    }
5470                })?;
5471                def.write
5472                    .clone()
5473                    .ok_or_else(|| GraphError::RoleWriteDenied {
5474                        reason: "role-bound token: writes are not permitted".into(),
5475                    })?
5476            };
5477        // Resolve mask inside the call (same guard, §5 coherence).
5478        let mask = self.mask_for_role(role)?;
5479        self.pending_write_authz = Some(WriteAuthz {
5480            role: role.into(),
5481            scope,
5482            mask,
5483        });
5484        // RAII guard: always clears pending_write_authz on scope exit, including
5485        // on panic or early-return, mirroring the RestoreEmitDeltas precedent.
5486        struct ClearPendingAuthzOnDrop(*mut Option<WriteAuthz>);
5487        impl Drop for ClearPendingAuthzOnDrop {
5488            fn drop(&mut self) {
5489                // SAFETY: pointer into the owning GraphDb; guard is dropped
5490                // within this function's frame before it returns.
5491                unsafe { *self.0 = None };
5492            }
5493        }
5494        // SAFETY: raw pointer into self; guard dropped before this fn returns.
5495        let _authz_guard = ClearPendingAuthzOnDrop(&mut self.pending_write_authz as *mut _);
5496        let tokens = lex(cypher).map_err(|e| GraphError::QueryError {
5497            detail: format!("lex: {e}"),
5498        })?;
5499        let stmt = parse_write(&tokens).map_err(|e| GraphError::QueryError {
5500            detail: format!("parse: {e}"),
5501        })?;
5502        self.exec_write_stmt(stmt, params)
5503    }
5504
5505    /// Execute `ops` with optional role-scoped write authorization, suppressing
5506    /// fsync (for use inside the group-commit drain thread, which performs one
5507    /// group fsync after releasing the write lock).
5508    ///
5509    /// Identical to [`write_batch_authz`] except the fsync policy is temporarily
5510    /// forced to `Relaxed` for the duration of the call, matching the drain-thread
5511    /// contract established by [`commit_batch_nosync`].
5512    pub(crate) fn write_batch_authz_nosync(
5513        &mut self,
5514        authz: Option<&WriteAuthz>,
5515        ops: Vec<BatchOp>,
5516    ) -> Result<(usize, usize)> {
5517        let saved = self.fsync;
5518        struct RestoreFsync(*mut FsyncPolicy, FsyncPolicy);
5519        impl Drop for RestoreFsync {
5520            fn drop(&mut self) {
5521                // SAFETY: pointer into the owning GraphDb; guard is dropped
5522                // within the enclosing function's frame before it returns.
5523                unsafe { *self.0 = self.1 };
5524            }
5525        }
5526        // SAFETY: raw pointer into self; guard dropped before this fn returns.
5527        let _g = RestoreFsync(&mut self.fsync as *mut FsyncPolicy, saved);
5528        self.fsync = FsyncPolicy::Relaxed;
5529        self.commit_logged_batch(ops, None, authz.cloned())
5530    }
5531
5532    /// Execute a `/ingest` request with role-scoped write authorization.
5533    ///
5534    /// Resolves the role's `WriteScope` and `NodeMask` inside this call (same
5535    /// write-guard lifetime as the mutation, satisfying §5 lock discipline).
5536    /// Sets `pending_write_authz` for the duration of the call so that the
5537    /// `commit_ingest` → `commit_logged_batch` path picks up the authz context
5538    /// and evaluates the decision table per-op before any WAL write.
5539    ///
5540    /// §7.3: roles with empty `create_labels` will see every `InsertNode` op
5541    /// denied by the decision table with the appropriate §4.3 scope reason;
5542    /// no special HTTP-layer check is needed.
5543    ///
5544    /// Roles with `write: None` return `RoleWriteDenied` with
5545    /// "writes are not permitted" (byte-identical to v1 blanket 403).
5546    pub fn ingest_with_edges_authz(
5547        &mut self,
5548        role: &str,
5549        label: &str,
5550        rows: Vec<std::collections::BTreeMap<String, Value>>,
5551        opts: &crate::ingest::IngestOptions,
5552        edges: &[(String, String, String)],
5553    ) -> Result<crate::ingest::IngestReport> {
5554        // Resolve scope (fails fast if role has no write scope).
5555        // write:None → byte-identical v1 blanket-403 body (plan §v1-sidecar mandate).
5556        let scope =
5557            {
5558                let roles = self.roles.as_deref().ok_or_else(|| GraphError::Corrupt {
5559                    detail: "roles.json was corrupt at open; re-open to restore role access".into(),
5560                })?;
5561                let def = roles.iter().find(|r| r.name == role).ok_or_else(|| {
5562                    GraphError::KeyNotFound {
5563                        key: format!("role:{role}"),
5564                    }
5565                })?;
5566                def.write
5567                    .clone()
5568                    .ok_or_else(|| GraphError::RoleWriteDenied {
5569                        reason: "role-bound token: writes are not permitted".into(),
5570                    })?
5571            };
5572        let mask = self.mask_for_role(role)?;
5573        self.pending_write_authz = Some(WriteAuthz {
5574            role: role.into(),
5575            scope,
5576            mask,
5577        });
5578        // RAII guard: always clears pending_write_authz on scope exit, including
5579        // on panic or early-return, mirroring the RestoreEmitDeltas precedent.
5580        struct ClearPendingAuthzOnDrop(*mut Option<WriteAuthz>);
5581        impl Drop for ClearPendingAuthzOnDrop {
5582            fn drop(&mut self) {
5583                // SAFETY: pointer into the owning GraphDb; guard is dropped
5584                // within this function's frame before it returns.
5585                unsafe { *self.0 = None };
5586            }
5587        }
5588        // SAFETY: raw pointer into self; guard dropped before this fn returns.
5589        let _authz_guard = ClearPendingAuthzOnDrop(&mut self.pending_write_authz as *mut _);
5590        self.ingest_with_edges(label, rows, opts, edges)
5591    }
5592
5593    /// Evaluate the write-authz decision table for one `BatchOp`.
5594    ///
5595    /// Called by `commit_logged_batch` for each op when `pending_write_authz`
5596    /// is `Some`, BEFORE MutPreview.  A denial returns an error immediately;
5597    /// the remaining ops are not evaluated and no WAL frame is written.
5598    ///
5599    /// `batch_created` carries the key→label pairs of nodes that earlier ops in
5600    /// THIS batch will create.  Used by `InsertEdgeUpsert` to count same-batch
5601    /// placeholder nodes as visible (spec: "a placeholder endpoint the SAME
5602    /// batch creates counts as visible if its label passed the create-class gate").
5603    fn check_single_op_authz(
5604        &self,
5605        authz: &WriteAuthz,
5606        op: &BatchOp,
5607        batch_created: &BTreeMap<String, String>,
5608    ) -> Result<()> {
5609        // Helper: 3-way node status under the authz mask.
5610        //
5611        // Batch-created nodes (from earlier InsertNode in THIS batch) are treated
5612        // as Visible with their recorded label — their create gate already passed
5613        // and they are not yet in self.ids (not committed).  This fixes the
5614        // MERGE+ON CREATE SET case where InsertNode + SetProp arrive together:
5615        // the SetProp must not see the node as Absent.
5616        let node_status = |key: &str| -> NodeAuthzStatus {
5617            if let Some(label) = batch_created.get(key) {
5618                return NodeAuthzStatus::Visible(label.clone());
5619            }
5620            match self.ids.get(key) {
5621                None => NodeAuthzStatus::Absent,
5622                Some(id) if !authz.mask.contains_id(id) => NodeAuthzStatus::Hidden,
5623                Some(id) => {
5624                    let label = self
5625                        .labels
5626                        .get(id as usize)
5627                        .and_then(|&sym| {
5628                            if sym == u32::MAX {
5629                                None
5630                            } else {
5631                                self.syms.resolve(sym).map(str::to_string)
5632                            }
5633                        })
5634                        .unwrap_or_default();
5635                    NodeAuthzStatus::Visible(label)
5636                }
5637            }
5638        };
5639
5640        // Helper: is an InsertEdgeUpsert endpoint visible?
5641        // A same-batch placeholder counts as visible if its label passed
5642        // the create-class gate (spec "upsert placeholder-counts-as-visible").
5643        let upsert_ep_visible = |ep_key: &str, placeholder_label: &str| -> bool {
5644            // In store and visible?
5645            if let Some(id) = self.ids.get(ep_key) {
5646                return authz.mask.contains_id(id);
5647            }
5648            // Created by an earlier op in this batch?
5649            if let Some(created_label) = batch_created.get(ep_key) {
5650                return authz.scope.create_labels.contains(created_label);
5651            }
5652            // Will be created by THIS InsertEdgeUpsert: placeholder_label
5653            // must pass the create-class gate.
5654            authz
5655                .scope
5656                .create_labels
5657                .contains(&placeholder_label.to_string())
5658        };
5659
5660        match op {
5661            // RenameNode / CreateRule / DeleteRule: defense-in-depth gate.
5662            // These ops are never routed to role-scoped paths by the HTTP layer,
5663            // but we 403 them here to close any future bypass route.
5664            BatchOp::RenameNode { .. } | BatchOp::CreateRule(_) | BatchOp::DeleteRule { .. } => {
5665                return Err(GraphError::RoleWriteDenied {
5666                    reason: "role-bound token: this endpoint is not permitted".into(),
5667                });
5668            }
5669
5670            // ── CREATE-class: InsertNode ─────────────────────────────────────
5671            //
5672            // Decision table row 1 (scope-before-lookup): check label in
5673            // create_labels BEFORE any key lookup.  This is the structural
5674            // closure of the §6.2 timing-oracle item — the denial fires even
5675            // when the store is EMPTY (see test_create_scope_denied_empty_store).
5676            BatchOp::InsertNode { label, key, .. } => {
5677                if !authz.scope.create_labels.contains(label) {
5678                    return Err(GraphError::RoleWriteDenied {
5679                        reason: format!(
5680                            "role-bound token: label '{}' not in write scope (create_labels)",
5681                            label
5682                        ),
5683                    });
5684                }
5685                // Row 2/3: key lookup.
5686                match self.ids.get(key.as_str()) {
5687                    Some(id) if authz.mask.contains_id(id) => {
5688                        // Visible: DuplicateKey — let MutPreview handle this.
5689                    }
5690                    Some(_) => {
5691                        // Hidden: indistinguishable from absent to the role.
5692                        return Err(GraphError::RoleWriteDenied {
5693                            reason: "role-bound token: target node not visible".into(),
5694                        });
5695                    }
5696                    None => {
5697                        // Absent: proceed (create).
5698                    }
5699                }
5700            }
5701
5702            // ── UPDATE-class: SetProp, RemoveProp ────────────────────────────
5703            BatchOp::SetProp { key, .. } | BatchOp::RemoveProp { key, .. } => {
5704                if batch_created.contains_key(key.as_str()) {
5705                    // Batch-created node: create gate already passed this batch.
5706                    // Updating it in the same batch is always allowed, regardless
5707                    // of update_labels (ruling §3.5: "writer just created it").
5708                } else {
5709                    let label = match node_status(key) {
5710                        NodeAuthzStatus::Visible(lbl) => lbl,
5711                        _ => {
5712                            return Err(GraphError::RoleWriteDenied {
5713                                reason: "role-bound token: target node not visible".into(),
5714                            });
5715                        }
5716                    };
5717                    if !authz.scope.update_labels.contains(&label) {
5718                        return Err(GraphError::RoleWriteDenied {
5719                            reason: format!(
5720                                "role-bound token: label '{}' not in write scope (update_labels)",
5721                                label
5722                            ),
5723                        });
5724                    }
5725                }
5726            }
5727
5728            // ── DELETE-class: DeleteNode ─────────────────────────────────────
5729            BatchOp::DeleteNode { key } => {
5730                let label = match node_status(key) {
5731                    NodeAuthzStatus::Visible(lbl) => lbl,
5732                    _ => {
5733                        return Err(GraphError::RoleWriteDenied {
5734                            reason: "role-bound token: target node not visible".into(),
5735                        });
5736                    }
5737                };
5738                if !authz.scope.delete_labels.contains(&label) {
5739                    return Err(GraphError::RoleWriteDenied {
5740                        reason: format!(
5741                            "role-bound token: label '{}' not in write scope (delete_labels)",
5742                            label
5743                        ),
5744                    });
5745                }
5746            }
5747
5748            // ── DELETE-class: DeleteEdge ─────────────────────────────────────
5749            //
5750            // Derived-edge rejection runs BEFORE the delete_edge_types scope
5751            // check (spec §3.5: "existing derived-edge rejection precedes
5752            // delete_edge_types check").
5753            BatchOp::DeleteEdge {
5754                edge_type,
5755                src_key,
5756                dst_key,
5757            } => {
5758                // Check provenance ownership BEFORE scope (spec §3.5 ordering).
5759                if let (Some(src_id), Some(dst_id), Some(et_sym)) = (
5760                    self.ids.get(src_key.as_str()),
5761                    self.ids.get(dst_key.as_str()),
5762                    self.syms.get(edge_type.as_str()),
5763                ) {
5764                    if self.engine.is_owned(et_sym, src_id, dst_id) {
5765                        return Err(GraphError::RuleOwned {
5766                            detail: format!(
5767                                "edge {edge_type} {src_key}→{dst_key} is rule-owned; \
5768                                 delete or change the owning rule"
5769                            ),
5770                        });
5771                    }
5772                    // Also check would_derive via MutPreview (empty overlay, pre-batch).
5773                    let preview = MutPreview::new(self);
5774                    if preview.would_derive(edge_type, src_key, dst_key) {
5775                        return Err(GraphError::RuleOwned {
5776                            detail: format!(
5777                                "edge {edge_type} {src_key}→{dst_key} is rule-owned; \
5778                                 delete or change the owning rule, or a live rule would \
5779                                 re-derive it"
5780                            ),
5781                        });
5782                    }
5783                }
5784                // Scope check (AFTER derived-edge check, BEFORE endpoint visibility).
5785                if !authz.scope.delete_edge_types.contains(edge_type) {
5786                    return Err(GraphError::RoleWriteDenied {
5787                        reason: format!(
5788                            "role-bound token: edge type '{}' not in write scope (delete_edge_types)",
5789                            edge_type
5790                        ),
5791                    });
5792                }
5793                // Both endpoints must be visible.
5794                for ep_key in [src_key.as_str(), dst_key.as_str()] {
5795                    match self.ids.get(ep_key) {
5796                        None => {
5797                            return Err(GraphError::RoleWriteDenied {
5798                                reason: "role-bound token: edge endpoint not visible".into(),
5799                            });
5800                        }
5801                        Some(id) if !authz.mask.contains_id(id) => {
5802                            return Err(GraphError::RoleWriteDenied {
5803                                reason: "role-bound token: edge endpoint not visible".into(),
5804                            });
5805                        }
5806                        _ => {}
5807                    }
5808                }
5809            }
5810
5811            // ── EDGE-CREATE: InsertEdge ──────────────────────────────────────
5812            //
5813            // Scope check BEFORE endpoint lookup (preserves timing symmetry).
5814            BatchOp::InsertEdge {
5815                edge_type,
5816                src_key,
5817                dst_key,
5818            } => {
5819                if !authz.scope.create_edge_types.contains(edge_type) {
5820                    return Err(GraphError::RoleWriteDenied {
5821                        reason: format!(
5822                            "role-bound token: edge type '{}' not in write scope (create_edge_types)",
5823                            edge_type
5824                        ),
5825                    });
5826                }
5827                // Both endpoints must be visible. A node created by an earlier
5828                // InsertNode in the same batch (tracked in batch_created) counts
5829                // as visible if its label passed the create-class gate.
5830                for ep_key in [src_key.as_str(), dst_key.as_str()] {
5831                    if batch_created.contains_key(ep_key) {
5832                        // Created earlier this batch — already scope-checked.
5833                        continue;
5834                    }
5835                    match self.ids.get(ep_key) {
5836                        None => {
5837                            return Err(GraphError::RoleWriteDenied {
5838                                reason: "role-bound token: edge endpoint not visible".into(),
5839                            });
5840                        }
5841                        Some(id) if !authz.mask.contains_id(id) => {
5842                            return Err(GraphError::RoleWriteDenied {
5843                                reason: "role-bound token: edge endpoint not visible".into(),
5844                            });
5845                        }
5846                        _ => {}
5847                    }
5848                }
5849            }
5850
5851            // ── EDGE-CREATE: InsertEdgeUpsert ────────────────────────────────
5852            //
5853            // Scope check first; then endpoint visibility using same-batch
5854            // placeholder awareness (spec: "a placeholder endpoint the SAME
5855            // batch creates counts as visible if its label passed the
5856            // create-class gate").
5857            BatchOp::InsertEdgeUpsert {
5858                edge_type,
5859                src_key,
5860                dst_key,
5861                placeholder_label,
5862            } => {
5863                if !authz.scope.create_edge_types.contains(edge_type) {
5864                    return Err(GraphError::RoleWriteDenied {
5865                        reason: format!(
5866                            "role-bound token: edge type '{}' not in write scope (create_edge_types)",
5867                            edge_type
5868                        ),
5869                    });
5870                }
5871                // Check placeholder label against create_labels (create-class gate).
5872                // This ensures the auto-created endpoints are scope-allowed.
5873                for ep_key in [src_key.as_str(), dst_key.as_str()] {
5874                    if !upsert_ep_visible(ep_key, placeholder_label) {
5875                        return Err(GraphError::RoleWriteDenied {
5876                            reason: "role-bound token: edge endpoint not visible".into(),
5877                        });
5878                    }
5879                }
5880            }
5881        }
5882        Ok(())
5883    }
5884
5885    /// Write `roles` to `roles.json` atomically and update the in-memory list.
5886    ///
5887    /// Called by `apply_schema` when roles change. Never called on unchanged
5888    /// re-apply — this preserves byte-identical idempotency.
5889    pub(crate) fn commit_roles(&mut self, roles: Vec<RoleDef>) -> Result<()> {
5890        let file = RolesFile::new_versioned(roles.clone());
5891        let bytes = serde_json::to_vec(&file).map_err(|e| GraphError::Corrupt {
5892            detail: format!("roles serialization: {e}"),
5893        })?;
5894        self.fs
5895            .write_atomic(FileId::Roles, &bytes)
5896            .map_err(GraphError::Io)?;
5897        self.roles = Some(roles);
5898        // Refresh the MVCC frozen overlay so that reader() immediately sees the
5899        // updated role definitions without waiting for the next K-commit fold.
5900        self.fold_now();
5901        Ok(())
5902    }
5903
5904    fn view(&self) -> GraphView<'_> {
5905        GraphView {
5906            ids: &self.ids,
5907            syms: &self.syms,
5908            labels: &self.labels,
5909            props: self.props_view(),
5910            topo: self.topo_view(),
5911            edge_props: self.edge_props_view(),
5912            mask: None,
5913            prop_index: Some(&self.prop_index),
5914        }
5915    }
5916
5917    fn view_masked<'a>(&'a self, mask: &'a crate::mask::NodeMask) -> GraphView<'a> {
5918        GraphView {
5919            ids: &self.ids,
5920            syms: &self.syms,
5921            labels: &self.labels,
5922            props: self.props_view(),
5923            topo: self.topo_view(),
5924            edge_props: self.edge_props_view(),
5925            mask: Some(&mask.visible),
5926            prop_index: Some(&self.prop_index),
5927        }
5928    }
5929
5930    /// Execute a read-only Cypher query with a node visibility mask.
5931    ///
5932    /// Only nodes whose key is in `mask` are accessible: label scans, key
5933    /// lookups, and neighbor expansions all respect the mask. Edges where
5934    /// either endpoint is hidden are silently dropped.
5935    ///
5936    /// Returns `Err` with a "masked queries are read-only" message when
5937    /// `cypher` is a write statement (CREATE / MERGE / MATCH…SET / DELETE).
5938    pub fn query_masked(
5939        &self,
5940        cypher: &str,
5941        params: &std::collections::BTreeMap<String, Value>,
5942        mask: &crate::mask::NodeMask,
5943    ) -> Result<ResultSet> {
5944        // Reject write statements up front.
5945        let tokens = lex(cypher).map_err(|e| GraphError::QueryError {
5946            detail: format!("lex: {e}"),
5947        })?;
5948        if is_write_tokens(&tokens) {
5949            return Err(GraphError::MaskedReadOnly);
5950        }
5951        let union = parse_read(&tokens).map_err(|e| GraphError::QueryError {
5952            detail: format!("parse: {e}"),
5953        })?;
5954        // Each UNION part executes against the same masked view, so the mask
5955        // applies uniformly across the chain.
5956        execute_union(&self.view_masked(mask), &union, &Params(params)).map_err(|e| {
5957            GraphError::QueryError {
5958                detail: format!("execute: {e}"),
5959            }
5960        })
5961    }
5962
5963    pub fn node_ref(&self, key: &str) -> Option<NodeRef<'_, F>> {
5964        let id = self.ids.get(key)?;
5965        Some(NodeRef { db: self, id })
5966    }
5967
5968    /// BFS neighborhood expansion restricted to visible nodes in `mask`.
5969    ///
5970    /// Hidden nodes are never used as traversal intermediaries in either
5971    /// [`MaskMode::Omit`] or [`MaskMode::Stub`] — a visible node reachable
5972    /// only through a hidden node will not appear in results.
5973    ///
5974    /// In [`MaskMode::Stub`] mode, hidden nodes that are direct neighbours of
5975    /// a visited visible node are appended to the result as stub rows
5976    /// (`label` column is `null`, same key+depth columns as visible rows).
5977    /// They are NOT added to the BFS frontier.
5978    ///
5979    /// Returns `None` when `key` does not exist (caller should 404).
5980    ///
5981    /// **SECURITY**: role-token callers always pass an Omit-mode mask, so
5982    /// stub rows are never produced on the role path.
5983    pub fn neighborhood_masked(
5984        &self,
5985        key: &str,
5986        depth: u32,
5987        edge_types: Option<&[&str]>,
5988        dir: Dir,
5989        mask: &crate::mask::NodeMask,
5990    ) -> Option<ResultSet> {
5991        let start_id = self.ids.get(key)?;
5992        let view = self.view_masked(mask);
5993        let resolved: Option<Vec<u32>> = edge_types.map(|names| {
5994            names
5995                .iter()
5996                .filter_map(|name| view.syms.get(name))
5997                .collect()
5998        });
5999        let nb = neighborhood(&view, start_id, depth, resolved.as_deref(), dir);
6000        let mut rs = ResultSet::new(vec!["key".into(), "label".into(), "depth".into()]);
6001        // Collect visible BFS results (start_id at depth 0, BFS nodes after).
6002        let mut visited: Vec<(u32, u32)> = Vec::with_capacity(nb.nodes.len() + 1);
6003        visited.push((start_id, 0));
6004        for (nid, d) in &nb.nodes {
6005            let k = view.key_of(*nid);
6006            let label = view
6007                .label_of(*nid)
6008                .expect("real nodes always have a label; u32::MAX sentinel cannot occur");
6009            rs.push_row(vec![
6010                Some(Value::Str(k.to_string())),
6011                Some(Value::Str(label.to_string())),
6012                Some(Value::Int(*d as i64)),
6013            ]);
6014            visited.push((*nid, *d));
6015        }
6016        // Stub mode: add hidden direct neighbours of each visited node as stubs.
6017        // Hidden nodes are edge-endpoints only — they are not added to the BFS
6018        // frontier, so the BFS never expands through them.
6019        if mask.mode() == crate::mask::MaskMode::Stub {
6020            let raw_view = self.view();
6021            let mut seen: std::collections::HashSet<u32> =
6022                visited.iter().map(|(id, _)| *id).collect();
6023            for (node_id, node_depth) in &visited {
6024                if *node_depth >= depth {
6025                    continue;
6026                }
6027                for e in expand(&raw_view, *node_id, resolved.as_deref(), dir) {
6028                    let nbr = if e.src == *node_id { e.dst } else { e.src };
6029                    if !mask.contains_id(nbr) && seen.insert(nbr) {
6030                        if let Some(k) = self.ids.key_of(nbr) {
6031                            rs.push_row(vec![
6032                                Some(Value::Str(k.to_string())),
6033                                None,
6034                                Some(Value::Int((*node_depth + 1) as i64)),
6035                            ]);
6036                        }
6037                    }
6038                }
6039            }
6040        }
6041        Some(rs)
6042    }
6043
6044    /// Live node's key, label, and columnar props. Unknown or tombstoned → `None`.
6045    pub fn node_info(&self, key: &str) -> Option<NodeInfo> {
6046        let n = self.node_ref(key)?;
6047        Some(NodeInfo {
6048            key: n.key().to_string(),
6049            label: n.label().to_string(),
6050            props: n.props(),
6051        })
6052    }
6053
6054    /// Look up a node with mask awareness.
6055    ///
6056    /// | Key state         | Omit mode       | Stub mode              |
6057    /// |-------------------|-----------------|------------------------|
6058    /// | does not exist    | `None` (→ 404)  | `None` (→ 404)         |
6059    /// | exists, visible   | `Some(Visible)` | `Some(Visible)`        |
6060    /// | exists, hidden    | `None` (→ 404)  | `Some(Restricted)`     |
6061    ///
6062    /// **SECURITY**: only call from client-mask (full-token) paths.
6063    /// Role-token paths must use [`node_info`] after an explicit visibility check.
6064    pub fn node_info_masked(
6065        &self,
6066        key: &str,
6067        mask: &crate::mask::NodeMask,
6068    ) -> Option<MaskedNodeResult> {
6069        let id = self.ids.get(key)?;
6070        if mask.contains_id(id) {
6071            Some(MaskedNodeResult::Visible(self.node_info(key)?))
6072        } else {
6073            match mask.mode() {
6074                crate::mask::MaskMode::Stub => Some(MaskedNodeResult::Restricted),
6075                crate::mask::MaskMode::Omit => None,
6076            }
6077        }
6078    }
6079
6080    /// Get edges for `key` with mask-aware hidden-endpoint handling.
6081    ///
6082    /// - Omit mode: edges to hidden endpoints are excluded (same as role-path filtering).
6083    /// - Stub mode: edges to hidden endpoints are included; `src_restricted`/`dst_restricted`
6084    ///   is `true` for each hidden endpoint.
6085    ///
6086    /// Unknown key → [`GraphError::KeyNotFound`].
6087    ///
6088    /// **SECURITY**: only call from client-mask (full-token) paths.
6089    pub fn node_edges_masked(
6090        &self,
6091        key: &str,
6092        mask: &crate::mask::NodeMask,
6093    ) -> Result<Vec<MaskedEdge>> {
6094        self.ensure_v8_base_sections_loaded();
6095        let id = self
6096            .ids
6097            .get(key)
6098            .ok_or_else(|| GraphError::KeyNotFound { key: key.into() })?;
6099        let derived: BTreeSet<(u32, u32, u32)> = self
6100            .engine
6101            .provenance_touching(id)
6102            .map(|(_rule, etype, src, dst)| (etype, src, dst))
6103            .collect();
6104        let mut edges = Vec::new();
6105        let tv = self.topo_view();
6106        for etype in tv.etypes() {
6107            // etype comes from the archived CSR (access_unchecked, no eager CRC).
6108            // A bit-flip in the large TOPOLOGY section can produce an etype id
6109            // that is not in the interner.  Return Corrupt rather than panic.
6110            let edge_type = self
6111                .syms
6112                .resolve(etype)
6113                .ok_or_else(|| GraphError::Corrupt {
6114                    detail: format!("v8: topology etype {etype} not in interner"),
6115                })?
6116                .to_string();
6117            for dir in [Direction::Out, Direction::In] {
6118                for &nbr in tv.neighbors(etype, dir, id).as_ref() {
6119                    let nbr_restricted = !mask.contains_id(nbr);
6120                    if nbr_restricted && mask.mode() == crate::mask::MaskMode::Omit {
6121                        continue;
6122                    }
6123                    let nbr_key = self
6124                        .ids
6125                        .key_of(nbr)
6126                        .ok_or_else(|| GraphError::Corrupt {
6127                            detail: format!("topology id {nbr} has no key"),
6128                        })?
6129                        .to_string();
6130                    let (src_id, dst_id, src_key, dst_key, src_restricted, dst_restricted) =
6131                        match dir {
6132                            Direction::Out => {
6133                                (id, nbr, key.to_string(), nbr_key, false, nbr_restricted)
6134                            }
6135                            Direction::In => {
6136                                (nbr, id, nbr_key, key.to_string(), nbr_restricted, false)
6137                            }
6138                        };
6139                    edges.push(MaskedEdge {
6140                        edge_type: edge_type.clone(),
6141                        src_key,
6142                        src_restricted,
6143                        dst_key,
6144                        dst_restricted,
6145                        derived: derived.contains(&(etype, src_id, dst_id)),
6146                    });
6147                }
6148            }
6149        }
6150        edges.sort_by(|a, b| {
6151            a.edge_type
6152                .cmp(&b.edge_type)
6153                .then(a.src_key.cmp(&b.src_key))
6154                .then(a.dst_key.cmp(&b.dst_key))
6155        });
6156        edges.dedup_by(|a, b| {
6157            a.edge_type == b.edge_type && a.src_key == b.src_key && a.dst_key == b.dst_key
6158        });
6159        Ok(edges)
6160    }
6161
6162    /// Every directed edge incident on `key`, both directions, every etype.
6163    ///
6164    /// Walk is `topology.etypes()` × `{Out, In}` × `neighbors()`. `derived` is
6165    /// membership in [`RuleEngine::provenance_touching`] (O(degree) via the
6166    /// Plan-8 `by_node` index). Sorted by `(edge_type, src_key, dst_key)`.
6167    /// Unknown key → [`GraphError::KeyNotFound`].
6168    pub fn node_edges(&self, key: &str) -> Result<Vec<EdgeInfo>> {
6169        self.ensure_v8_base_sections_loaded();
6170        let id = self
6171            .ids
6172            .get(key)
6173            .ok_or_else(|| GraphError::KeyNotFound { key: key.into() })?;
6174        let derived: BTreeSet<(u32, u32, u32)> = self
6175            .engine
6176            .provenance_touching(id)
6177            .map(|(_rule, etype, src, dst)| (etype, src, dst))
6178            .collect();
6179        let mut edges = Vec::new();
6180        let tv = self.topo_view();
6181        for etype in tv.etypes() {
6182            // Same guard as node_edges_masked: etype from unchecked-CRC CSR.
6183            let edge_type = self
6184                .syms
6185                .resolve(etype)
6186                .ok_or_else(|| GraphError::Corrupt {
6187                    detail: format!("v8: topology etype {etype} not in interner"),
6188                })?
6189                .to_string();
6190            for dir in [Direction::Out, Direction::In] {
6191                for &nbr in tv.neighbors(etype, dir, id).as_ref() {
6192                    let (src, dst, src_key, dst_key) = match dir {
6193                        Direction::Out => (
6194                            id,
6195                            nbr,
6196                            key.to_string(),
6197                            self.ids
6198                                .key_of(nbr)
6199                                .ok_or_else(|| GraphError::Corrupt {
6200                                    detail: format!("topology id {nbr} has no key"),
6201                                })?
6202                                .to_string(),
6203                        ),
6204                        Direction::In => (
6205                            nbr,
6206                            id,
6207                            self.ids
6208                                .key_of(nbr)
6209                                .ok_or_else(|| GraphError::Corrupt {
6210                                    detail: format!("topology id {nbr} has no key"),
6211                                })?
6212                                .to_string(),
6213                            key.to_string(),
6214                        ),
6215                    };
6216                    edges.push(EdgeInfo {
6217                        edge_type: edge_type.clone(),
6218                        src_key,
6219                        dst_key,
6220                        derived: derived.contains(&(etype, src, dst)),
6221                    });
6222                }
6223            }
6224        }
6225        edges.sort_by(|a, b| {
6226            a.edge_type
6227                .cmp(&b.edge_type)
6228                .then(a.src_key.cmp(&b.src_key))
6229                .then(a.dst_key.cmp(&b.dst_key))
6230        });
6231        // Self-loops appear in both Out and In; sort makes the pair adjacent
6232        // (sort key matches PartialEq for this case) so one pass drops the dup.
6233        edges.dedup();
6234        Ok(edges)
6235    }
6236
6237    // ── Backup ────────────────────────────────────────────────────────────────
6238
6239    /// Copy this store to `dest` as a consistent, verified snapshot.
6240    ///
6241    /// Copies every durable file in the database directory — `snapshot.bin`,
6242    /// `wal.bin`, all `wal.<N>.archive` files, `wal.floor`, `wal.genesis`, and
6243    /// `roles.json` — into a freshly created `dest` directory using OS-level
6244    /// `copy` calls (no large in-process buffers).
6245    ///
6246    /// # Consistency guarantee
6247    ///
6248    /// The guarantee is **process-local**: the caller holds `&self`, which
6249    /// prevents any concurrent writer in the **same process** from modifying
6250    /// the files during the copy.  Running `mushroomdb backup` against a
6251    /// directory that is **concurrently being written by another process** (e.g.
6252    /// `mushroomdb serve`) is **unsafe** — the copy can be torn.  The post-copy
6253    /// `verified: true` result reduces but does not eliminate the risk of a
6254    /// silent corrupt backup (CRC catches many bit-flips; it cannot catch a
6255    /// consistent mid-write snapshot).
6256    ///
6257    /// **The safe path for a live-served store is `POST /backup` on the HTTP
6258    /// server.** That handler acquires the read lock on the shared database
6259    /// before calling this method, which is the correct cross-process
6260    /// synchronisation point because the server is the single process writing
6261    /// the files.
6262    ///
6263    /// After copying, opens the destination read-only and runs the CRC section
6264    /// verifier (`verify_snapshot`) to confirm byte-for-byte integrity.
6265    /// `BackupReport::verified` reflects whether both checks passed.
6266    ///
6267    /// Returns `Err` when `self` is not backed by a `RealFs` (e.g. `SimFs`).
6268    pub fn backup_to(&self, dest: &std::path::Path) -> Result<BackupReport> {
6269        // Derive source directory from snapshot_path (RealFs only).
6270        let src_dir = match self.fs.snapshot_path() {
6271            Some(p) => p.parent().map(|d| d.to_path_buf()).ok_or_else(|| {
6272                GraphError::Io(std::io::Error::other("snapshot has no parent dir"))
6273            })?,
6274            None => {
6275                return Err(GraphError::Io(std::io::Error::other(
6276                    "backup_to requires a real filesystem (RealFs)",
6277                )))
6278            }
6279        };
6280
6281        std::fs::create_dir_all(dest)?;
6282
6283        let mut files: Vec<String> = Vec::new();
6284        let mut bytes: u64 = 0;
6285
6286        // Helper: copy src_dir/name → dest/name if the file exists.
6287        let mut try_copy = |name: &str| -> std::io::Result<()> {
6288            let src_path = src_dir.join(name);
6289            if src_path.exists() {
6290                let n = std::fs::copy(&src_path, dest.join(name))?;
6291                bytes += n;
6292                files.push(name.to_string());
6293            }
6294            Ok(())
6295        };
6296
6297        try_copy("snapshot.bin")?;
6298        try_copy("snapshot.bin.bak")?;
6299        try_copy("wal.bin")?;
6300        try_copy("wal.floor")?;
6301        try_copy("wal.genesis")?;
6302        try_copy("roles.json")?;
6303
6304        // Copy WAL archives.
6305        let archives = self.fs.list_archives()?;
6306        for n in &archives {
6307            let name = format!("wal.{n}.archive");
6308            let n_bytes = std::fs::copy(src_dir.join(&name), dest.join(&name))?;
6309            bytes += n_bytes;
6310            files.push(name);
6311        }
6312
6313        files.sort();
6314
6315        // Post-copy verification: open dest and run CRC checks.
6316        let snap_in_dest = dest.join("snapshot.bin").exists();
6317        let crc_ok = if snap_in_dest {
6318            crate::verify_snapshot(dest)
6319                .map(|results| results.iter().all(|(_, _, _, r)| r.is_ok()))
6320                .unwrap_or(false)
6321        } else {
6322            true // WAL-only store: nothing to CRC-check in snapshot
6323        };
6324        let opens_ok = GraphDb::<core_storage::fs::RealFs>::open(dest).is_ok();
6325        let verified = crc_ok && opens_ok;
6326
6327        Ok(BackupReport {
6328            files,
6329            bytes,
6330            verified,
6331        })
6332    }
6333
6334    // ── Export helpers ────────────────────────────────────────────────────────
6335
6336    /// All live nodes, sorted by key (deterministic).
6337    ///
6338    /// Reads base + WAL overlay. Tombstoned nodes are excluded.
6339    pub fn all_nodes_for_export(&self) -> Vec<NodeInfo> {
6340        self.ensure_v8_base_sections_loaded();
6341        let pv = self.props_view();
6342        let mut nodes = Vec::new();
6343        for id in 0..self.ids.len() as u32 {
6344            let Some(key) = self.ids.key_of(id) else {
6345                continue;
6346            };
6347            let Some(&sym) = self.labels.get(id as usize) else {
6348                continue;
6349            };
6350            if sym == u32::MAX {
6351                continue; // tombstoned
6352            }
6353            let Some(label) = self.syms.resolve(sym) else {
6354                continue;
6355            };
6356            let mut props = BTreeMap::new();
6357            for field in pv.field_names() {
6358                if let Some(vr) = pv.get(id, &field) {
6359                    props.insert(field, vr.into_value());
6360                }
6361            }
6362            nodes.push(NodeInfo {
6363                key: key.to_string(),
6364                label: label.to_string(),
6365                props,
6366            });
6367        }
6368        nodes.sort_by(|a, b| a.key.cmp(&b.key));
6369        nodes
6370    }
6371
6372    /// All directed edges, sorted by `(edge_type, src, dst)`. Each edge appears once.
6373    ///
6374    /// Derived edges carry `derived: true` and the creating rule's name in `rule`.
6375    /// Manual edges carry `derived: false` and `rule: None`.
6376    /// Deterministic across runs on the same store state.
6377    pub fn all_edges_for_export(&self) -> Vec<ExportEdge> {
6378        self.ensure_v8_base_sections_loaded();
6379
6380        // Build (etype_sym, src_id, dst_id) → rule_name for O(1) derivation lookup.
6381        let mut prov: HashMap<(u32, u32, u32), String> = HashMap::new();
6382        for (rule_name, triples) in self.engine.provenance() {
6383            for &(etype, src, dst) in triples {
6384                prov.insert((etype, src, dst), rule_name.clone());
6385            }
6386        }
6387
6388        let tv = self.topo_view();
6389        let mut edges = Vec::new();
6390
6391        for id in 0..self.ids.len() as u32 {
6392            let Some(key) = self.ids.key_of(id) else {
6393                continue;
6394            };
6395            let Some(&lsym) = self.labels.get(id as usize) else {
6396                continue;
6397            };
6398            if lsym == u32::MAX {
6399                continue; // tombstoned
6400            }
6401
6402            for etype_sym in tv.etypes() {
6403                // etype from archived CSR (access_unchecked, no eager CRC).
6404                // Skip edges whose etype is not in the interner; this can only
6405                // occur with a corrupt large TOPOLOGY section (bit-flip on an
6406                // etype field in the archived data).  The function returns Vec,
6407                // not Result, so we continue rather than propagate.
6408                let Some(edge_type) = self.syms.resolve(etype_sym) else {
6409                    continue;
6410                };
6411                let edge_type = edge_type.to_string();
6412                for &nbr in tv.neighbors(etype_sym, Direction::Out, id).as_ref() {
6413                    let Some(dst_key) = self.ids.key_of(nbr) else {
6414                        continue; // skip corrupt entries
6415                    };
6416                    let prov_key = (etype_sym, id, nbr);
6417                    let rule = prov.get(&prov_key).cloned();
6418                    let derived = rule.is_some();
6419                    edges.push(ExportEdge {
6420                        edge_type: edge_type.clone(),
6421                        src: key.to_string(),
6422                        dst: dst_key.to_string(),
6423                        derived,
6424                        rule,
6425                    });
6426                }
6427            }
6428        }
6429
6430        edges.sort_by(|a, b| {
6431            a.edge_type
6432                .cmp(&b.edge_type)
6433                .then(a.src.cmp(&b.src))
6434                .then(a.dst.cmp(&b.dst))
6435        });
6436        edges
6437    }
6438
6439    pub fn nodes_with_label(&self, label: &str) -> Vec<NodeRef<'_, F>> {
6440        self.view()
6441            .nodes_with_label(label)
6442            .into_iter()
6443            .map(|id| NodeRef { db: self, id })
6444            .collect()
6445    }
6446
6447    pub fn find_nodes(&self, label: &str, filter: &Filter) -> Vec<NodeRef<'_, F>> {
6448        let view = self.view();
6449        view.nodes_with_label(label)
6450            .into_iter()
6451            .filter(|&id| {
6452                eval_filter(filter, &|field| {
6453                    view.prop(id, field).map(|vr| vr.into_value())
6454                })
6455            })
6456            .map(|id| NodeRef { db: self, id })
6457            .collect()
6458    }
6459
6460    /// Returns `true` if any approximate (HNSW) VectorSimilar rule covers
6461    /// `field`.  Use as a capability probe: when `true`, `find_similar_vector`
6462    /// with `label = None` will use the native ANN path rather than the O(n)
6463    /// brute-force scan.
6464    pub fn has_vector_rule(&self, field: &str) -> bool {
6465        self.engine.hnsw_has_rule(field)
6466    }
6467
6468    /// Find nodes whose `field` vector is most similar to `q` (cosine
6469    /// similarity), returning up to `k` results with similarity ≥ `min`,
6470    /// sorted descending.
6471    ///
6472    /// When `label` is `None` the search spans all labels (via
6473    /// `hnsw_search_any_dst` or a full brute-force scan); when `label` is
6474    /// `Some(lbl)` it restricts to nodes with that label.
6475    ///
6476    /// Uses the HNSW index when one is available (fast path); otherwise falls
6477    /// back to an O(n) brute-force scan.
6478    pub fn find_similar_vector(
6479        &self,
6480        field: &str,
6481        label: Option<&str>,
6482        q: &[f64],
6483        k: usize,
6484        min: f64,
6485    ) -> Vec<(String, f64)> {
6486        // Ensure any HNSW blobs retained from the snapshot are deserialized
6487        // before the first ANN query on a clean-open (no-WAL) path.
6488        self.engine.ensure_hnsw_loaded();
6489        // L2-normalise query for cosine via dot product.
6490        let norm: f64 = q.iter().map(|x| x * x).sum::<f64>().sqrt();
6491        if norm == 0.0 {
6492            return vec![];
6493        }
6494        let q_unit: Vec<f64> = q.iter().map(|x| x / norm).collect();
6495
6496        // Try HNSW fast path.
6497        // `None` label searches across all VectorSimilar rules covering `field`
6498        // (merging their results); `Some(lbl)` restricts to rules whose
6499        // dst_label matches.  Returns `None` when no populated HNSW index
6500        // covers the request — the O(n) brute-force fallback handles that case.
6501        let hnsw_hits = match label {
6502            Some(lbl) => self.engine.hnsw_search_dst(field, lbl, &q_unit, k),
6503            None => self.engine.hnsw_search_any_dst(field, &q_unit, k),
6504        };
6505        if let Some(hits) = hnsw_hits {
6506            let mut out: Vec<(String, f64)> = hits
6507                .into_iter()
6508                .filter(|&(_, sim)| sim >= min)
6509                .filter_map(|(id, sim)| self.ids.key_of(id).map(|key| (key.to_string(), sim)))
6510                .collect();
6511            out.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
6512            out.truncate(k);
6513            return out;
6514        }
6515
6516        // Brute-force fallback: O(n) scan (only reached when no HNSW index
6517        // covers the request).
6518        let view = self.view();
6519        let candidate_ids: Vec<u32> = match label {
6520            Some(lbl) => view.nodes_with_label(lbl),
6521            None => view.nodes_all(),
6522        };
6523        let mut scored: Vec<(String, f64)> = candidate_ids
6524            .into_iter()
6525            .filter_map(|id| {
6526                let v = view.prop(id, field)?;
6527                let v_owned = v.into_value();
6528                let xs = value_as_float_list(&v_owned)?;
6529                let v_norm: f64 = xs.iter().map(|x| x * x).sum::<f64>().sqrt();
6530                if v_norm == 0.0 {
6531                    return None;
6532                }
6533                let dot: f64 = q_unit
6534                    .iter()
6535                    .zip(xs.iter())
6536                    .map(|(a, b)| a * (b / v_norm))
6537                    .sum();
6538                if dot < min {
6539                    return None;
6540                }
6541                let key = self.ids.key_of(id)?.to_string();
6542                Some((key, dot))
6543            })
6544            .collect();
6545        scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
6546        scored.truncate(k);
6547        scored
6548    }
6549
6550    /// Like [`find_similar_vector`] but restricts results to nodes visible in
6551    /// `mask`. Hidden nodes never appear in results; the mask is applied
6552    /// **before** k-truncation so a caller still receives up to `k` visible
6553    /// hits.
6554    ///
6555    /// # HNSW path (over-fetch policy)
6556    ///
6557    /// When an HNSW index covers the request, this function fetches `4 * k`
6558    /// candidates from the index and discards hidden nodes in the post-filter
6559    /// step.  If fewer than `k` visible nodes remain after filtering the caller
6560    /// receives whatever is available — we do not re-query the index.  The 4×
6561    /// multiplier is a heuristic suited for sparsely masked graphs; callers
6562    /// operating under a very selective mask should register a VectorSimilar
6563    /// rule with a non-approximate index, or use the brute-force path (no HNSW
6564    /// rule) which exhaustively filters through the masked [`GraphView`].
6565    ///
6566    /// # Brute-force path
6567    ///
6568    /// When no HNSW index covers the request the function builds a masked
6569    /// [`GraphView`] so that `nodes_all` / `nodes_with_label` return only
6570    /// visible nodes, guaranteeing exact `k` results (or all visible nodes if
6571    /// fewer than `k` exist).
6572    pub fn find_similar_vector_masked(
6573        &self,
6574        field: &str,
6575        label: Option<&str>,
6576        q: &[f64],
6577        k: usize,
6578        min: f64,
6579        mask: &crate::mask::NodeMask,
6580    ) -> Vec<(String, f64)> {
6581        self.engine.ensure_hnsw_loaded();
6582        let norm: f64 = q.iter().map(|x| x * x).sum::<f64>().sqrt();
6583        if norm == 0.0 {
6584            return vec![];
6585        }
6586        let q_unit: Vec<f64> = q.iter().map(|x| x / norm).collect();
6587
6588        // HNSW fast path — over-fetch 4×k so post-masking still yields up to k
6589        // visible hits.  See doc comment above for the policy rationale.
6590        let over_k = k.saturating_mul(4).max(k + 1);
6591        let hnsw_hits = match label {
6592            Some(lbl) => self.engine.hnsw_search_dst(field, lbl, &q_unit, over_k),
6593            None => self.engine.hnsw_search_any_dst(field, &q_unit, over_k),
6594        };
6595        if let Some(hits) = hnsw_hits {
6596            let mut out: Vec<(String, f64)> = hits
6597                .into_iter()
6598                .filter(|&(id, sim)| sim >= min && mask.visible.contains(&id))
6599                .filter_map(|(id, sim)| self.ids.key_of(id).map(|key| (key.to_string(), sim)))
6600                .collect();
6601            out.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
6602            out.truncate(k);
6603            return out;
6604        }
6605
6606        // Brute-force fallback — masked view ensures only visible nodes are
6607        // enumerated by nodes_all(); nodes_with_label() does not filter by
6608        // mask so we apply view.visible() explicitly for the labeled case.
6609        let view = self.view_masked(mask);
6610        let candidate_ids: Vec<u32> = match label {
6611            Some(lbl) => view
6612                .nodes_with_label(lbl)
6613                .into_iter()
6614                .filter(|&id| view.visible(id))
6615                .collect(),
6616            None => view.nodes_all(),
6617        };
6618        let mut scored: Vec<(String, f64)> = candidate_ids
6619            .into_iter()
6620            .filter_map(|id| {
6621                let v = view.prop(id, field)?;
6622                let v_owned = v.into_value();
6623                let xs = value_as_float_list(&v_owned)?;
6624                let v_norm: f64 = xs.iter().map(|x| x * x).sum::<f64>().sqrt();
6625                if v_norm == 0.0 {
6626                    return None;
6627                }
6628                let dot: f64 = q_unit
6629                    .iter()
6630                    .zip(xs.iter())
6631                    .map(|(a, b)| a * (b / v_norm))
6632                    .sum();
6633                if dot < min {
6634                    return None;
6635                }
6636                let key = self.ids.key_of(id)?.to_string();
6637                Some((key, dot))
6638            })
6639            .collect();
6640        scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
6641        scored.truncate(k);
6642        scored
6643    }
6644
6645    /// Read a single property from an edge.
6646    ///
6647    /// Returns `None` when the edge does not exist, the field is absent, or any
6648    /// of the string keys cannot be resolved to interned ids.  Only edge props
6649    /// written by rules (weight fields) are accessible without a `set_edge_prop`
6650    /// binding; topology-only edges (no props set) return `None` for every field.
6651    pub fn get_edge_prop(
6652        &self,
6653        edge_type: &str,
6654        src_key: &str,
6655        dst_key: &str,
6656        field: &str,
6657    ) -> Option<Value> {
6658        let etype = self.syms.get(edge_type)?;
6659        let src = self.ids.get(src_key)?;
6660        let dst = self.ids.get(dst_key)?;
6661        self.edge_props_view().get(etype, src, dst, field)
6662    }
6663
6664    /// Lex → parse → plan → execute `cypher` over a read-only view.
6665    /// Every pipeline `Err(String)` becomes `GraphError::QueryError` with a
6666    /// stage prefix (`lex:` / `parse:` / `plan:` / `execute:`).
6667    pub fn query(&self, cypher: &str, params: &BTreeMap<String, Value>) -> Result<ResultSet> {
6668        let tokens = lex(cypher).map_err(|e| GraphError::QueryError {
6669            detail: format!("lex: {e}"),
6670        })?;
6671        let union = parse_read(&tokens).map_err(|e| GraphError::QueryError {
6672            detail: format!("parse: {e}"),
6673        })?;
6674        execute_union(&self.view(), &union, &Params(params)).map_err(|e| GraphError::QueryError {
6675            detail: format!("execute: {e}"),
6676        })
6677    }
6678
6679    /// Convenience entry-point that accepts a slice of `(name, value)` pairs
6680    /// instead of a pre-built `BTreeMap`.  Equivalent to building the map and
6681    /// calling [`GraphDb::query`].
6682    pub fn query_with_params(&self, cypher: &str, params: &[(&str, Value)]) -> Result<ResultSet> {
6683        let map: BTreeMap<String, Value> = params
6684            .iter()
6685            .map(|(k, v)| (k.to_string(), v.clone()))
6686            .collect();
6687        self.query(cypher, &map)
6688    }
6689
6690    /// Execute a Cypher write statement (CREATE / MATCH…SET / MATCH…DELETE / MERGE).
6691    ///
6692    /// All mutations flow through the same `insert_node` / `set_prop` /
6693    /// `delete_edge` / `insert_edge` path as the Rust API so the rule engine
6694    /// fires and the WAL captures everything with one fsync per statement.
6695    ///
6696    /// Returns a one-row [`ResultSet`] with columns `created`, `properties_set`,
6697    /// and `deleted` matching the write-result contract.
6698    ///
6699    /// **Mutation routing**: mutations are collected into a single
6700    /// [`BatchBuilder`] and committed atomically (one WAL `Batch` frame, one
6701    /// fsync). The MATCH phase for SET/DELETE uses a read-only `execute` call
6702    /// over `self.view()` — the borrow is dropped before the batch is opened.
6703    ///
6704    /// **Limitations (v1)**:
6705    /// - SET RHS must be a literal, `$param`, or arithmetic; bare property copy → named error.
6706    /// - `DETACH DELETE n` → calls `delete_node` for each matched node (removes all edges).
6707    /// - Bare `DELETE n` → error if n has any incident edges; succeeds for isolated nodes.
6708    /// - MERGE supports `ON CREATE SET` / `ON MATCH SET` in the same write batch.
6709    /// - Deleting a derived edge → named error "cannot delete derived edge".
6710    pub fn query_write(
6711        &mut self,
6712        cypher: &str,
6713        params: &BTreeMap<String, Value>,
6714    ) -> Result<ResultSet> {
6715        let tokens = lex(cypher).map_err(|e| GraphError::QueryError {
6716            detail: format!("lex: {e}"),
6717        })?;
6718        let stmt = parse_write(&tokens).map_err(|e| GraphError::QueryError {
6719            detail: format!("parse: {e}"),
6720        })?;
6721        self.exec_write_stmt(stmt, params)
6722    }
6723
6724    fn exec_write_stmt(
6725        &mut self,
6726        stmt: WriteStatement,
6727        params: &BTreeMap<String, Value>,
6728    ) -> Result<ResultSet> {
6729        match stmt {
6730            WriteStatement::Create(s) => self.exec_create(s, params),
6731            WriteStatement::MatchSet(s) => self.exec_match_set(s, params),
6732            WriteStatement::MatchDelete(s) => self.exec_match_delete(s, params),
6733            WriteStatement::MatchDeleteNode(s) => self.exec_match_delete_node(s, params),
6734            WriteStatement::Merge(s) => self.exec_merge(s, params),
6735        }
6736    }
6737
6738    fn exec_create(
6739        &mut self,
6740        stmt: core_query::cypher::CreateStmt,
6741        params: &BTreeMap<String, Value>,
6742    ) -> Result<ResultSet> {
6743        // Extract the node key from props: require a string-valued `id` field.
6744        let mut var_to_key: BTreeMap<String, String> = BTreeMap::new();
6745        for node in &stmt.nodes {
6746            let var = node.var.as_deref().unwrap_or("_cn0");
6747            let key = node
6748                .props
6749                .iter()
6750                .find(|(f, _)| f == "id")
6751                .and_then(|(_, v)| {
6752                    if let Value::Str(s) = v {
6753                        Some(s.clone())
6754                    } else {
6755                        None
6756                    }
6757                })
6758                .ok_or_else(|| GraphError::QueryError {
6759                    detail: format!(
6760                        "CREATE node ({}:{}) requires a string 'id' property",
6761                        var, node.label
6762                    ),
6763                })?;
6764            var_to_key.insert(var.to_string(), key);
6765        }
6766
6767        let mut batch = self.batch();
6768        let mut created: usize = 0;
6769        for node in &stmt.nodes {
6770            let var = node.var.as_deref().unwrap_or("_cn0");
6771            let key = &var_to_key[var];
6772            batch.insert_node(&node.label, key, node.props.clone());
6773            created += 1;
6774        }
6775        for edge in &stmt.edges {
6776            let src_key = var_to_key
6777                .get(&edge.src_var)
6778                .ok_or_else(|| GraphError::QueryError {
6779                    detail: format!("CREATE edge src variable '{}' is not bound", edge.src_var),
6780                })?;
6781            let dst_key = var_to_key
6782                .get(&edge.dst_var)
6783                .ok_or_else(|| GraphError::QueryError {
6784                    detail: format!("CREATE edge dst variable '{}' is not bound", edge.dst_var),
6785                })?;
6786            batch.insert_edge(&edge.etype, src_key, dst_key);
6787        }
6788        batch.commit()?;
6789
6790        // Optional RETURN clause: project created bindings as a read result.
6791        if let Some(returns) = stmt.returns {
6792            // Each created node is looked up by its key via a separate MATCH pattern.
6793            // Multiple single-node patterns cross-join to produce 1 output row with
6794            // all variables bound (each pattern returns exactly 1 row).
6795            let patterns: Vec<Pattern> = stmt
6796                .nodes
6797                .iter()
6798                .map(|node| {
6799                    let var = node.var.as_deref().unwrap_or("_cn0");
6800                    let key = var_to_key[var].clone();
6801                    Pattern {
6802                        start: NodePat {
6803                            var: Some(var.to_string()),
6804                            label: Some(node.label.clone()),
6805                            props: vec![("id".to_string(), Operand::Lit(Value::Str(key)))],
6806                        },
6807                        chain: vec![],
6808                        shortest: false,
6809                    }
6810                })
6811                .collect();
6812            let q = Query {
6813                matches: patterns,
6814                optional_clauses: vec![],
6815                where_expr: None,
6816                unwinds: vec![],
6817                post_unwind_where: None,
6818                stages: vec![],
6819                returns,
6820                distinct: false,
6821                order_by: vec![],
6822                skip: None,
6823                limit: None,
6824            };
6825            let ops = plan(&q).map_err(|e| GraphError::QueryError {
6826                detail: format!("plan: {e}"),
6827            })?;
6828            return execute(&self.view(), &ops, &Params(params)).map_err(|e| {
6829                GraphError::QueryError {
6830                    detail: format!("execute: {e}"),
6831                }
6832            });
6833        }
6834
6835        let mut rs = write_result_set();
6836        rs.push_row(vec![
6837            Some(Value::Int(created as i64)),
6838            Some(Value::Int(0)),
6839            Some(Value::Int(0)),
6840        ]);
6841        Ok(rs)
6842    }
6843
6844    fn exec_match_set(
6845        &mut self,
6846        stmt: core_query::cypher::MatchSetStmt,
6847        params: &BTreeMap<String, Value>,
6848    ) -> Result<ResultSet> {
6849        let project_returns = stmt.returns.clone();
6850        // Collect unique node vars targeted by SET clauses, plus RETURN bindings
6851        // so the post-write projection can look them up by key.
6852        let mut set_vars: Vec<String> = Vec::new();
6853        for s in &stmt.sets {
6854            if !set_vars.contains(&s.var) {
6855                set_vars.push(s.var.clone());
6856            }
6857        }
6858        let rel_vars = pattern_rel_vars(&stmt.matches);
6859        let mut lookup_vars = set_vars.clone();
6860        for v in pattern_node_vars(&stmt.matches) {
6861            add_var(&mut lookup_vars, &v);
6862        }
6863        if let Some(ref returns) = project_returns {
6864            for v in ret_node_vars(returns) {
6865                if !rel_vars.iter().any(|r| r == &v) {
6866                    add_var(&mut lookup_vars, &v);
6867                }
6868            }
6869        }
6870
6871        // Synthesize a read query: MATCH … WHERE … RETURN <lookup_vars>, <set_values…>
6872        // SET values are projected as ScalarExpr items so that arithmetic expressions
6873        // (e.g. `SET n.score = n.score * 1.5`) are evaluated in the matched-row context.
6874        let mut set_returns: Vec<RetItem> = lookup_vars
6875            .iter()
6876            .map(|v| RetItem {
6877                value: RetVal::Var(v.clone()),
6878                alias: None,
6879            })
6880            .collect();
6881        // One computed column per SET clause; alias is `__sv_<i>`.
6882        let set_val_cols: Vec<String> = stmt
6883            .sets
6884            .iter()
6885            .enumerate()
6886            .map(|(i, _)| format!("__sv_{i}"))
6887            .collect();
6888        for (sc, col) in stmt.sets.iter().zip(&set_val_cols) {
6889            set_returns.push(RetItem {
6890                value: RetVal::ScalarExpr(sc.value.clone()),
6891                alias: Some(col.clone()),
6892            });
6893        }
6894        // Capture relationship types while r is bound; SET does not change them.
6895        for r in &rel_vars {
6896            set_returns.push(RetItem {
6897                value: RetVal::FuncCall {
6898                    name: "type".into(),
6899                    args: vec![Operand::Var(r.clone())],
6900                },
6901                alias: Some(rel_type_alias(r)),
6902            });
6903        }
6904
6905        let read_q = Query {
6906            matches: stmt.matches.clone(),
6907            optional_clauses: vec![],
6908            where_expr: stmt.where_expr.clone(),
6909            unwinds: vec![],
6910            post_unwind_where: None,
6911            stages: vec![],
6912            returns: set_returns,
6913            distinct: false,
6914            order_by: vec![],
6915            skip: None,
6916            limit: None,
6917        };
6918        let ops = plan(&read_q).map_err(|e| GraphError::QueryError {
6919            detail: format!("plan: {e}"),
6920        })?;
6921        // MATCH phase is read-only; borrow ends before batch opens.
6922        //
6923        // When a role-scoped write is in flight, run the MATCH read through
6924        // view_masked so hidden nodes are invisible → hidden ≡ absent ≡
6925        // zero-rows (no SetProp ops generated, no existence-oracle 403).
6926        // Full-authority writes (pending_write_authz=None) keep view().
6927        let match_rs = {
6928            let mask_opt = self.pending_write_authz.as_ref().map(|a| a.mask.clone());
6929            if let Some(ref mask) = mask_opt {
6930                execute(&self.view_masked(mask), &ops, &Params(params))
6931            } else {
6932                execute(&self.view(), &ops, &Params(params))
6933            }
6934        }
6935        .map_err(|e| GraphError::QueryError {
6936            detail: format!("execute: {e}"),
6937        })?;
6938
6939        // Collect (key, field, value) for each matched row × each SET clause.
6940        let mut set_ops: Vec<(String, String, Value)> = Vec::new();
6941        for row_i in 0..match_rs.len() {
6942            for (sc, col) in stmt.sets.iter().zip(&set_val_cols) {
6943                let key = match match_rs.get(row_i, &sc.var) {
6944                    Some(Value::Str(k)) => k.clone(),
6945                    _ => {
6946                        return Err(GraphError::QueryError {
6947                            detail: format!(
6948                                "SET variable '{}' did not resolve to a node key",
6949                                sc.var
6950                            ),
6951                        })
6952                    }
6953                };
6954                // The SET value was already evaluated by the executor.
6955                let value = match match_rs.get(row_i, col) {
6956                    Some(v) => v.clone(),
6957                    None => {
6958                        return Err(GraphError::QueryError {
6959                            detail: format!(
6960                                "SET value for {}.{} evaluated to null",
6961                                sc.var, sc.field
6962                            ),
6963                        })
6964                    }
6965                };
6966                set_ops.push((key, sc.field.clone(), value));
6967            }
6968        }
6969
6970        // Apply as one atomic batch.
6971        let props_set = set_ops.len();
6972        let mut batch = self.batch();
6973        for (key, field, value) in set_ops {
6974            batch.set_prop(&key, &field, value);
6975        }
6976        batch.commit()?;
6977
6978        if let Some(returns) = project_returns {
6979            return project_set_return_rows(self, &rel_vars, &match_rs, &returns, params);
6980        }
6981
6982        let mut rs = write_result_set();
6983        rs.push_row(vec![
6984            Some(Value::Int(0)),
6985            Some(Value::Int(props_set as i64)),
6986            Some(Value::Int(0)),
6987        ]);
6988        Ok(rs)
6989    }
6990
6991    fn exec_match_delete(
6992        &mut self,
6993        stmt: core_query::cypher::MatchDeleteStmt,
6994        params: &BTreeMap<String, Value>,
6995    ) -> Result<ResultSet> {
6996        // Collect unique node vars needed to identify edge endpoints.
6997        let mut node_vars: Vec<String> = Vec::new();
6998        for ed in &stmt.deletes {
6999            if !node_vars.contains(&ed.src_var) {
7000                node_vars.push(ed.src_var.clone());
7001            }
7002            if !node_vars.contains(&ed.dst_var) {
7003                node_vars.push(ed.dst_var.clone());
7004            }
7005        }
7006
7007        // Synthesize read query.
7008        let returns: Vec<RetItem> = node_vars
7009            .iter()
7010            .map(|v| RetItem {
7011                value: RetVal::Var(v.clone()),
7012                alias: None,
7013            })
7014            .collect();
7015        let read_q = Query {
7016            matches: stmt.matches,
7017            optional_clauses: vec![],
7018            where_expr: stmt.where_expr,
7019            unwinds: vec![],
7020            post_unwind_where: None,
7021            stages: vec![],
7022            returns,
7023            distinct: false,
7024            order_by: vec![],
7025            skip: None,
7026            limit: None,
7027        };
7028        let ops = plan(&read_q).map_err(|e| GraphError::QueryError {
7029            detail: format!("plan: {e}"),
7030        })?;
7031        // Role-scoped writes: mask the MATCH read phase so hidden nodes are
7032        // invisible → hidden ≡ absent ≡ zero-rows (spec §3.1, hidden ≡ absent).
7033        let match_rs = {
7034            let mask_opt = self.pending_write_authz.as_ref().map(|a| a.mask.clone());
7035            if let Some(ref mask) = mask_opt {
7036                execute(&self.view_masked(mask), &ops, &Params(params))
7037            } else {
7038                execute(&self.view(), &ops, &Params(params))
7039            }
7040        }
7041        .map_err(|e| GraphError::QueryError {
7042            detail: format!("execute: {e}"),
7043        })?;
7044
7045        // Collect (etype, src_key, dst_key) for each row × each delete target.
7046        let mut del_ops: Vec<(String, String, String)> = Vec::new();
7047        for row_i in 0..match_rs.len() {
7048            for ed in &stmt.deletes {
7049                let src_key = match match_rs.get(row_i, &ed.src_var) {
7050                    Some(Value::Str(k)) => k.clone(),
7051                    _ => {
7052                        return Err(GraphError::QueryError {
7053                            detail: format!(
7054                                "DELETE src variable '{}' did not resolve to a node key",
7055                                ed.src_var
7056                            ),
7057                        })
7058                    }
7059                };
7060                let dst_key = match match_rs.get(row_i, &ed.dst_var) {
7061                    Some(Value::Str(k)) => k.clone(),
7062                    _ => {
7063                        return Err(GraphError::QueryError {
7064                            detail: format!(
7065                                "DELETE dst variable '{}' did not resolve to a node key",
7066                                ed.dst_var
7067                            ),
7068                        })
7069                    }
7070                };
7071                del_ops.push((ed.etype.clone(), src_key, dst_key));
7072            }
7073        }
7074
7075        // Apply as one atomic batch.
7076        let deleted = del_ops.len();
7077        let mut batch = self.batch();
7078        for (etype, src_key, dst_key) in del_ops {
7079            batch.delete_edge(&etype, &src_key, &dst_key);
7080        }
7081        batch.commit().map_err(|e| match e {
7082            GraphError::RuleOwned { .. } => GraphError::QueryError {
7083                detail: "cannot delete derived edge; retract via the rule or change the property"
7084                    .to_string(),
7085            },
7086            other => other,
7087        })?;
7088
7089        let mut rs = write_result_set();
7090        rs.push_row(vec![
7091            Some(Value::Int(0)),
7092            Some(Value::Int(0)),
7093            Some(Value::Int(deleted as i64)),
7094        ]);
7095        Ok(rs)
7096    }
7097
7098    /// Execute `MATCH … [DETACH] DELETE <node_var> [, …]`.
7099    ///
7100    /// Collects the matching node keys via an ephemeral read query, then calls
7101    /// `delete_node` on each one.  When `stmt.detach` is `false` (bare DELETE)
7102    /// the executor first checks that the node has no incident edges; if any
7103    /// remain it returns a named error matching openCypher semantics.
7104    fn exec_match_delete_node(
7105        &mut self,
7106        stmt: MatchDeleteNodeStmt,
7107        params: &BTreeMap<String, Value>,
7108    ) -> Result<ResultSet> {
7109        // Build a read query returning only the node keys we need.
7110        let returns: Vec<RetItem> = stmt
7111            .node_vars
7112            .iter()
7113            .map(|v| RetItem {
7114                value: RetVal::Var(v.clone()),
7115                alias: None,
7116            })
7117            .collect();
7118        let read_q = Query {
7119            matches: stmt.matches,
7120            optional_clauses: vec![],
7121            where_expr: stmt.where_expr,
7122            unwinds: vec![],
7123            post_unwind_where: None,
7124            stages: vec![],
7125            returns,
7126            distinct: false,
7127            order_by: vec![],
7128            skip: None,
7129            limit: None,
7130        };
7131        let ops = plan(&read_q).map_err(|e| GraphError::QueryError {
7132            detail: format!("plan: {e}"),
7133        })?;
7134        // Role-scoped writes: mask the MATCH read phase so hidden nodes are
7135        // invisible → hidden ≡ absent ≡ zero-rows (spec §3.1, hidden ≡ absent).
7136        let match_rs = {
7137            let mask_opt = self.pending_write_authz.as_ref().map(|a| a.mask.clone());
7138            if let Some(ref mask) = mask_opt {
7139                execute(&self.view_masked(mask), &ops, &Params(params))
7140            } else {
7141                execute(&self.view(), &ops, &Params(params))
7142            }
7143        }
7144        .map_err(|e| GraphError::QueryError {
7145            detail: format!("execute: {e}"),
7146        })?;
7147
7148        // Collect unique node keys to delete (deduplicate across rows × vars).
7149        let mut keys: Vec<String> = Vec::new();
7150        for row_i in 0..match_rs.len() {
7151            for var in &stmt.node_vars {
7152                if let Some(Value::Str(k)) = match_rs.get(row_i, var) {
7153                    if !keys.contains(k) {
7154                        keys.push(k.clone());
7155                    }
7156                }
7157            }
7158        }
7159
7160        if !stmt.detach {
7161            // openCypher bare DELETE: error if any matched node has incident edges.
7162            for key in &keys {
7163                if let Some(id) = self.ids.get(key) {
7164                    let tv = self.topo_view();
7165                    let has_edges = tv.etypes().any(|et| {
7166                        !tv.neighbors(et, Direction::Out, id).is_empty()
7167                            || !tv.neighbors(et, Direction::In, id).is_empty()
7168                    });
7169                    if has_edges {
7170                        return Err(GraphError::QueryError {
7171                            detail: format!(
7172                                "Cannot delete node `{key}` because it still has incident edges. \
7173                                 Use DETACH DELETE to remove the node and all its edges."
7174                            ),
7175                        });
7176                    }
7177                }
7178            }
7179        }
7180
7181        let mut nodes_deleted = 0i64;
7182        let mut edges_deleted = 0i64;
7183        for key in keys {
7184            match self.delete_node(&key) {
7185                Ok(report) => {
7186                    nodes_deleted += 1;
7187                    edges_deleted += (report.manual_edges + report.derived_edges) as i64;
7188                }
7189                Err(GraphError::KeyNotFound { .. }) => {
7190                    // Node may have been deleted by an earlier iteration (e.g., via
7191                    // multiple MATCH rows for the same node).  Safe to skip.
7192                }
7193                Err(e) => return Err(e),
7194            }
7195        }
7196
7197        let mut rs = write_result_set();
7198        rs.push_row(vec![
7199            Some(Value::Int(0)),
7200            Some(Value::Int(0)),
7201            Some(Value::Int(nodes_deleted + edges_deleted)),
7202        ]);
7203        Ok(rs)
7204    }
7205
7206    fn exec_merge(
7207        &mut self,
7208        stmt: core_query::cypher::MergeStmt,
7209        params: &BTreeMap<String, Value>,
7210    ) -> Result<ResultSet> {
7211        // MERGE: check if a node with the given key already exists.
7212        let key = match &stmt.key_value {
7213            Value::Str(s) => s.clone(),
7214            _ => {
7215                return Err(GraphError::QueryError {
7216                    detail: format!(
7217                        "MERGE key value must be a string (got {:?})",
7218                        stmt.key_value
7219                    ),
7220                })
7221            }
7222        };
7223
7224        if let Some(var) = stmt.var.as_deref() {
7225            for sc in stmt.on_create.iter().chain(&stmt.on_match) {
7226                if sc.var != var {
7227                    return Err(GraphError::QueryError {
7228                        detail: format!(
7229                            "SET variable '{}' does not match MERGE variable '{var}'",
7230                            sc.var
7231                        ),
7232                    });
7233                }
7234            }
7235        }
7236
7237        // ── MERGE authz pre-check (when role-scoped) ─────────────────────────
7238        //
7239        // MERGE scope precondition: check create OR update scope for the
7240        // declared label BEFORE calling `has_node` (timing-oracle closure,
7241        // spec §6.2 "MERGE visibility oracle" item: hidden ≡ absent for
7242        // unscoped roles — the scope denial fires without touching the key store).
7243        //
7244        // Clone to avoid holding a borrow on `self.pending_write_authz` while
7245        // also calling `self.ids.get(key)`.
7246        let merge_existed: bool = if let Some(authz) = self.pending_write_authz.clone() {
7247            let has_create = authz.scope.create_labels.contains(&stmt.label);
7248            let has_update = authz.scope.update_labels.contains(&stmt.label);
7249            if !has_create && !has_update {
7250                // Scope-before-lookup: 403 without has_node call (timing oracle
7251                // closure — see test_merge_unscoped_no_key_lookup).
7252                return Err(GraphError::RoleWriteDenied {
7253                    reason: format!(
7254                        "role-bound token: label '{}' not in write scope (create_labels)",
7255                        stmt.label
7256                    ),
7257                });
7258            }
7259            // Key lookup under mask.
7260            match self.ids.get(key.as_str()) {
7261                Some(id) if authz.mask.contains_id(id) => {
7262                    // Visible: must have update scope to proceed to match arm.
7263                    if !has_update {
7264                        return Err(GraphError::RoleWriteDenied {
7265                            reason: format!(
7266                                "role-bound token: label '{}' not in write scope (update_labels)",
7267                                stmt.label
7268                            ),
7269                        });
7270                    }
7271                    true // existed = true → match arm
7272                }
7273                Some(_) => {
7274                    // Hidden: same error as absent to the role (spec §3.1/§3.3).
7275                    return Err(GraphError::RoleWriteDenied {
7276                        reason: "role-bound token: target node not visible".into(),
7277                    });
7278                }
7279                None => {
7280                    // Absent: must have create scope to proceed to the create arm.
7281                    //
7282                    // Update-only roles (create_labels empty, update_labels set):
7283                    // return the SAME "not visible" error as the hidden-key branch
7284                    // so hidden ≡ absent — no distinguishing oracle (spec §6.1
7285                    // "confirm existence of hidden nodes: No").
7286                    //
7287                    // Create-scoped roles (has_create=true): absent → create arm
7288                    // as before.  The accepted structural key-existence disclosure
7289                    // (§THREAT-MODEL) applies only when the role holds create scope.
7290                    if !has_create {
7291                        return Err(GraphError::RoleWriteDenied {
7292                            reason: "role-bound token: target node not visible".into(),
7293                        });
7294                    }
7295                    false // existed = false → create arm
7296                }
7297            }
7298        } else {
7299            // Full authority: use the existing non-masked has_node check.
7300            self.has_node(&key)
7301        };
7302
7303        let existed = merge_existed;
7304        let mut created = 0i64;
7305        if !existed || !stmt.on_match.is_empty() {
7306            let mut batch = self.batch();
7307            if !existed {
7308                let props = vec![(stmt.key_field.clone(), stmt.key_value.clone())];
7309                batch.insert_node(&stmt.label, &key, props);
7310                for sc in &stmt.on_create {
7311                    let value = resolve_merge_set_value(&sc.value, params)?;
7312                    batch.set_prop(&key, &sc.field, value);
7313                }
7314                created = 1;
7315            } else {
7316                for sc in &stmt.on_match {
7317                    let value = resolve_merge_set_value(&sc.value, params)?;
7318                    batch.set_prop(&key, &sc.field, value);
7319                }
7320            }
7321            batch.commit()?;
7322        }
7323
7324        // Refresh the role mask so the just-created node is visible to this
7325        // statement's RETURN (read-after-write). Safe: create_labels ⊆ read labels
7326        // (apply_schema subset rule), so the new node's label is already in the
7327        // role's read scope — this never widens beyond the role's declared labels.
7328        if !existed {
7329            if let Some(role) = self.pending_write_authz.as_ref().map(|a| a.role.clone()) {
7330                let new_mask = self.mask_for_role(&role)?;
7331                if let Some(a) = self.pending_write_authz.as_mut() {
7332                    a.mask = new_mask;
7333                }
7334            }
7335        }
7336
7337        // Optional RETURN clause: project the node (created or matched) as a read result.
7338        if let Some(returns) = stmt.returns {
7339            let var = stmt.var.as_deref().unwrap_or("_mn0");
7340            let q = Query {
7341                matches: vec![Pattern {
7342                    start: NodePat {
7343                        var: Some(var.to_string()),
7344                        label: Some(stmt.label.clone()),
7345                        props: vec![("id".to_string(), Operand::Lit(stmt.key_value.clone()))],
7346                    },
7347                    chain: vec![],
7348                    shortest: false,
7349                }],
7350                optional_clauses: vec![],
7351                where_expr: None,
7352                unwinds: vec![],
7353                post_unwind_where: None,
7354                stages: vec![],
7355                returns,
7356                distinct: false,
7357                order_by: vec![],
7358                skip: None,
7359                limit: None,
7360            };
7361            let ops = plan(&q).map_err(|e| GraphError::QueryError {
7362                detail: format!("plan: {e}"),
7363            })?;
7364            // Use view_masked when a role-scoped write is in flight so the
7365            // post-merge projection is consistent with the masked read phase.
7366            let mask_opt = self.pending_write_authz.as_ref().map(|a| a.mask.clone());
7367            return (if let Some(ref mask) = mask_opt {
7368                execute(&self.view_masked(mask), &ops, &Params(params))
7369            } else {
7370                execute(&self.view(), &ops, &Params(params))
7371            })
7372            .map_err(|e| GraphError::QueryError {
7373                detail: format!("execute: {e}"),
7374            });
7375        }
7376
7377        let mut rs = write_result_set();
7378        rs.push_row(vec![
7379            Some(Value::Int(created)),
7380            Some(Value::Int(0)),
7381            Some(Value::Int(0)),
7382        ]);
7383        Ok(rs)
7384    }
7385
7386    /// Return all rule-owned edges between `key_a` and `key_b` (either direction),
7387    /// annotated with rule name, edge type, direction, and weight.
7388    /// Results are sorted by (rule, edge_type).
7389    /// Returns `Err(KeyNotFound)` if either key is unknown.
7390    pub fn explain(&self, key_a: &str, key_b: &str) -> Result<Vec<Explanation>> {
7391        self.ensure_v8_base_sections_loaded();
7392        let id_a = self
7393            .ids
7394            .get(key_a)
7395            .ok_or_else(|| GraphError::KeyNotFound { key: key_a.into() })?;
7396        let id_b = self
7397            .ids
7398            .get(key_b)
7399            .ok_or_else(|| GraphError::KeyNotFound { key: key_b.into() })?;
7400
7401        let mut results = Vec::new();
7402
7403        // Walk the smaller incident set so explain is O(min(deg(a), deg(b)))
7404        // rather than O(total provenance).
7405        let scan = if self.engine.provenance_touching_len(id_a)
7406            <= self.engine.provenance_touching_len(id_b)
7407        {
7408            id_a
7409        } else {
7410            id_b
7411        };
7412        for (rule_name, etype, src, dst) in self.engine.provenance_touching(scan) {
7413            if !((src == id_a && dst == id_b) || (src == id_b && dst == id_a)) {
7414                continue;
7415            }
7416            let Some(rule_def) = self.engine.rules().find(|r| r.name == rule_name) else {
7417                continue;
7418            };
7419            let edge_type = match self.syms.resolve(etype) {
7420                Some(s) => s.to_string(),
7421                None => continue,
7422            };
7423            // Provenance (src, dst) ids come from the archived PROVENANCE section
7424            // (large, no eager CRC).  A corrupt section can produce ids that are
7425            // out of range; return Corrupt rather than panic.
7426            let src_key = self
7427                .ids
7428                .key_of(src)
7429                .ok_or_else(|| GraphError::Corrupt {
7430                    detail: format!("v8: provenance src id {src} not in id table"),
7431                })?
7432                .to_string();
7433            let dst_key = self
7434                .ids
7435                .key_of(dst)
7436                .ok_or_else(|| GraphError::Corrupt {
7437                    detail: format!("v8: provenance dst id {dst} not in id table"),
7438                })?
7439                .to_string();
7440            let weight = rule_def.weight_prop.as_deref().and_then(|prop| {
7441                self.edge_props_view()
7442                    .get(etype, src, dst, prop)
7443                    .and_then(|v| {
7444                        if let Value::Float(f) = v {
7445                            Some(f)
7446                        } else {
7447                            None
7448                        }
7449                    })
7450            });
7451            results.push(Explanation {
7452                rule: rule_name.to_string(),
7453                edge_type,
7454                src_key,
7455                dst_key,
7456                weight,
7457                predicate: PredicateSummary {
7458                    approximate: rule_def.approximate,
7459                    ..PredicateSummary::from(&rule_def.predicate)
7460                },
7461            });
7462        }
7463
7464        results.sort_by(|a, b| a.rule.cmp(&b.rule).then(a.edge_type.cmp(&b.edge_type)));
7465        Ok(results)
7466    }
7467
7468    pub fn neighbors(&self, key: &str, edge_type: &str, dir: Direction) -> Result<Vec<String>> {
7469        let id = self
7470            .ids
7471            .get(key)
7472            .ok_or_else(|| GraphError::KeyNotFound { key: key.into() })?;
7473        let Some(sym) = self.syms.get(edge_type) else {
7474            return Ok(Vec::new());
7475        };
7476        self.topo_view()
7477            .neighbors(sym, dir, id)
7478            .iter()
7479            .map(|&n| {
7480                self.ids
7481                    .key_of(n)
7482                    .map(|k| k.to_string())
7483                    .ok_or_else(|| GraphError::Corrupt {
7484                        detail: format!("topology id {n} has no key"),
7485                    })
7486            })
7487            .collect::<Result<Vec<_>>>()
7488    }
7489
7490    /// Return the last-change commit sequence for `key`, or `None` if the node
7491    /// does not exist or has never been mutated since the last V5-V7 snapshot
7492    /// (horizon-bounded for legacy stores).
7493    ///
7494    /// The returned sequence is a monotonically increasing counter that starts
7495    /// at 1 for the first commit after `open` and increments with every
7496    /// successful write.  WAL replay at open also assigns sequences (1..N for N
7497    /// replayed frames), so sequences are consistent across snapshot+WAL cycles.
7498    ///
7499    /// For V5-V7 stores opened without a V8 snapshot, nodes that were present
7500    /// in the snapshot but not touched by any WAL frame will return `None`
7501    /// (horizon-bounded: CAS against such nodes is only safe after the first
7502    /// V8 snapshot or after the node is next mutated).
7503    pub fn last_changed(&self, key: &str) -> Option<u64> {
7504        let id = self.ids.get(key)?;
7505        self.last_change.get(&id).copied()
7506    }
7507
7508    /// The current commit sequence (number of successful commits since open,
7509    /// including WAL replay frames).  Useful for recording a baseline before
7510    /// a read-modify-write cycle.
7511    pub fn commit_seq(&self) -> u64 {
7512        self.commit_seq
7513    }
7514
7515    /// Check that all `preconds` are satisfied against the current db state.
7516    /// Returns `Err(GraphError::CasConflict)` on the first failing precondition.
7517    pub(crate) fn check_preconditions(&self, preconds: &[Precondition]) -> Result<()> {
7518        for precond in preconds {
7519            match precond {
7520                Precondition::NodeUnchangedSince { key, expected } => {
7521                    // Missing entry means the node predates the WAL window or
7522                    // does not exist; treat as 0 (before any commit).
7523                    let actual = self.last_changed(key).unwrap_or_default();
7524                    if actual != *expected {
7525                        return Err(GraphError::CasConflict {
7526                            key: key.clone(),
7527                            expected: *expected,
7528                            actual,
7529                        });
7530                    }
7531                }
7532                Precondition::NodeAbsent { key } => {
7533                    // Node must not exist (not live).
7534                    if self.ids.get(key).is_some() {
7535                        let actual = self.last_changed(key).unwrap_or(0);
7536                        return Err(GraphError::CasConflict {
7537                            key: key.clone(),
7538                            expected: u64::MAX,
7539                            actual,
7540                        });
7541                    }
7542                }
7543            }
7544        }
7545        Ok(())
7546    }
7547
7548    /// Apply a batch of mutations with compare-and-set preconditions.
7549    ///
7550    /// All preconditions are checked atomically before any operation is applied.
7551    /// If any precondition fails, the entire batch is rejected with
7552    /// [`GraphError::CasConflict`] and no WAL frame is written.
7553    ///
7554    /// # Returns
7555    /// `(nodes_inserted, edges_inserted)` on success, same as [`write_batch`].
7556    ///
7557    /// # Errors
7558    /// - [`GraphError::CasConflict`] if any precondition is not satisfied.
7559    /// - Any error that [`write_batch`] would return for the ops themselves.
7560    pub fn write_batch_cas(
7561        &mut self,
7562        preconds: Vec<Precondition>,
7563        ops: Vec<BatchOp>,
7564    ) -> Result<(usize, usize)> {
7565        self.check_preconditions(&preconds)?;
7566        self.commit_logged_batch(ops, None, None)
7567    }
7568
7569    /// Update the per-node last-change map for a WAL record at commit `seq`.
7570    ///
7571    /// Called after a successful apply to record which nodes were touched.
7572    /// For replay, called with the WAL-frame's replayed seq.
7573    ///
7574    /// Touch definition (see [`Precondition`] doc):
7575    /// - InsertNode / InsertNodeId / SetProp / SetPropId / RemoveProp → the node.
7576    /// - InsertEdge / InsertEdgeId / DeleteEdge → both src and dst.
7577    /// - DeleteNode → node tombstoned; last_changed() returns None so no update needed.
7578    /// - DerivedEdge markers, Intern, rule/view records → no-ops.
7579    /// - Batch → recurse into inner records.
7580    fn update_last_change_from_rec(&mut self, rec: &WalRecord, seq: u64) {
7581        match rec {
7582            WalRecord::InsertNode { key, .. }
7583            | WalRecord::SetProp { key, .. }
7584            | WalRecord::RemoveProp { key, .. } => {
7585                if let Some(id) = self.ids.get(key) {
7586                    self.last_change.insert(id, seq);
7587                }
7588            }
7589            WalRecord::InsertNodeId { key, .. } => {
7590                if let Some(id) = self.ids.get(key) {
7591                    self.last_change.insert(id, seq);
7592                }
7593            }
7594            WalRecord::SetPropId { id, .. } => {
7595                self.last_change.insert(*id, seq);
7596            }
7597            WalRecord::InsertEdge {
7598                src_key, dst_key, ..
7599            }
7600            | WalRecord::DeleteEdge {
7601                src_key, dst_key, ..
7602            } => {
7603                if let Some(src_id) = self.ids.get(src_key) {
7604                    self.last_change.insert(src_id, seq);
7605                }
7606                if let Some(dst_id) = self.ids.get(dst_key) {
7607                    self.last_change.insert(dst_id, seq);
7608                }
7609            }
7610            WalRecord::InsertEdgeId { src, dst, .. } => {
7611                self.last_change.insert(*src, seq);
7612                self.last_change.insert(*dst, seq);
7613            }
7614            // DeleteNode: node is tombstoned; last_changed(key) returns None for
7615            // deleted keys (ids.get() returns None post-tombstone), so no update needed.
7616            // History markers: state no-ops; the underlying mutation already
7617            // touched the relevant nodes' last_change entries.
7618            WalRecord::DeleteNode { .. }
7619            | WalRecord::DerivedEdgeAdded { .. }
7620            | WalRecord::DerivedEdgeRetracted { .. }
7621            | WalRecord::Intern { .. }
7622            | WalRecord::CreateRule { .. }
7623            | WalRecord::DeleteRule { .. }
7624            | WalRecord::RebuildRule { .. }
7625            | WalRecord::CreateView { .. }
7626            | WalRecord::DeleteView { .. }
7627            | WalRecord::EnableFulltext { .. }
7628            | WalRecord::DisableFulltext { .. }
7629            | WalRecord::EnableIndex { .. }
7630            | WalRecord::DisableIndex { .. } => {}
7631            // RenameNode: node id is stable; update last_change via the new key.
7632            // Called after apply(), so ids already reflects new_key.
7633            WalRecord::RenameNode { new_key, .. } => {
7634                if let Some(id) = self.ids.get(new_key) {
7635                    self.last_change.insert(id, seq);
7636                }
7637            }
7638            WalRecord::Batch(inner) => {
7639                for inner_rec in inner {
7640                    self.update_last_change_from_rec(inner_rec, seq);
7641                }
7642            }
7643        }
7644    }
7645
7646    pub fn node_count(&self) -> usize {
7647        self.ids.len()
7648    }
7649
7650    /// Configure archive retention: keep the `N` newest WAL archives at each
7651    /// [`snapshot_with`] call when `archive_wal: true`.
7652    ///
7653    /// `Some(N)` where N > 0 → prune oldest archives keeping the newest N.
7654    /// `Some(0)` or `None` → unlimited (no pruning).
7655    ///
7656    /// Pruning only ever happens inside [`snapshot_with`]; this method only
7657    /// stores the policy.  Archives below the retention limit are deleted
7658    /// oldest-first.  The horizon floor is updated so that
7659    /// [`was_linked`] / history APIs return `CommitOutOfRange` for commits
7660    /// in pruned archives rather than silently returning wrong data.
7661    pub fn set_wal_archive_retention(&mut self, keep: Option<u32>) {
7662        self.wal_archive_retention = keep;
7663    }
7664
7665    /// Delete any WAL archives that are fully below the current horizon floor.
7666    ///
7667    /// Orphaned archives arise when the floor is written first during retention
7668    /// pruning and then a crash interrupts the archive-delete sequence.  The
7669    /// opening cleanup ensures no subsequent read path sees stale data.
7670    ///
7671    /// Under the monotonic naming scheme, the archive name N equals the
7672    /// cumulative end-frame index of the archive in global commit space (i.e.
7673    /// the archive covers global frames `[prev_n, N)`).  An archive is
7674    /// fully orphaned when `N <= wal_horizon_floor`: all of its frames fall
7675    /// below the floor and have already been counted in it.
7676    fn cleanup_orphaned_archives(&mut self) -> Result<()> {
7677        if self.wal_horizon_floor == 0 {
7678            // Floor at 0 means no pruning has ever occurred; nothing to clean.
7679            return Ok(());
7680        }
7681        let archive_ns = self.fs.list_archives()?;
7682        for n in archive_ns {
7683            if n <= self.wal_horizon_floor {
7684                // Archive N ends at global frame N; all its frames are below
7685                // the floor (floor already accounts for them) → orphaned.
7686                self.fs.delete_archive(n).map_err(GraphError::Io)?;
7687            } else {
7688                // Archives are sorted ascending; first one above floor stops scan.
7689                break;
7690            }
7691        }
7692        Ok(())
7693    }
7694
7695    /// Collect all WAL frames from surviving archives (oldest-first) then the
7696    /// live WAL into one flat list, and return the total along with the number
7697    /// of archive frames at the front of the list.
7698    ///
7699    /// Commit indices into the returned list are LOCAL (0 = first frame of
7700    /// oldest surviving archive).  To obtain the GLOBAL index add
7701    /// `self.wal_horizon_floor`.
7702    fn all_frames(&self) -> Result<(Vec<WalRecord>, u64)> {
7703        let archive_ns = self.fs.list_archives()?;
7704        let mut all: Vec<WalRecord> = Vec::new();
7705        for n in archive_ns {
7706            let bytes = self.fs.read_archive(n)?;
7707            let (frames, _) = decode_all(&bytes);
7708            all.extend(frames);
7709        }
7710        let archive_count = all.len() as u64;
7711        let live_bytes = self.fs.read(FileId::Wal)?;
7712        let (live_frames, _) = decode_all(&live_bytes);
7713        all.extend(live_frames);
7714        Ok((all, archive_count))
7715    }
7716
7717    /// Return the total number of committed WAL frames visible in the current
7718    /// horizon window, including frames in surviving WAL archives.
7719    ///
7720    /// This is the exclusive upper bound for valid `at_commit` indices in
7721    /// `was_linked`. Valid indices are `wal_horizon_floor()..wal_total_commits()`.
7722    ///
7723    /// Returns the horizon floor when all surviving history is empty.
7724    pub fn wal_total_commits(&self) -> Result<u64> {
7725        let (frames, _) = self.all_frames()?;
7726        Ok(self.wal_horizon_floor + frames.len() as u64)
7727    }
7728
7729    /// The global frame index of the first commit reachable through surviving
7730    /// archives (0 when no archives have been pruned).
7731    pub fn wal_horizon_floor(&self) -> u64 {
7732        self.wal_horizon_floor
7733    }
7734
7735    /// Return the per-node change history for `key` by scanning the on-disk WAL.
7736    ///
7737    /// ## Horizon
7738    ///
7739    /// History reaches back only to the last WAL-truncating snapshot, exactly like `open_at`.
7740    /// Snapshots written with `keep_wal: true` preserve deeper history. This is the honest,
7741    /// zero-cost contract; a durable history log is out of scope.
7742    ///
7743    /// ## Derived edges
7744    ///
7745    /// Rule-created (derived) edges are **not** in the WAL and therefore do not appear in
7746    /// history. Only edges written directly by the application are recorded.
7747    ///
7748    /// ## Deleted nodes
7749    ///
7750    /// For nodes that have been deleted, dense-id records (SetPropId, InsertEdgeId) that
7751    /// predate the deletion may not resolve (the id is tombstoned in the live map). The
7752    /// string-keyed `DeleteNode` record still matches and produces a `NodeDeleted` entry.
7753    /// Prop/edge history of a deleted node may therefore be partially unresolvable.
7754    ///
7755    /// ## Dense-id edge entries and tombstoned partners
7756    ///
7757    /// Edge entries from dense-id WAL records (`InsertEdgeId`) are omitted when the partner
7758    /// endpoint's dense id is tombstoned. As a result, a live node's history can contain an
7759    /// `EdgeRemoved` (string-keyed, always resolves) without a corresponding `EdgeAdded`.
7760    /// Build commit-bounded alias intervals for `queried_key`.
7761    ///
7762    /// Returns a list of `(key, valid_from_inclusive, valid_until_exclusive)` tuples.
7763    /// A record written under `key` at commit `c` matches the queried identity iff
7764    /// `c >= valid_from && (valid_until.is_none() || c < valid_until)`.
7765    ///
7766    /// Each alias entry carries both a lower and an upper bound so that key-reuse
7767    /// after a rename is handled correctly: if "a" is renamed to "b" at commit 5,
7768    /// then a NEW node is created as "a" at commit 7 and renamed to "c" at commit 10,
7769    /// querying "c" must NOT surface identity-1's events (commits 0–4 under "a");
7770    /// only identity-2's events (commits 7–9 under "a") are in scope.
7771    ///
7772    /// Only **forward aliasing**: querying the *new* key surfaces events written
7773    /// under the *old* key.  The reverse direction is not supported.
7774    fn build_key_alias_intervals(
7775        &self,
7776        frames: &[core_storage::wal::WalRecord],
7777        queried_key: &str,
7778    ) -> Vec<(String, u64, Option<u64>)> {
7779        use core_storage::wal::WalRecord;
7780
7781        // Pre-pass: build reverse_rename and key_starts maps.
7782        let mut reverse_rename: HashMap<String, (String, u64)> = HashMap::new();
7783        let mut key_starts: HashMap<String, Vec<u64>> = HashMap::new();
7784
7785        for (local_i, frame) in frames.iter().enumerate() {
7786            let commit = self.wal_horizon_floor + local_i as u64;
7787            let records: &[WalRecord] = match frame {
7788                WalRecord::Batch(inner) => inner.as_slice(),
7789                single => std::slice::from_ref(single),
7790            };
7791            for rec in records {
7792                match rec {
7793                    WalRecord::InsertNode { key, .. } | WalRecord::InsertNodeId { key, .. } => {
7794                        key_starts.entry(key.clone()).or_default().push(commit);
7795                    }
7796                    WalRecord::RenameNode { old_key, new_key } => {
7797                        // new_key came into existence at this commit.
7798                        key_starts.entry(new_key.clone()).or_default().push(commit);
7799                        // Record the reverse rename: new_key was introduced by renaming old_key.
7800                        reverse_rename.insert(new_key.clone(), (old_key.clone(), commit));
7801                    }
7802                    _ => {}
7803                }
7804            }
7805        }
7806
7807        // Build alias intervals by following the reverse rename chain.
7808        let mut result: Vec<(String, u64, Option<u64>)> = Vec::new();
7809        let mut current_key = queried_key.to_string();
7810        let mut current_valid_until: Option<u64> = None;
7811
7812        loop {
7813            // valid_from: the most recent commit where current_key was assigned to this
7814            // identity.  For aliases (valid_until = Some(vu)), find the last start event
7815            // for the key strictly before vu — this is where the alias's occupancy by
7816            // this identity began, correctly excluding prior identities that reused the key.
7817            let valid_from = if let Some(vu) = current_valid_until {
7818                key_starts
7819                    .get(&current_key)
7820                    .and_then(|starts| starts.iter().rev().find(|&&s| s < vu).copied())
7821                    .unwrap_or(self.wal_horizon_floor)
7822            } else {
7823                // Queried key — no upper bound; may have been introduced at any commit.
7824                self.wal_horizon_floor
7825            };
7826
7827            result.push((current_key.clone(), valid_from, current_valid_until));
7828
7829            match reverse_rename.get(&current_key) {
7830                Some((old_key, rename_commit)) => {
7831                    current_valid_until = Some(*rename_commit);
7832                    current_key = old_key.clone();
7833                }
7834                None => break,
7835            }
7836        }
7837
7838        result
7839    }
7840
7841    /// Returns true if `record_key` matches any alias interval that covers `commit`.
7842    fn aliases_match(
7843        intervals: &[(String, u64, Option<u64>)],
7844        record_key: &str,
7845        commit: u64,
7846    ) -> bool {
7847        intervals
7848            .iter()
7849            .any(|(k, vf, vu)| k == record_key && commit >= *vf && vu.is_none_or(|u| commit < u))
7850    }
7851
7852    pub fn node_history(&self, key: &str) -> Result<Vec<crate::history::HistoryEntry>> {
7853        use crate::history::{HistoryChange, HistoryEntry};
7854        use core_storage::wal::WalRecord;
7855
7856        let (frames, _) = self.all_frames()?;
7857
7858        // Resolve commit-bounded alias intervals for `key` (handles renames in the WAL).
7859        let alias_intervals = self.build_key_alias_intervals(&frames, key);
7860
7861        let mut out: Vec<HistoryEntry> = Vec::new();
7862
7863        for (local_i, frame) in frames.iter().enumerate() {
7864            let commit = self.wal_horizon_floor + local_i as u64;
7865            // Collect the inner records to process — Batch is one commit, single records are one commit.
7866            let records: &[WalRecord] = match frame {
7867                WalRecord::Batch(inner) => inner.as_slice(),
7868                single => std::slice::from_ref(single),
7869            };
7870
7871            for rec in records {
7872                let change = match rec {
7873                    WalRecord::InsertNode { label, key: k, .. }
7874                        if Self::aliases_match(&alias_intervals, k, commit) =>
7875                    {
7876                        Some(HistoryChange::NodeInserted {
7877                            label: label.clone(),
7878                        })
7879                    }
7880                    WalRecord::InsertNodeId { label, key: k, .. }
7881                        if Self::aliases_match(&alias_intervals, k, commit) =>
7882                    {
7883                        let label_str = match self.syms.resolve(*label) {
7884                            Some(s) => s.to_string(),
7885                            None => continue,
7886                        };
7887                        Some(HistoryChange::NodeInserted { label: label_str })
7888                    }
7889                    WalRecord::SetProp {
7890                        key: k,
7891                        field,
7892                        value,
7893                    } if Self::aliases_match(&alias_intervals, k, commit) => {
7894                        Some(HistoryChange::PropSet {
7895                            field: field.clone(),
7896                            value: value.clone(),
7897                        })
7898                    }
7899                    WalRecord::SetPropId { id, field, value } => match self.ids.key_of(*id) {
7900                        // key_of returns the current (post-rename) key; compare to queried key.
7901                        Some(resolved) if resolved == key => {
7902                            let field_str = match self.syms.resolve(*field) {
7903                                Some(s) => s.to_string(),
7904                                None => continue,
7905                            };
7906                            Some(HistoryChange::PropSet {
7907                                field: field_str,
7908                                value: value.clone(),
7909                            })
7910                        }
7911                        _ => None,
7912                    },
7913                    WalRecord::RemoveProp { key: k, field }
7914                        if Self::aliases_match(&alias_intervals, k, commit) =>
7915                    {
7916                        Some(HistoryChange::PropRemoved {
7917                            field: field.clone(),
7918                        })
7919                    }
7920                    WalRecord::InsertEdge {
7921                        edge_type,
7922                        src_key,
7923                        dst_key,
7924                    } => {
7925                        if Self::aliases_match(&alias_intervals, src_key, commit) {
7926                            Some(HistoryChange::EdgeAdded {
7927                                edge_type: edge_type.clone(),
7928                                other: dst_key.clone(),
7929                                outgoing: true,
7930                            })
7931                        } else if Self::aliases_match(&alias_intervals, dst_key, commit) {
7932                            Some(HistoryChange::EdgeAdded {
7933                                edge_type: edge_type.clone(),
7934                                other: src_key.clone(),
7935                                outgoing: false,
7936                            })
7937                        } else {
7938                            None
7939                        }
7940                    }
7941                    WalRecord::InsertEdgeId { etype, src, dst } => {
7942                        let etype_str = match self.syms.resolve(*etype) {
7943                            Some(s) => s.to_string(),
7944                            None => continue,
7945                        };
7946                        let src_key = self.ids.key_of(*src);
7947                        let dst_key = self.ids.key_of(*dst);
7948                        if src_key == Some(key) {
7949                            let other = match dst_key {
7950                                Some(s) => s.to_string(),
7951                                None => continue,
7952                            };
7953                            Some(HistoryChange::EdgeAdded {
7954                                edge_type: etype_str,
7955                                other,
7956                                outgoing: true,
7957                            })
7958                        } else if dst_key == Some(key) {
7959                            let other = match src_key {
7960                                Some(s) => s.to_string(),
7961                                None => continue,
7962                            };
7963                            Some(HistoryChange::EdgeAdded {
7964                                edge_type: etype_str,
7965                                other,
7966                                outgoing: false,
7967                            })
7968                        } else {
7969                            None
7970                        }
7971                    }
7972                    WalRecord::DeleteEdge {
7973                        edge_type,
7974                        src_key,
7975                        dst_key,
7976                    } => {
7977                        if Self::aliases_match(&alias_intervals, src_key, commit) {
7978                            Some(HistoryChange::EdgeRemoved {
7979                                edge_type: edge_type.clone(),
7980                                other: dst_key.clone(),
7981                                outgoing: true,
7982                            })
7983                        } else if Self::aliases_match(&alias_intervals, dst_key, commit) {
7984                            Some(HistoryChange::EdgeRemoved {
7985                                edge_type: edge_type.clone(),
7986                                other: src_key.clone(),
7987                                outgoing: false,
7988                            })
7989                        } else {
7990                            None
7991                        }
7992                    }
7993                    WalRecord::DeleteNode { key: k }
7994                        if Self::aliases_match(&alias_intervals, k, commit) =>
7995                    {
7996                        Some(HistoryChange::NodeDeleted)
7997                    }
7998                    // Skip: rule/view/fulltext/intern metadata; Batch wrapper handled above.
7999                    _ => None,
8000                };
8001
8002                if let Some(change) = change {
8003                    out.push(HistoryEntry { commit, change });
8004                }
8005            }
8006        }
8007
8008        Ok(out)
8009    }
8010
8011    /// Return the per-edge change history between nodes `a` and `b` by scanning
8012    /// the on-disk WAL.
8013    ///
8014    /// ## Horizon
8015    ///
8016    /// History reaches back only to the last WAL-truncating snapshot, exactly
8017    /// like `node_history` and `open_at`. The returned [`HistoryResult`] carries
8018    /// `total_commits` (= number of WAL frames), which is the exclusive upper
8019    /// bound for valid commit indices.
8020    ///
8021    /// ## Derived edges
8022    ///
8023    /// Rule-derived edges appear via `DerivedEdgeAdded` / `DerivedEdgeRetracted`
8024    /// WAL markers written by `log_then_apply_with` after each rule-firing
8025    /// mutation. The `rule` field of those events carries the rule name.
8026    ///
8027    /// ## DeleteNode
8028    ///
8029    /// When a node is deleted, its manual incident edges are swept inline without
8030    /// individual `DeleteEdge` WAL records. `edge_history` detects `DeleteNode`
8031    /// events for either endpoint and synthesises `Retracted(rule:None)` events
8032    /// for each manual edge that was active at that point. Derived edges active at
8033    /// the time of deletion are handled by the `DerivedEdgeRetracted` marker that
8034    /// the engine appends immediately after the `DeleteNode` record; those events
8035    /// carry correct rule attribution and are emitted by the marker arm, not the
8036    /// synthetic sweep.
8037    ///
8038    /// ## Masks
8039    ///
8040    /// Like `node_history`, this method has no mask parameter and returns WAL
8041    /// history regardless of any role mask. For masked history semantics, apply
8042    /// the mask at the caller level.
8043    pub fn edge_history(
8044        &self,
8045        a: &str,
8046        b: &str,
8047    ) -> Result<crate::history::HistoryResult<crate::history::EdgeHistoryEvent>> {
8048        use crate::history::{EdgeEvent, EdgeHistoryEvent, HistoryResult};
8049        use core_storage::wal::WalRecord;
8050
8051        let (frames, _) = self.all_frames()?;
8052        let total_commits = self.wal_horizon_floor + frames.len() as u64;
8053
8054        // Resolve all historical names for a and b (handles RenameNode in the WAL).
8055        // Intervals are commit-bounded so recycled keys don't contaminate histories.
8056        let alias_a = self.build_key_alias_intervals(&frames, a);
8057        let alias_b = self.build_key_alias_intervals(&frames, b);
8058
8059        // Active edges between a and b tracked as (edge_type, src_key, dst_key, is_derived).
8060        // The is_derived flag is used by the DeleteNode sweep: manual edges are
8061        // swept with a synthetic Retracted(rule:None); derived edges are skipped
8062        // because the engine writes a DerivedEdgeRetracted marker immediately after
8063        // the DeleteNode record, which carries the correct rule attribution.
8064        let mut active: Vec<(String, String, String, bool)> = Vec::new();
8065        let mut out: Vec<EdgeHistoryEvent> = Vec::new();
8066
8067        for (local_i, frame) in frames.iter().enumerate() {
8068            let commit = self.wal_horizon_floor + local_i as u64;
8069            let records: &[WalRecord] = match frame {
8070                WalRecord::Batch(inner) => inner.as_slice(),
8071                single => std::slice::from_ref(single),
8072            };
8073
8074            for rec in records {
8075                match rec {
8076                    WalRecord::InsertEdge {
8077                        edge_type,
8078                        src_key,
8079                        dst_key,
8080                    } => {
8081                        let is_ab = Self::aliases_match(&alias_a, src_key, commit)
8082                            && Self::aliases_match(&alias_b, dst_key, commit);
8083                        let is_ba = Self::aliases_match(&alias_b, src_key, commit)
8084                            && Self::aliases_match(&alias_a, dst_key, commit);
8085                        if is_ab || is_ba {
8086                            active.push((
8087                                edge_type.clone(),
8088                                src_key.clone(),
8089                                dst_key.clone(),
8090                                false,
8091                            ));
8092                            out.push(EdgeHistoryEvent {
8093                                edge_type: edge_type.clone(),
8094                                commit,
8095                                event: EdgeEvent::Added,
8096                                rule: None,
8097                            });
8098                        }
8099                    }
8100                    WalRecord::InsertEdgeId { etype, src, dst } => {
8101                        let etype_str = match self.syms.resolve(*etype) {
8102                            Some(s) => s.to_string(),
8103                            None => continue,
8104                        };
8105                        // Use key_of_historical so tombstoned nodes (deleted
8106                        // later in the WAL) still resolve during the scan.
8107                        let src_key = self.ids.key_of_historical(*src);
8108                        let dst_key = self.ids.key_of_historical(*dst);
8109                        let is_ab = src_key == Some(a) && dst_key == Some(b);
8110                        let is_ba = src_key == Some(b) && dst_key == Some(a);
8111                        if is_ab || is_ba {
8112                            let src_str = src_key.unwrap().to_string();
8113                            let dst_str = dst_key.unwrap().to_string();
8114                            active.push((etype_str.clone(), src_str, dst_str, false));
8115                            out.push(EdgeHistoryEvent {
8116                                edge_type: etype_str,
8117                                commit,
8118                                event: EdgeEvent::Added,
8119                                rule: None,
8120                            });
8121                        }
8122                    }
8123                    WalRecord::DeleteEdge {
8124                        edge_type,
8125                        src_key,
8126                        dst_key,
8127                    } => {
8128                        let is_ab = Self::aliases_match(&alias_a, src_key, commit)
8129                            && Self::aliases_match(&alias_b, dst_key, commit);
8130                        let is_ba = Self::aliases_match(&alias_b, src_key, commit)
8131                            && Self::aliases_match(&alias_a, dst_key, commit);
8132                        if is_ab || is_ba {
8133                            // Remove the first matching active entry (flag ignored).
8134                            if let Some(pos) = active.iter().position(|(et, s, d, _)| {
8135                                et == edge_type && s == src_key && d == dst_key
8136                            }) {
8137                                active.remove(pos);
8138                            }
8139                            out.push(EdgeHistoryEvent {
8140                                edge_type: edge_type.clone(),
8141                                commit,
8142                                event: EdgeEvent::Retracted,
8143                                rule: None,
8144                            });
8145                        }
8146                    }
8147                    WalRecord::DeleteNode { key: k }
8148                        if Self::aliases_match(&alias_a, k, commit)
8149                            || Self::aliases_match(&alias_b, k, commit) =>
8150                    {
8151                        // Sweep: implicitly retract only MANUAL active edges.
8152                        // Derived active edges are skipped here because the rule
8153                        // engine appends a DerivedEdgeRetracted marker immediately
8154                        // after this DeleteNode record; that marker produces the
8155                        // single correctly-attributed Retracted event.  Derived
8156                        // entries are dropped from `active` (the marker arm's
8157                        // idempotent retain finds nothing to remove).
8158                        for (et, _, _, is_derived) in active.drain(..) {
8159                            if !is_derived {
8160                                out.push(EdgeHistoryEvent {
8161                                    edge_type: et,
8162                                    commit,
8163                                    event: EdgeEvent::Retracted,
8164                                    rule: None,
8165                                });
8166                            }
8167                            // Derived: drop silently; marker carries the Retracted event.
8168                        }
8169                    }
8170                    WalRecord::DerivedEdgeAdded {
8171                        rule,
8172                        edge_type: et,
8173                        src_key,
8174                        dst_key,
8175                    } => {
8176                        let is_ab = Self::aliases_match(&alias_a, src_key, commit)
8177                            && Self::aliases_match(&alias_b, dst_key, commit);
8178                        let is_ba = Self::aliases_match(&alias_b, src_key, commit)
8179                            && Self::aliases_match(&alias_a, dst_key, commit);
8180                        if is_ab || is_ba {
8181                            active.push((et.clone(), src_key.clone(), dst_key.clone(), true));
8182                            out.push(EdgeHistoryEvent {
8183                                edge_type: et.clone(),
8184                                commit,
8185                                event: EdgeEvent::Added,
8186                                rule: Some(rule.clone()),
8187                            });
8188                        }
8189                    }
8190                    WalRecord::DerivedEdgeRetracted {
8191                        rule,
8192                        edge_type: et,
8193                        src_key,
8194                        dst_key,
8195                    } => {
8196                        let is_ab = Self::aliases_match(&alias_a, src_key, commit)
8197                            && Self::aliases_match(&alias_b, dst_key, commit);
8198                        let is_ba = Self::aliases_match(&alias_b, src_key, commit)
8199                            && Self::aliases_match(&alias_a, dst_key, commit);
8200                        if is_ab || is_ba {
8201                            // Push unconditionally: a derived edge whose Added marker
8202                            // predates the history horizon has no `active` entry, but
8203                            // the retraction is still a real in-window event.
8204                            // Remove from active idempotently if present.
8205                            active.retain(|(aet, s, d, _)| {
8206                                !(aet == et && s == src_key && d == dst_key)
8207                            });
8208                            out.push(EdgeHistoryEvent {
8209                                edge_type: et.clone(),
8210                                commit,
8211                                event: EdgeEvent::Retracted,
8212                                rule: Some(rule.clone()),
8213                            });
8214                        }
8215                    }
8216                    // All other records (InsertNode, SetProp, CreateRule, etc.)
8217                    // do not affect edges between a and b.
8218                    _ => {}
8219                }
8220            }
8221        }
8222
8223        Ok(HistoryResult {
8224            items: out,
8225            total_commits,
8226        })
8227    }
8228
8229    /// Return `true` iff an edge of `edge_type` existed between `a` and `b`
8230    /// (in either direction) at the WAL commit `at_commit`.
8231    ///
8232    /// ## Horizon
8233    ///
8234    /// Valid commit indices are `0..total_commits` where `total_commits` is the
8235    /// number of WAL frames. An `at_commit >= total_commits` is outside the
8236    /// visible horizon and returns [`GraphError::CommitOutOfRange`].
8237    ///
8238    /// ## Derived edges
8239    ///
8240    /// Rule-derived edges are tracked via `DerivedEdgeAdded` / `DerivedEdgeRetracted`
8241    /// WAL markers appended at firing time (Task 1). `was_linked` reads these markers
8242    /// and therefore includes derived edges in its point-in-time evaluation,
8243    /// matching `edge_history`'s fidelity.
8244    pub fn was_linked(&self, a: &str, b: &str, edge_type: &str, at_commit: u64) -> Result<bool> {
8245        use core_storage::wal::WalRecord;
8246
8247        let (frames, _) = self.all_frames()?;
8248        let total_commits = self.wal_horizon_floor + frames.len() as u64;
8249
8250        // Horizon floor: commits in pruned archives are unreachable.
8251        if at_commit < self.wal_horizon_floor {
8252            return Err(GraphError::CommitOutOfRange {
8253                commit: at_commit,
8254                total: total_commits,
8255            });
8256        }
8257        if at_commit >= total_commits {
8258            return Err(GraphError::CommitOutOfRange {
8259                commit: at_commit,
8260                total: total_commits,
8261            });
8262        }
8263
8264        // Resolve all historical names for a and b (handles RenameNode in the WAL).
8265        // Intervals are commit-bounded so recycled keys don't contaminate point-in-time reads.
8266        let alias_a = self.build_key_alias_intervals(&frames, a);
8267        let alias_b = self.build_key_alias_intervals(&frames, b);
8268
8269        // Local index into surviving frames (0 = first frame of oldest archive).
8270        let local_commit = at_commit - self.wal_horizon_floor;
8271
8272        // Replay local frames 0..=local_commit, tracking active edges.
8273        let mut active: BTreeSet<(String, String, String)> = BTreeSet::new();
8274
8275        for (local_i, frame) in frames.iter().enumerate().take((local_commit + 1) as usize) {
8276            let commit = self.wal_horizon_floor + local_i as u64;
8277            let records: &[WalRecord] = match frame {
8278                WalRecord::Batch(inner) => inner.as_slice(),
8279                single => std::slice::from_ref(single),
8280            };
8281
8282            for rec in records {
8283                match rec {
8284                    WalRecord::InsertEdge {
8285                        edge_type: et,
8286                        src_key,
8287                        dst_key,
8288                    } => {
8289                        let is_ab = Self::aliases_match(&alias_a, src_key, commit)
8290                            && Self::aliases_match(&alias_b, dst_key, commit);
8291                        let is_ba = Self::aliases_match(&alias_b, src_key, commit)
8292                            && Self::aliases_match(&alias_a, dst_key, commit);
8293                        if is_ab || is_ba {
8294                            active.insert((et.clone(), src_key.clone(), dst_key.clone()));
8295                        }
8296                    }
8297                    WalRecord::InsertEdgeId { etype, src, dst } => {
8298                        let etype_str = match self.syms.resolve(*etype) {
8299                            Some(s) => s.to_string(),
8300                            None => continue,
8301                        };
8302                        // Use key_of_historical so tombstoned nodes resolve.
8303                        let src_key = self.ids.key_of_historical(*src);
8304                        let dst_key = self.ids.key_of_historical(*dst);
8305                        let is_ab = src_key == Some(a) && dst_key == Some(b);
8306                        let is_ba = src_key == Some(b) && dst_key == Some(a);
8307                        if is_ab || is_ba {
8308                            active.insert((
8309                                etype_str,
8310                                src_key.unwrap().to_string(),
8311                                dst_key.unwrap().to_string(),
8312                            ));
8313                        }
8314                    }
8315                    WalRecord::DeleteEdge {
8316                        edge_type: et,
8317                        src_key,
8318                        dst_key,
8319                    } => {
8320                        let is_ab = Self::aliases_match(&alias_a, src_key, commit)
8321                            && Self::aliases_match(&alias_b, dst_key, commit);
8322                        let is_ba = Self::aliases_match(&alias_b, src_key, commit)
8323                            && Self::aliases_match(&alias_a, dst_key, commit);
8324                        if is_ab || is_ba {
8325                            active.remove(&(et.clone(), src_key.clone(), dst_key.clone()));
8326                        }
8327                    }
8328                    WalRecord::DeleteNode { key: k }
8329                        if Self::aliases_match(&alias_a, k, commit)
8330                            || Self::aliases_match(&alias_b, k, commit) =>
8331                    {
8332                        // All edges touching the deleted node are gone.
8333                        active.retain(|(_, s, d)| s != k && d != k);
8334                    }
8335                    WalRecord::DerivedEdgeAdded {
8336                        edge_type: et,
8337                        src_key,
8338                        dst_key,
8339                        ..
8340                    } => {
8341                        let is_ab = Self::aliases_match(&alias_a, src_key, commit)
8342                            && Self::aliases_match(&alias_b, dst_key, commit);
8343                        let is_ba = Self::aliases_match(&alias_b, src_key, commit)
8344                            && Self::aliases_match(&alias_a, dst_key, commit);
8345                        if is_ab || is_ba {
8346                            active.insert((et.clone(), src_key.clone(), dst_key.clone()));
8347                        }
8348                    }
8349                    WalRecord::DerivedEdgeRetracted {
8350                        edge_type: et,
8351                        src_key,
8352                        dst_key,
8353                        ..
8354                    } => {
8355                        let is_ab = Self::aliases_match(&alias_a, src_key, commit)
8356                            && Self::aliases_match(&alias_b, dst_key, commit);
8357                        let is_ba = Self::aliases_match(&alias_b, src_key, commit)
8358                            && Self::aliases_match(&alias_a, dst_key, commit);
8359                        if is_ab || is_ba {
8360                            active.remove(&(et.clone(), src_key.clone(), dst_key.clone()));
8361                        }
8362                    }
8363                    _ => {}
8364                }
8365            }
8366        }
8367
8368        Ok(active.iter().any(|(et, _, _)| et == edge_type))
8369    }
8370
8371    pub fn edge_count(&self) -> u64 {
8372        self.topo_view().edge_count()
8373    }
8374
8375    /// Live/tombstone/edge counts plus per-rule provenance size, trip latch,
8376    /// and fire counter (includes rebuild evaluations). Rules are sorted by name.
8377    pub fn stats(&self) -> Stats {
8378        self.ensure_v8_base_sections_loaded();
8379        let rules: Vec<RuleStats> = self
8380            .engine
8381            .rules()
8382            .map(|r| RuleStats {
8383                name: r.name.clone(),
8384                edges: self
8385                    .engine
8386                    .provenance()
8387                    .get(&r.name)
8388                    .map(|s| s.len() as u64)
8389                    .unwrap_or(0),
8390                tripped: self.engine.is_tripped(&r.name),
8391                fires: self.engine.fire_count(&r.name),
8392                approximate: r.approximate,
8393            })
8394            .collect();
8395        Stats {
8396            nodes_live: self.ids.live_len(),
8397            nodes_tombstoned: self.ids.len() - self.ids.live_len(),
8398            edges: self.topo_view().edge_count(),
8399            rules,
8400        }
8401    }
8402
8403    /// On-disk snapshot format version this binary writes and reads.
8404    pub fn format_version() -> u16 {
8405        core_storage::snapshot::VERSION
8406    }
8407
8408    /// Test-support: total bytes appended (SimFs only usage).
8409    pub fn fs_total_appended(&self) -> usize
8410    where
8411        F: FsIntrospect,
8412    {
8413        self.fs.total_appended()
8414    }
8415
8416    /// Test-support: successful `Fs::sync` calls (SimFs / counting fs).
8417    pub fn fs_sync_count(&self) -> usize
8418    where
8419        F: FsIntrospect,
8420    {
8421        self.fs.sync_count()
8422    }
8423
8424    /// Consume the db, returning its fs (for crash simulation).
8425    pub fn into_fs(self) -> F {
8426        self.fs
8427    }
8428
8429    pub fn snapshot(&mut self) -> Result<()> {
8430        self.snapshot_with(SnapshotOptions::default())
8431    }
8432
8433    /// Snapshot with explicit options.
8434    ///
8435    /// # `keep_wal`
8436    ///
8437    /// When `keep_wal` is `false` (the default, same as [`snapshot`]):
8438    ///   - The WAL is replaced with a minimal baseline containing one
8439    ///     `EnableFulltext` record per active declaration.  All pre-snapshot
8440    ///     history is discarded; `open_at` can only reach post-snapshot commits.
8441    ///
8442    /// When `keep_wal` is `true`:
8443    ///   - The WAL is left intact.  All pre-snapshot commits remain reachable
8444    ///     via `open_at`.  The existing WAL already contains the original
8445    ///     `EnableFulltext` records, so no baseline re-write is needed; the
8446    ///     recovery guards in `apply()` silently skip any duplicate records on
8447    ///     replay.
8448    ///   - Crash window: a crash after the snapshot write but before the next
8449    ///     WAL write leaves the full pre-snapshot WAL intact.  On reopen the
8450    ///     snapshot is loaded and the WAL replayed idempotently over it — safe
8451    ///     because every `apply()` arm is idempotent when replayed over an
8452    ///     already-current snapshot.
8453    pub fn snapshot_with(&mut self, opts: SnapshotOptions) -> Result<()> {
8454        if self.read_only {
8455            return Err(GraphError::ReadOnly);
8456        }
8457        // Capture whether snapshot.bin already existed BEFORE this snapshot write.
8458        // Used by the archive path's conservative genesis-chain check: if a prior
8459        // snapshot exists but wal.truncated does not, we cannot distinguish a
8460        // legacy store (may have been truncated in an older code version) from a
8461        // new store that only used keep_wal=true.  Conservative: refuse genesis in
8462        // both cases.  Must be sampled here, before the snapshot write below.
8463        let had_prior_snapshot = self.fs.snapshot_path().map(|p| p.exists()).unwrap_or(false);
8464        self.ensure_v8_base_sections_loaded();
8465        // Ensure provenance is decoded before to_persist() clones it.
8466        self.engine.ensure_provenance_loaded_mut();
8467        let (rule_defs_typed, provenance, rule_tripped, rule_fires) = self.engine.to_persist();
8468        let rule_defs = rule_defs_typed
8469            .iter()
8470            .map(|r| bincode::serialize(r).expect("RuleDef serialize cannot fail"))
8471            .collect();
8472        // Collect HNSW state and IVF state.  When indexes are not yet
8473        // populated (clean open, no mutation since open), pass the retained
8474        // raw bytes through directly so that migrate/snapshot does not
8475        // silently discard fitted approximate-rule indexes.
8476        let hnsw_state = self.engine.export_hnsw_state_passthrough();
8477        let ivf_bytes = if !self.engine.indexes_populated() {
8478            // Pass retained IVF bytes through unchanged (no re-encode).
8479            self.engine.retained_ivf_bytes_clone().unwrap_or_default()
8480        } else {
8481            // Indexes live: encode from current state.
8482            let raw_ivf = self.engine.export_ivf_state();
8483            let ivf_state_map: BTreeMap<String, core_storage::snapshot::PerRuleIvfState> = raw_ivf
8484                .into_iter()
8485                .map(|(name, ((sc, sa, sd), (dc, da, dd)))| {
8486                    (
8487                        name,
8488                        core_storage::snapshot::PerRuleIvfState {
8489                            src: core_storage::snapshot::SideIvfState {
8490                                centroids: sc,
8491                                clusters: sa,
8492                                drift: sd,
8493                            },
8494                            dst: core_storage::snapshot::SideIvfState {
8495                                centroids: dc,
8496                                clusters: da,
8497                                drift: dd,
8498                            },
8499                        },
8500                    )
8501                })
8502                .collect();
8503            if ivf_state_map.is_empty() {
8504                Vec::new()
8505            } else {
8506                bincode::serialize(&ivf_state_map).expect("IVF state serialize cannot fail")
8507            }
8508        };
8509        let view_defs: Vec<Vec<u8>> = self
8510            .view_store
8511            .views()
8512            .map(|v| bincode::serialize(v).expect("ViewDef serialize cannot fail"))
8513            .collect();
8514        if self.base.is_some() {
8515            // V8 merge-snapshot path: encode base+overlay into a new V8 snapshot,
8516            // write it atomically, remap it as the new base, then clear the overlay.
8517            let meta = V8Meta {
8518                labels: self.labels.clone(),
8519                edge_props: self.edge_props.clone(),
8520                rule_defs,
8521                provenance,
8522                rule_tripped,
8523                rule_fires,
8524                ivf_bytes,
8525                view_defs,
8526                wal_truncated: !opts.keep_wal,
8527                hnsw: hnsw_state,
8528                last_change: self.last_change.clone(),
8529            };
8530            let mut buf: Vec<u8> = Vec::new();
8531            {
8532                // Clone the Arc so the old base stays alive while we encode.
8533                // The borrow of archived_csr (into old_base's mmap) is released
8534                // at the end of this block, before we replace self.base.
8535                let old_base = self.base.clone().expect("is_some checked above");
8536                let archived_csr = old_base.topology().map_err(|e| GraphError::Corrupt {
8537                    detail: format!("v8 snapshot: topology section: {e:?}"),
8538                })?;
8539                let archived_cols = old_base.columns().map_err(|e| GraphError::Corrupt {
8540                    detail: format!("v8 snapshot: columns section: {e:?}"),
8541                })?;
8542                let archived_edge_props =
8543                    old_base
8544                        .edge_props_section()
8545                        .map_err(|e| GraphError::Corrupt {
8546                            detail: format!("v8 snapshot: edge_props section: {e:?}"),
8547                        })?;
8548                let edge_props_raw =
8549                    old_base
8550                        .edge_props_raw_bytes()
8551                        .map_err(|e| GraphError::Corrupt {
8552                            detail: format!("v8 snapshot: edge_props raw bytes: {e:?}"),
8553                        })?;
8554                let prov_raw =
8555                    old_base
8556                        .provenance_raw_bytes()
8557                        .map_err(|e| GraphError::Corrupt {
8558                            detail: format!("v8 snapshot: provenance raw bytes: {e:?}"),
8559                        })?;
8560                encode_v8(
8561                    Some(archived_csr),
8562                    Some(archived_cols),
8563                    Some((archived_edge_props, edge_props_raw)),
8564                    Some(prov_raw),
8565                    &self.topo,
8566                    &self.props,
8567                    &self.ids,
8568                    &self.syms,
8569                    &meta,
8570                    &mut buf,
8571                )?;
8572            }
8573            self.fs.write_atomic(FileId::Snapshot, &buf)?;
8574            // Remap the freshly-written snapshot as the new base.
8575            // C2: use file mmap on RealFs; fall back to from_bytes on SimFs.
8576            let new_base = if let Some(snap_path) = self.fs.snapshot_path() {
8577                core_storage::v8::MappedBase::map(&snap_path)
8578            } else {
8579                core_storage::v8::MappedBase::from_bytes(buf)
8580            }
8581            .map_err(|e| GraphError::Corrupt {
8582                detail: format!("v8 snapshot: remap new base: {e:?}"),
8583            })?;
8584            self.base = Some(Arc::new(new_base));
8585            // Clear the overlay and prop tombstones — all data is now in the new base.
8586            self.topo = Topology::new();
8587            self.props = core_storage::columns::ColumnStore::new();
8588        } else {
8589            // Legacy path (V5–V7 stores without a V8 base).
8590            //
8591            // Memory-diet path: build V8Meta directly from &self — no SnapshotState
8592            // clone and no encode_v8_from_state intermediate clones.  The big
8593            // structures (self.topo, self.props) are borrowed, not cloned.
8594            // self.edge_props is moved (not cloned) because we immediately clear it
8595            // when we remap the new V8 snapshot as self.base (see below).
8596            //
8597            // Eliminates from peak RSS vs. the old SnapshotState path:
8598            //   • self.topo.clone()      (~topology HashMap footprint)
8599            //   • self.props.clone()     (~column-store footprint)
8600            //   • encode_v8_from_state V8Meta secondary clones (labels, edge_props, …)
8601            let meta = V8Meta {
8602                labels: self.labels.clone(),
8603                wal_truncated: !opts.keep_wal,
8604                // Move edge_props out so the large overlay is freed when meta
8605                // drops at end of this block (self.edge_props is now empty; reads
8606                // after base assignment go through the mmap'd base section).
8607                edge_props: std::mem::take(&mut self.edge_props),
8608                rule_defs,
8609                provenance,
8610                rule_tripped,
8611                rule_fires,
8612                ivf_bytes,
8613                view_defs,
8614                hnsw: hnsw_state,
8615                last_change: self.last_change.clone(),
8616            };
8617            let mut buf = Vec::new();
8618            encode_v8(
8619                None,
8620                None,
8621                None,
8622                None,
8623                &self.topo,
8624                &self.props,
8625                &self.ids,
8626                &self.syms,
8627                &meta,
8628                &mut buf,
8629            )?;
8630            // meta (and the moved edge_props inside it) is no longer needed;
8631            // drop it before the write to keep the peak window narrow.
8632            drop(meta);
8633            self.fs.write_atomic(FileId::Snapshot, &buf)?;
8634            // Remap the freshly-written V8 snapshot as self.base.
8635            // On RealFs: drop the encode buffer before mmap to recover ~1.9 GiB.
8636            // On SimFs (tests): pass buf to from_bytes.
8637            let new_base = if let Some(snap_path) = self.fs.snapshot_path() {
8638                drop(buf);
8639                core_storage::v8::MappedBase::map(&snap_path)
8640            } else {
8641                core_storage::v8::MappedBase::from_bytes(buf)
8642            }
8643            .map_err(|e| GraphError::Corrupt {
8644                detail: format!("v8 snapshot: remap new base (legacy path): {e:?}"),
8645            })?;
8646            self.base = Some(Arc::new(new_base));
8647            // Free the large heap-allocated decoded state — all data is now in the
8648            // mmap'd base.  Mirrors the V8 merge-snapshot path (see above).
8649            // self.edge_props was already moved into meta and is effectively empty.
8650            self.topo = Topology::new();
8651            self.props = core_storage::columns::ColumnStore::new();
8652        }
8653
8654        if opts.archive_wal {
8655            // History-preserving snapshot (Task 4):
8656            //   1. Snapshot already written above (write_atomic → fsynced).
8657            //   2. Rename WAL → wal.<commit_seq>.archive  (atomic, same fs).
8658            //      Crash window B: crash here leaves archive present, WAL
8659            //      absent.  Reopen: snapshot loaded (full state), no WAL
8660            //      replay.  Archive is NOT replayed into live state — it is
8661            //      pre-snapshot by construction.  Safe.
8662            //   3. Optionally write genesis marker (first archive only, no
8663            //      prior WAL truncation).
8664            //   4. Prune old archives (retention), update horizon floor.
8665            //      Pruning invalidates the genesis chain; delete marker.
8666            //   5. Write new minimal baseline WAL (write_atomic).
8667            //      Crash window C: crash here leaves new archive plus no live
8668            //      WAL.  Same as window B — handled above.
8669            //
8670            // Sample existing archives BEFORE the rename so we can detect
8671            // whether this is the first archive.
8672            let existing_archives = self.fs.list_archives()?;
8673            let is_first_archive = existing_archives.is_empty();
8674
8675            // Compute a globally-monotonic archive name: the name equals the
8676            // cumulative end-frame index of the archive in global commit space.
8677            //
8678            // Using `commit_seq` directly is UNSOUND across sessions: on reopen
8679            // commit_seq is seeded from max(last_change), which underestimates
8680            // the WAL depth when trailing commits (e.g. insert_edge) do not
8681            // update last_change.  A session-2 archive could then receive a name
8682            // ≤ the session-1 archive, causing incorrect sort order or collision.
8683            //
8684            // Instead: read and decode the live WAL here (before the rename) to
8685            // get its exact frame count, then add it to the last known global
8686            // end-frame index (the name of the most recent existing archive, or
8687            // wal_horizon_floor if no archives exist).  This is O(WAL size) but
8688            // snapshot is already serialising the full graph state, so the cost
8689            // is dominated.
8690            let live_wal_bytes_for_name = self.fs.read(FileId::Wal)?;
8691            let (live_frames_for_name, _) = decode_all(&live_wal_bytes_for_name);
8692            let archive_n = existing_archives
8693                .last()
8694                .copied()
8695                .unwrap_or(self.wal_horizon_floor)
8696                + live_frames_for_name.len() as u64;
8697            self.fs.archive_wal(archive_n)?;
8698
8699            // Genesis marker: written once when the first archive is taken
8700            // from a store that has never undergone a WAL-truncating snapshot.
8701            // When present, `open_at` may replay archive-resident commits from
8702            // empty state (the archive chain covers from global index 0).
8703            //
8704            // Two conditions must ALL hold:
8705            //   1. This is the first archive (existing_archives was empty).
8706            //   2. No snapshot.bin existed before this operation (had_prior_snapshot=false).
8707            //      A WAL-truncating snapshot (keep_wal=false) always writes snapshot.bin
8708            //      before truncating the WAL, so if any prior truncating snapshot was taken
8709            //      — even in a previous session — snapshot.bin is present and this condition
8710            //      is false.  This subsumes the cross-session truncation case without
8711            //      requiring a separate wal.truncated sidecar file.
8712            //      For legacy stores (snapshot.bin written by an older code version that
8713            //      may have truncated the WAL), the same conservative refusal applies:
8714            //      we cannot prove the chain is complete, so we refuse genesis (cost =
8715            //      no as-of-through-archives; never silent wrong data).
8716            //      On SimFs (snapshot_path() == None) had_prior_snapshot is always false,
8717            //      so SimFs always passes this check.
8718            if is_first_archive && !had_prior_snapshot {
8719                self.fs.write_genesis_marker()?;
8720                self.archive_genesis_chain = true;
8721            }
8722
8723            // Retention pruning: keep newest `keep` archives; delete oldest.
8724            // Pruning is the ONLY deletion site for archives.
8725            //
8726            // Crash-safety ordering (C1 fix):
8727            //   1. Count frames in surplus archives (reads only — no mutation).
8728            //   2. Advance and PERSIST the horizon floor FIRST via write-then-
8729            //      rename (atomic).  A crash after this point leaves orphaned
8730            //      archives on disk, but the floor is correct.  The opening
8731            //      cleanup sweep (`cleanup_orphaned_archives`) removes them on
8732            //      the next open, so the store is always safe to reopen.
8733            //   3. Delete the genesis marker (floor > 0 already blocks open_at
8734            //      via the conjunctive gate; marker cleanup is belt-and-suspenders).
8735            //   4. Delete surplus archives.  A crash between any two deletes
8736            //      leaves the floor committed and orphaned archives cleaned at
8737            //      next open — never a stale floor with a missing archive prefix.
8738            if let Some(keep) = self.wal_archive_retention {
8739                if keep > 0 {
8740                    let archives = self.fs.list_archives()?;
8741                    // archives is sorted ascending (oldest first)
8742                    if archives.len() as u32 > keep {
8743                        let surplus = archives.len() - keep as usize;
8744                        // Step 1: count pruned frames (reads, no mutation).
8745                        let mut pruned_frames = 0u64;
8746                        for &n in &archives[..surplus] {
8747                            let bytes = self.fs.read_archive(n)?;
8748                            let (frames, _) = decode_all(&bytes);
8749                            pruned_frames += frames.len() as u64;
8750                        }
8751                        // Step 2: advance and persist floor FIRST.
8752                        self.wal_horizon_floor += pruned_frames;
8753                        self.fs.write_horizon_floor(self.wal_horizon_floor)?;
8754                        // Step 3: delete genesis marker (floor > 0 already
8755                        // blocks open_at; this is belt-and-suspenders cleanup).
8756                        if pruned_frames > 0 && self.archive_genesis_chain {
8757                            self.fs.delete_genesis_marker()?;
8758                            self.archive_genesis_chain = false;
8759                        }
8760                        // Step 4: delete surplus archives.  Crash here →
8761                        // orphaned archives; cleaned at next open.
8762                        for &n in &archives[..surplus] {
8763                            self.fs.delete_archive(n)?;
8764                        }
8765                    }
8766                }
8767            }
8768
8769            // Write new minimal baseline WAL (mirrors the keep_wal=false path).
8770            let mut baseline_wal: Vec<u8> = Vec::new();
8771            for (label, field) in self.fulltext.enabled_pairs() {
8772                let rec = WalRecord::EnableFulltext {
8773                    label: label.clone(),
8774                    field: field.clone(),
8775                };
8776                baseline_wal.extend_from_slice(&encode_record(&rec));
8777            }
8778            for (label, field) in self.prop_index.enabled_pairs() {
8779                let rec = WalRecord::EnableIndex {
8780                    label: label.clone(),
8781                    field: field.clone(),
8782                };
8783                baseline_wal.extend_from_slice(&encode_record(&rec));
8784            }
8785            self.fs.write_atomic(FileId::Wal, &baseline_wal)?;
8786        } else if opts.keep_wal {
8787            // keep_wal=true: WAL is left untouched.  The existing WAL already
8788            // contains the EnableFulltext records from the original enable calls;
8789            // replay is idempotent (guards in apply() skip already-live entries).
8790            // No baseline re-write is needed or safe here — the full WAL history
8791            // must remain intact for open_at to reach pre-snapshot commits.
8792        } else {
8793            // keep_wal=false (default): truncate by replacing the WAL with a
8794            // minimal baseline of one EnableFulltext record per active pair.
8795            //
8796            // Crash-ordering: write_atomic is atomic.
8797            //   • Crash before snapshot write  → WAL unchanged.  Safe.
8798            //   • Crash after snapshot write but before this WAL write → full
8799            //     pre-snapshot WAL still present; open_with replays idempotently.
8800            //   • Crash after both writes → normal post-snapshot state.
8801            //
8802            // Genesis chain: a WAL-truncating snapshot breaks the archive chain
8803            // for any archives taken AFTER this point (their WAL slices would
8804            // not start at genesis).  Delete any existing genesis marker so that
8805            // open_at refuses archive-resident commits.  Future sessions are
8806            // covered by had_prior_snapshot: snapshot.bin written here persists
8807            // across sessions and prevents a later archiving session from
8808            // incorrectly claiming a complete genesis chain.
8809            if self.archive_genesis_chain {
8810                self.fs.delete_genesis_marker()?;
8811                self.archive_genesis_chain = false;
8812            }
8813            let mut baseline_wal: Vec<u8> = Vec::new();
8814            for (label, field) in self.fulltext.enabled_pairs() {
8815                let rec = WalRecord::EnableFulltext {
8816                    label: label.clone(),
8817                    field: field.clone(),
8818                };
8819                baseline_wal.extend_from_slice(&encode_record(&rec));
8820            }
8821            for (label, field) in self.prop_index.enabled_pairs() {
8822                let rec = WalRecord::EnableIndex {
8823                    label: label.clone(),
8824                    field: field.clone(),
8825                };
8826                baseline_wal.extend_from_slice(&encode_record(&rec));
8827            }
8828            self.fs.write_atomic(FileId::Wal, &baseline_wal)?;
8829        }
8830        // After snapshot the overlay may have changed (V8 merge path clears
8831        // self.topo and self.props). Refresh the MVCC fold so future readers
8832        // see the post-snapshot state rather than stale overlay data.
8833        self.fold_now();
8834        Ok(())
8835    }
8836}
8837
8838/// Queued mutation for a [`BatchBuilder`] or [`GraphDb::commit_group`].
8839///
8840/// The `submit_batch` / `commit_group` APIs accept `Vec<BatchOp>` so that
8841/// callers can build a set of mutations without holding `&mut GraphDb` and
8842/// hand them off to the group-committing writer for durable, batched I/O.
8843pub enum BatchOp {
8844    InsertNode {
8845        label: String,
8846        key: String,
8847        props: Vec<(String, Value)>,
8848    },
8849    InsertEdge {
8850        edge_type: String,
8851        src_key: String,
8852        dst_key: String,
8853    },
8854    SetProp {
8855        key: String,
8856        field: String,
8857        value: Value,
8858    },
8859    RemoveProp {
8860        key: String,
8861        field: String,
8862    },
8863    DeleteEdge {
8864        edge_type: String,
8865        src_key: String,
8866        dst_key: String,
8867    },
8868    DeleteNode {
8869        key: String,
8870    },
8871    CreateRule(RuleDef),
8872    DeleteRule {
8873        name: String,
8874    },
8875    /// Rename a node's key. Validated: old must exist, new must not.
8876    RenameNode {
8877        old_key: String,
8878        new_key: String,
8879    },
8880    /// Insert an edge, auto-creating any missing endpoint as a plain node with
8881    /// `placeholder_label` and no props. Rules fire and last-change is updated
8882    /// for each created endpoint (normal InsertNode semantics in the batch frame).
8883    InsertEdgeUpsert {
8884        edge_type: String,
8885        src_key: String,
8886        dst_key: String,
8887        placeholder_label: String,
8888    },
8889}
8890
8891/// Three-way node visibility status used by `check_single_op_authz`.
8892enum NodeAuthzStatus {
8893    /// Node exists in the store and is in the role's read mask.
8894    Visible(String), // carries the node's label
8895    /// Node exists in the store but is NOT in the role's read mask.
8896    Hidden,
8897    /// Node does not exist in the store.
8898    Absent,
8899}
8900
8901/// Overlay of ops already accepted earlier in the same batch. Never written
8902/// back to the database — validation only.
8903#[derive(Default)]
8904struct Overlay {
8905    extra_keys: BTreeSet<String>,
8906    deleted_keys: BTreeSet<String>,
8907    extra_props: BTreeMap<(String, String), Value>,
8908    removed_props: BTreeSet<(String, String)>,
8909    extra_edges: BTreeSet<(String, String, String)>,
8910    deleted_edges: BTreeSet<(String, String, String)>,
8911    extra_rules: BTreeSet<String>,
8912    deleted_rules: BTreeSet<String>,
8913}
8914
8915/// Read-only view of live db state plus a batch overlay. Shared by single-op
8916/// public methods (empty overlay) and `commit_batch`.
8917struct MutPreview<'a, F: Fs> {
8918    db: &'a GraphDb<F>,
8919    overlay: Overlay,
8920}
8921
8922impl<'a, F: Fs> MutPreview<'a, F> {
8923    fn new(db: &'a GraphDb<F>) -> Self {
8924        Self {
8925            db,
8926            overlay: Overlay::default(),
8927        }
8928    }
8929
8930    fn has_key(&self, key: &str) -> bool {
8931        if self.overlay.extra_keys.contains(key) {
8932            return true;
8933        }
8934        if self.overlay.deleted_keys.contains(key) {
8935            return false;
8936        }
8937        self.db.ids.get(key).is_some()
8938    }
8939
8940    fn has_prop(&self, key: &str, field: &str) -> bool {
8941        if !self.has_key(key) {
8942            return false;
8943        }
8944        let k = (key.to_string(), field.to_string());
8945        if self.overlay.removed_props.contains(&k) {
8946            return false;
8947        }
8948        if self.overlay.extra_props.contains_key(&k) {
8949            return true;
8950        }
8951        // Fresh identity (first insert in this batch, or delete+reinsert):
8952        // ignore props still sitting on the soon-to-be-tombstoned slot.
8953        if self.overlay.extra_keys.contains(key) {
8954            return false;
8955        }
8956        self.db.get_prop(key, field).is_some()
8957    }
8958
8959    fn has_edge(&self, edge_type: &str, src_key: &str, dst_key: &str) -> bool {
8960        let k = (
8961            edge_type.to_string(),
8962            src_key.to_string(),
8963            dst_key.to_string(),
8964        );
8965        if self.overlay.deleted_edges.contains(&k) {
8966            return false;
8967        }
8968        if self.overlay.extra_edges.contains(&k) {
8969            return true;
8970        }
8971        // A key created in this batch (including reinsert) has no db edges.
8972        if self.overlay.extra_keys.contains(src_key) || self.overlay.extra_keys.contains(dst_key) {
8973            return false;
8974        }
8975        if self.overlay.deleted_keys.contains(src_key)
8976            || self.overlay.deleted_keys.contains(dst_key)
8977        {
8978            return false;
8979        }
8980        let Some(src) = self.db.ids.get(src_key) else {
8981            return false;
8982        };
8983        let Some(dst) = self.db.ids.get(dst_key) else {
8984            return false;
8985        };
8986        let Some(sym) = self.db.syms.get(edge_type) else {
8987            return false;
8988        };
8989        self.db
8990            .topo_view()
8991            .neighbors(sym, Direction::Out, src)
8992            .binary_search(&dst)
8993            .is_ok()
8994    }
8995
8996    fn has_rule(&self, name: &str) -> bool {
8997        if self.overlay.extra_rules.contains(name) {
8998            return true;
8999        }
9000        if self.overlay.deleted_rules.contains(name) {
9001            return false;
9002        }
9003        self.db.engine.rules().any(|r| r.name == name)
9004    }
9005
9006    fn is_rule_owned(&self, edge_type: &str, src_key: &str, dst_key: &str) -> bool {
9007        if self.overlay.extra_keys.contains(src_key) || self.overlay.extra_keys.contains(dst_key) {
9008            return false;
9009        }
9010        if self.overlay.deleted_keys.contains(src_key)
9011            || self.overlay.deleted_keys.contains(dst_key)
9012        {
9013            return false;
9014        }
9015        let Some(src) = self.db.ids.get(src_key) else {
9016            return false;
9017        };
9018        let Some(dst) = self.db.ids.get(dst_key) else {
9019            return false;
9020        };
9021        let Some(et) = self.db.syms.get(edge_type) else {
9022            return false;
9023        };
9024        // extra_rules is deliberately not consulted: a CreateRule earlier in
9025        // this batch has not fired, so it contributes no provenance. That is
9026        // the documented rule-window gap (see GraphDb::batch).
9027        if self.overlay.deleted_rules.is_empty() {
9028            return self.db.engine.is_owned(et, src, dst);
9029        }
9030        for (rule, triples) in self.db.engine.provenance() {
9031            if self.overlay.deleted_rules.contains(rule) {
9032                continue;
9033            }
9034            if triples.contains(&(et, src, dst)) {
9035                return true;
9036            }
9037        }
9038        false
9039    }
9040
9041    fn check_insert_node(&self, key: &str) -> Result<()> {
9042        if self.has_key(key) {
9043            Err(GraphError::DuplicateKey { key: key.into() })
9044        } else {
9045            Ok(())
9046        }
9047    }
9048
9049    fn check_live_key(&self, key: &str) -> Result<()> {
9050        if self.has_key(key) {
9051            Ok(())
9052        } else {
9053            Err(GraphError::KeyNotFound { key: key.into() })
9054        }
9055    }
9056
9057    fn prepare_insert_edge(&self, edge_type: &str, src_key: &str, dst_key: &str) -> Result<bool> {
9058        for k in [src_key, dst_key] {
9059            if !self.has_key(k) {
9060                return Err(GraphError::KeyNotFound { key: k.into() });
9061            }
9062        }
9063        if self.is_rule_owned(edge_type, src_key, dst_key) {
9064            return Err(GraphError::RuleOwned {
9065                detail: format!("edge {edge_type} {src_key}→{dst_key} is rule-owned"),
9066            });
9067        }
9068        Ok(!self.has_edge(edge_type, src_key, dst_key))
9069    }
9070
9071    fn prepare_remove_prop(&self, key: &str, field: &str) -> Result<bool> {
9072        self.check_live_key(key)?;
9073        Ok(self.has_prop(key, field))
9074    }
9075
9076    fn prepare_delete_edge(&self, edge_type: &str, src_key: &str, dst_key: &str) -> Result<bool> {
9077        for k in [src_key, dst_key] {
9078            if !self.has_key(k) {
9079                return Err(GraphError::KeyNotFound { key: k.into() });
9080            }
9081        }
9082        // Provenance-owned OR a live rule would derive this pair. User-first
9083        // edges that a later rule matches are not in `owned`, but deleting
9084        // them would leave a hole `rebuild_rule` immediately fills.
9085        if self.is_rule_owned(edge_type, src_key, dst_key) {
9086            return Err(GraphError::RuleOwned {
9087                detail: format!(
9088                    "edge {edge_type} {src_key}→{dst_key} is rule-owned; \
9089                     delete or change the owning rule"
9090                ),
9091            });
9092        }
9093        if self.would_derive(edge_type, src_key, dst_key) {
9094            return Err(GraphError::RuleOwned {
9095                detail: format!(
9096                    "edge {edge_type} {src_key}→{dst_key} is rule-owned; \
9097                     delete or change the owning rule, or a live rule would re-derive it"
9098                ),
9099            });
9100        }
9101        Ok(self.has_edge(edge_type, src_key, dst_key))
9102    }
9103
9104    /// True if any live rule (minus overlay-deleted names) would derive
9105    /// `(edge_type, src, dst)` from current overlay-visible props/labels.
9106    /// CreateRule names in `extra_rules` are ignored — same documented
9107    /// same-batch rule-window as [`Self::is_rule_owned`].
9108    fn would_derive(&self, edge_type: &str, src_key: &str, dst_key: &str) -> bool {
9109        if src_key == dst_key {
9110            return false;
9111        }
9112        let Some(src_label) = self.label_of(src_key) else {
9113            return false;
9114        };
9115        let Some(dst_label) = self.label_of(dst_key) else {
9116            return false;
9117        };
9118        for rule in self.db.engine.rules() {
9119            if self.overlay.deleted_rules.contains(&rule.name) {
9120                continue;
9121            }
9122            if rule.edge_type != edge_type {
9123                continue;
9124            }
9125            if rule.src_label != src_label || rule.dst_label != dst_label {
9126                continue;
9127            }
9128            let src_props = |f: &str| self.prop_value(src_key, f);
9129            let dst_props = |f: &str| self.prop_value(dst_key, f);
9130            let src_view = NodeView {
9131                key: src_key,
9132                props: &src_props,
9133            };
9134            let dst_view = NodeView {
9135                key: dst_key,
9136                props: &dst_props,
9137            };
9138            if evaluate(&rule.predicate, &src_view, &dst_view).is_some() {
9139                return true;
9140            }
9141        }
9142        false
9143    }
9144
9145    fn label_of(&self, key: &str) -> Option<String> {
9146        if self.overlay.deleted_keys.contains(key) {
9147            return None;
9148        }
9149        // Fresh identities created in this batch have no stored label in the
9150        // overlay; they cannot be provenance-owned yet either.
9151        let id = self.db.ids.get(key)?;
9152        let sym = self.db.labels.get(id as usize).copied()?;
9153        if sym == u32::MAX {
9154            return None;
9155        }
9156        self.db.syms.resolve(sym).map(str::to_string)
9157    }
9158
9159    fn prop_value(&self, key: &str, field: &str) -> Option<Value> {
9160        if !self.has_key(key) {
9161            return None;
9162        }
9163        let k = (key.to_string(), field.to_string());
9164        if self.overlay.removed_props.contains(&k) {
9165            return None;
9166        }
9167        if let Some(v) = self.overlay.extra_props.get(&k) {
9168            return Some(v.clone());
9169        }
9170        if self.overlay.extra_keys.contains(key) {
9171            return None;
9172        }
9173        self.db.get_prop(key, field)
9174    }
9175
9176    fn check_create_rule(&self, def: &RuleDef) -> Result<()> {
9177        def.validate()
9178            .map_err(|e| GraphError::RuleInvalid { detail: e })?;
9179        if self.has_rule(&def.name) {
9180            return Err(GraphError::RuleInvalid {
9181                detail: format!("rule {:?} already exists", def.name),
9182            });
9183        }
9184        Ok(())
9185    }
9186
9187    fn check_delete_rule(&self, name: &str) -> Result<()> {
9188        if self.has_rule(name) {
9189            Ok(())
9190        } else {
9191            Err(GraphError::RuleNotFound { name: name.into() })
9192        }
9193    }
9194
9195    fn note_insert_node(&mut self, key: &str, props: &[(String, Value)]) {
9196        self.overlay.deleted_keys.remove(key);
9197        self.overlay.extra_keys.insert(key.to_string());
9198        self.overlay.extra_props.retain(|(k, _), _| k != key);
9199        self.overlay.removed_props.retain(|(k, _)| k != key);
9200        for (field, value) in props {
9201            self.overlay
9202                .extra_props
9203                .insert((key.to_string(), field.clone()), value.clone());
9204        }
9205    }
9206
9207    fn note_insert_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) {
9208        let k = (
9209            edge_type.to_string(),
9210            src_key.to_string(),
9211            dst_key.to_string(),
9212        );
9213        self.overlay.deleted_edges.remove(&k);
9214        self.overlay.extra_edges.insert(k);
9215    }
9216
9217    fn note_set_prop(&mut self, key: &str, field: &str, value: &Value) {
9218        let k = (key.to_string(), field.to_string());
9219        self.overlay.removed_props.remove(&k);
9220        self.overlay.extra_props.insert(k, value.clone());
9221    }
9222
9223    fn note_remove_prop(&mut self, key: &str, field: &str) {
9224        let k = (key.to_string(), field.to_string());
9225        self.overlay.extra_props.remove(&k);
9226        self.overlay.removed_props.insert(k);
9227    }
9228
9229    fn note_delete_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) {
9230        let k = (
9231            edge_type.to_string(),
9232            src_key.to_string(),
9233            dst_key.to_string(),
9234        );
9235        self.overlay.extra_edges.remove(&k);
9236        self.overlay.deleted_edges.insert(k);
9237    }
9238
9239    fn note_delete_node(&mut self, key: &str) {
9240        self.overlay.extra_keys.remove(key);
9241        self.overlay.deleted_keys.insert(key.to_string());
9242        self.overlay.extra_props.retain(|(k, _), _| k != key);
9243        self.overlay.removed_props.retain(|(k, _)| k != key);
9244        self.overlay
9245            .extra_edges
9246            .retain(|(_, s, d)| s != key && d != key);
9247        self.overlay
9248            .deleted_edges
9249            .retain(|(_, s, d)| s != key && d != key);
9250    }
9251
9252    fn note_create_rule(&mut self, name: &str) {
9253        self.overlay.deleted_rules.remove(name);
9254        self.overlay.extra_rules.insert(name.to_string());
9255    }
9256
9257    fn check_rename_node(&self, old: &str, new: &str) -> Result<()> {
9258        if !self.has_key(old) {
9259            return Err(GraphError::KeyNotFound { key: old.into() });
9260        }
9261        if self.has_key(new) {
9262            return Err(GraphError::DuplicateKey { key: new.into() });
9263        }
9264        Ok(())
9265    }
9266
9267    fn note_rename_node(&mut self, old: &str, new: &str) {
9268        // Mark old as deleted so subsequent batch ops cannot reference it.
9269        self.overlay.extra_keys.remove(old);
9270        self.overlay.deleted_keys.insert(old.to_string());
9271        // Mark new as extra so subsequent batch ops can reference it.
9272        self.overlay.deleted_keys.remove(new);
9273        self.overlay.extra_keys.insert(new.to_string());
9274        // Migrate any overlay props from old key to new key.
9275        let new_str = new.to_string();
9276        let transferred: Vec<((String, String), Value)> = self
9277            .overlay
9278            .extra_props
9279            .iter()
9280            .filter(|((k, _), _)| k.as_str() == old)
9281            .map(|((_, f), v)| ((new_str.clone(), f.clone()), v.clone()))
9282            .collect();
9283        self.overlay
9284            .extra_props
9285            .retain(|(k, _), _| k.as_str() != old);
9286        for (k, v) in transferred {
9287            self.overlay.extra_props.insert(k, v);
9288        }
9289        // Migrate removed_props.
9290        let transferred_removed: Vec<(String, String)> = self
9291            .overlay
9292            .removed_props
9293            .iter()
9294            .filter(|(k, _)| k.as_str() == old)
9295            .map(|(_, f)| (new_str.clone(), f.clone()))
9296            .collect();
9297        self.overlay
9298            .removed_props
9299            .retain(|(k, _)| k.as_str() != old);
9300        for k in transferred_removed {
9301            self.overlay.removed_props.insert(k);
9302        }
9303    }
9304
9305    fn note_delete_rule(&mut self, name: &str) {
9306        self.overlay.extra_rules.remove(name);
9307        self.overlay.deleted_rules.insert(name.to_string());
9308        // Treat the deleted rule's current provenance as gone so a later
9309        // delete_edge of those triples is a no-op (matches sequential).
9310        if let Some(triples) = self.db.engine.provenance().get(name) {
9311            for &(et, s, d) in triples {
9312                let Some(etype) = self.db.syms.resolve(et) else {
9313                    continue;
9314                };
9315                let Some(src) = self.db.ids.key_of(s) else {
9316                    continue;
9317                };
9318                let Some(dst) = self.db.ids.key_of(d) else {
9319                    continue;
9320                };
9321                let k = (etype.to_string(), src.to_string(), dst.to_string());
9322                self.overlay.extra_edges.remove(&k);
9323                self.overlay.deleted_edges.insert(k);
9324            }
9325        }
9326    }
9327}
9328
9329/// Collects mutations and commits them as one WAL `Batch` frame.
9330///
9331/// Holds `&mut GraphDb` for its lifetime. Queue with the same method names
9332/// as [`GraphDb`]; call [`commit`](Self::commit) to validate, log, and apply.
9333/// See [`GraphDb::batch`] for validation and atomicity rules.
9334pub struct BatchBuilder<'a, F: Fs> {
9335    db: &'a mut GraphDb<F>,
9336    ops: Vec<BatchOp>,
9337}
9338
9339impl<'a, F: Fs> BatchBuilder<'a, F> {
9340    pub fn insert_node(
9341        &mut self,
9342        label: &str,
9343        key: &str,
9344        props: Vec<(String, Value)>,
9345    ) -> &mut Self {
9346        self.ops.push(BatchOp::InsertNode {
9347            label: label.into(),
9348            key: key.into(),
9349            props,
9350        });
9351        self
9352    }
9353
9354    pub fn insert_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) -> &mut Self {
9355        self.ops.push(BatchOp::InsertEdge {
9356            edge_type: edge_type.into(),
9357            src_key: src_key.into(),
9358            dst_key: dst_key.into(),
9359        });
9360        self
9361    }
9362
9363    pub fn set_prop(&mut self, key: &str, field: &str, value: Value) -> &mut Self {
9364        self.ops.push(BatchOp::SetProp {
9365            key: key.into(),
9366            field: field.into(),
9367            value,
9368        });
9369        self
9370    }
9371
9372    pub fn remove_prop(&mut self, key: &str, field: &str) -> &mut Self {
9373        self.ops.push(BatchOp::RemoveProp {
9374            key: key.into(),
9375            field: field.into(),
9376        });
9377        self
9378    }
9379
9380    pub fn delete_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) -> &mut Self {
9381        self.ops.push(BatchOp::DeleteEdge {
9382            edge_type: edge_type.into(),
9383            src_key: src_key.into(),
9384            dst_key: dst_key.into(),
9385        });
9386        self
9387    }
9388
9389    pub fn delete_node(&mut self, key: &str) -> &mut Self {
9390        self.ops.push(BatchOp::DeleteNode { key: key.into() });
9391        self
9392    }
9393
9394    pub fn create_rule(&mut self, def: RuleDef) -> &mut Self {
9395        self.ops.push(BatchOp::CreateRule(def));
9396        self
9397    }
9398
9399    pub fn delete_rule(&mut self, name: &str) -> &mut Self {
9400        self.ops.push(BatchOp::DeleteRule { name: name.into() });
9401        self
9402    }
9403
9404    /// Queue a node-rename in this batch.
9405    ///
9406    /// Validation (old exists, new not taken) runs at commit time.
9407    pub fn rename_node(&mut self, old_key: &str, new_key: &str) -> &mut Self {
9408        self.ops.push(BatchOp::RenameNode {
9409            old_key: old_key.into(),
9410            new_key: new_key.into(),
9411        });
9412        self
9413    }
9414
9415    /// Queue an edge insert with endpoint auto-creation.
9416    ///
9417    /// Any missing endpoint is created as a plain node `{key, label:
9418    /// placeholder_label, no props}` inside this batch frame. Rules fire and
9419    /// last-change is updated for each auto-created node.
9420    pub fn insert_edge_upsert(
9421        &mut self,
9422        edge_type: &str,
9423        src_key: &str,
9424        dst_key: &str,
9425        placeholder_label: &str,
9426    ) -> &mut Self {
9427        self.ops.push(BatchOp::InsertEdgeUpsert {
9428            edge_type: edge_type.into(),
9429            src_key: src_key.into(),
9430            dst_key: dst_key.into(),
9431            placeholder_label: placeholder_label.into(),
9432        });
9433        self
9434    }
9435
9436    /// Validate every queued op, then log one `Batch` frame and apply.
9437    /// Empty / all-noop batches return `Ok(())` without writing the WAL.
9438    /// A second `commit()` after a successful one is an empty-batch no-op
9439    /// (queued ops were taken).
9440    /// Takes `&mut self` so it chains after the queue methods (`b.insert_node(..).commit()`)
9441    /// and also works as `let mut b = db.batch(); b.insert_node(..); b.commit()`.
9442    ///
9443    /// **Rule-window limitation:** batch validation cannot see edges that a
9444    /// rule created earlier in the *same* batch will derive at apply time, so
9445    /// a `delete_edge` / `insert_edge` in that window is silently no-oped
9446    /// where sequential calls would return `Err(RuleOwned)`. State integrity
9447    /// is unaffected (idempotent apply, provenance intact). Create rules in
9448    /// their own batch, or sequentially, when later ops may touch derived
9449    /// edges.
9450    /// Validate every queued op and commit atomically.
9451    ///
9452    /// Returns `(nodes_inserted, edges_inserted)` — the counts of node and edge
9453    /// WAL records actually written (duplicate edges are silent no-ops and are
9454    /// NOT counted). Both are 0 when the batch is empty or all-noop.
9455    pub fn commit(&mut self) -> Result<(usize, usize)> {
9456        let ops = std::mem::take(&mut self.ops);
9457        self.db.commit_batch(ops)
9458    }
9459
9460    /// Same as [`commit`](Self::commit) but tail the inner events with
9461    /// [`MutationEvent::Ingested`] instead of [`MutationEvent::BatchApplied`].
9462    pub(crate) fn commit_ingest(&mut self, label: &str, inserted: usize) -> Result<(usize, usize)> {
9463        let ops = std::mem::take(&mut self.ops);
9464        self.db
9465            .commit_logged_batch(ops, Some((label.to_string(), inserted)), None)
9466    }
9467}
9468
9469pub struct NodeRef<'a, F: Fs> {
9470    db: &'a GraphDb<F>,
9471    id: u32,
9472}
9473
9474impl<'a, F: Fs> NodeRef<'a, F> {
9475    pub fn key(&self) -> &str {
9476        self.db.ids.key_of(self.id).expect("dense ids")
9477    }
9478
9479    pub fn label(&self) -> &str {
9480        let sym = self
9481            .db
9482            .labels
9483            .get(self.id as usize)
9484            .copied()
9485            .filter(|&s| s != u32::MAX)
9486            .expect("real nodes always have a label; u32::MAX sentinel cannot occur");
9487        self.db.syms.resolve(sym).expect("interned label symbol")
9488    }
9489
9490    pub fn prop(&self, field: &str) -> Option<Value> {
9491        self.db
9492            .props_view()
9493            .get(self.id, field)
9494            .map(|vr| vr.into_value())
9495    }
9496
9497    /// All stored fields for this node, sorted by field name.
9498    ///
9499    /// Reads from the full base+overlay view so that props stored only in the
9500    /// V8 snapshot base (i.e. before any post-snapshot WAL writes) are visible.
9501    pub fn props(&self) -> BTreeMap<String, Value> {
9502        let mut out = BTreeMap::new();
9503        let pv = self.db.props_view();
9504        for field in pv.field_names() {
9505            if let Some(vr) = pv.get(self.id, &field) {
9506                out.insert(field, vr.into_value());
9507            }
9508        }
9509        out
9510    }
9511
9512    /// depth-N BFS as a ResultSet: columns ["key","label","depth"], BFS order.
9513    pub fn neighborhood(&self, depth: u32, edge_types: Option<&[&str]>, dir: Dir) -> ResultSet {
9514        let view = self.db.view();
9515        let resolved: Option<Vec<u32>> = edge_types.map(|names| {
9516            names
9517                .iter()
9518                .filter_map(|name| view.syms.get(name))
9519                .collect()
9520        });
9521        let nb = neighborhood(&view, self.id, depth, resolved.as_deref(), dir);
9522        let mut rs = ResultSet::new(vec!["key".into(), "label".into(), "depth".into()]);
9523        for (nid, d) in nb.nodes {
9524            let key = view.key_of(nid);
9525            let label = view
9526                .label_of(nid)
9527                .expect("real nodes always have a label; u32::MAX sentinel cannot occur");
9528            rs.push_row(vec![
9529                Some(Value::Str(key.to_string())),
9530                Some(Value::Str(label.to_string())),
9531                Some(Value::Int(d as i64)),
9532            ]);
9533        }
9534        rs
9535    }
9536
9537    /// 1-hop, Both directions: edge-type name → sorted unique neighbor keys.
9538    pub fn grouped_by_edge_type(&self) -> BTreeMap<String, Vec<String>> {
9539        let view = self.db.view();
9540        let mut groups: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
9541        for e in expand(&view, self.id, None, Dir::Both) {
9542            // Skip edges with unknown etypes (only possible from corrupt large
9543            // TOPOLOGY section; function returns BTreeMap not Result).
9544            let Some(etype) = view.syms.resolve(e.etype) else {
9545                continue;
9546            };
9547            let etype = etype.to_string();
9548            let nbr = if e.src == self.id { e.dst } else { e.src };
9549            groups
9550                .entry(etype)
9551                .or_default()
9552                .insert(view.key_of(nbr).to_string());
9553        }
9554        groups
9555            .into_iter()
9556            .map(|(k, v)| (k, v.into_iter().collect()))
9557            .collect()
9558    }
9559}
9560
9561#[cfg(test)]
9562mod tests {
9563    use super::*;
9564    use core_rules::Predicate;
9565
9566    fn tmp_dir(name: &str) -> std::path::PathBuf {
9567        let d =
9568            std::env::temp_dir().join(format!("graphdb-db-unit-{}-{}", name, std::process::id()));
9569        let _ = std::fs::remove_dir_all(&d);
9570        d
9571    }
9572
9573    fn fk_rule() -> RuleDef {
9574        RuleDef {
9575            name: "works_at".into(),
9576            src_label: "Person".into(),
9577            dst_label: "Org".into(),
9578            predicate: Predicate::KeyMatch {
9579                field: "org_id".into(),
9580            },
9581            edge_type: "WORKS_AT".into(),
9582            weight_prop: None,
9583            max_edges: None,
9584            approximate: false,
9585            via_label: None,
9586            via_edge: None,
9587            via_dir: None,
9588        }
9589    }
9590
9591    /// Regression guard for the no-views delta-copy fast path.
9592    ///
9593    /// When no views are defined, `pending_deltas_since().to_vec()` must never
9594    /// be called — even during a large CreateRule backfill. The DELTA_COPY_COUNT
9595    /// thread-local is incremented inside every `if !view_store.is_empty()` block;
9596    /// a count of 0 after the entire sequence proves the guard fires correctly.
9597    #[test]
9598    fn no_delta_copy_when_no_views() {
9599        DELTA_COPY_COUNT.with(|c| c.set(0));
9600        let dir = tmp_dir("no-delta-copy");
9601        {
9602            let mut db = GraphDb::open(&dir).unwrap();
9603            // Insert 50 Org + 50 Person nodes with FK links.
9604            for i in 0..50u32 {
9605                db.insert_node("Org", &format!("o{i}"), vec![]).unwrap();
9606            }
9607            for i in 0..50u32 {
9608                db.insert_node(
9609                    "Person",
9610                    &format!("p{i}"),
9611                    vec![("org_id".into(), Value::Str(format!("o{i}")))],
9612                )
9613                .unwrap();
9614            }
9615            // CreateRule backfill should NOT invoke to_vec() when no views are defined.
9616            db.create_rule(fk_rule()).unwrap();
9617
9618            // Counter must stay 0 — no views, no copies.
9619            let copies = DELTA_COPY_COUNT.with(|c| c.get());
9620            assert_eq!(
9621                copies, 0,
9622                "pending_deltas_since().to_vec() called despite no views"
9623            );
9624
9625            // Derived edges must still be correct (the guard skips only the
9626            // empty delta propagation loop, not the rule application itself).
9627            let nbrs = db.neighbors("p0", "WORKS_AT", Direction::Out).unwrap();
9628            assert_eq!(
9629                nbrs,
9630                vec!["o0"],
9631                "rule must derive edges even with no views"
9632            );
9633        }
9634        let _ = std::fs::remove_dir_all(&dir);
9635    }
9636
9637    /// Gating regression: subscribe AFTER a backfill must see no stale events.
9638    /// subscribe BEFORE a backfill must see every edge-fire event.
9639    #[test]
9640    fn subscribe_after_backfill_no_stale_events() {
9641        let dir = tmp_dir("sub-after-backfill");
9642        {
9643            let mut db = GraphDb::open(&dir).unwrap();
9644            for i in 0..10u32 {
9645                db.insert_node("Org", &format!("o{i}"), vec![]).unwrap();
9646                db.insert_node(
9647                    "Person",
9648                    &format!("p{i}"),
9649                    vec![("org_id".into(), Value::Str(format!("o{i}")))],
9650                )
9651                .unwrap();
9652            }
9653            // Create rule BEFORE subscribing — emit_deltas is false during backfill.
9654            db.create_rule(fk_rule()).unwrap();
9655
9656            // Subscribe AFTER the backfill — queue must be empty (no stale events).
9657            let sub = db.subscribe_all_rules().unwrap();
9658            // No events should have queued for the prior backfill.
9659            assert!(
9660                sub.try_recv().is_none(),
9661                "subscribe after backfill must see no stale events"
9662            );
9663
9664            // Inserting a new node now should fire an event (emit_deltas is now true).
9665            db.insert_node("Org", "o_new", vec![]).unwrap();
9666            db.insert_node(
9667                "Person",
9668                "p_new",
9669                vec![("org_id".into(), Value::Str("o_new".into()))],
9670            )
9671            .unwrap();
9672            let ev = sub.recv_timeout(std::time::Duration::from_millis(200));
9673            assert!(
9674                ev.is_some(),
9675                "edge-fire event must arrive after subscribe (emit_deltas=true)"
9676            );
9677        }
9678        let _ = std::fs::remove_dir_all(&dir);
9679    }
9680
9681    /// Gating regression: subscribe BEFORE a backfill → events flow.
9682    #[test]
9683    fn subscribe_before_backfill_events_flow() {
9684        let dir = tmp_dir("sub-before-backfill");
9685        {
9686            let mut db = GraphDb::open(&dir).unwrap();
9687            // Subscribe FIRST — emit_deltas becomes true.
9688            let sub = db.subscribe_all_rules().unwrap();
9689
9690            for i in 0..5u32 {
9691                db.insert_node("Org", &format!("o{i}"), vec![]).unwrap();
9692                db.insert_node(
9693                    "Person",
9694                    &format!("p{i}"),
9695                    vec![("org_id".into(), Value::Str(format!("o{i}")))],
9696                )
9697                .unwrap();
9698            }
9699            // Backfill fires with emit_deltas=true → events queued.
9700            db.create_rule(fk_rule()).unwrap();
9701
9702            // Should receive at least one edge-fired event from the backfill.
9703            let mut received = 0usize;
9704            while sub.try_recv().is_some() {
9705                received += 1;
9706            }
9707            assert!(
9708                received > 0,
9709                "subscribe before backfill must receive edge-fire events (got 0)"
9710            );
9711        }
9712        let _ = std::fs::remove_dir_all(&dir);
9713    }
9714
9715    /// Companion: when a view IS defined, the delta path fires and view values update.
9716    #[test]
9717    fn delta_copy_fires_when_view_exists() {
9718        use core_rules::ViewSource;
9719        DELTA_COPY_COUNT.with(|c| c.set(0));
9720        let dir = tmp_dir("delta-copy-with-view");
9721        {
9722            let mut db = GraphDb::open(&dir).unwrap();
9723            db.insert_node("Org", "o1", vec![]).unwrap();
9724            db.insert_node(
9725                "Person",
9726                "p1",
9727                vec![("org_id".into(), Value::Str("o1".into()))],
9728            )
9729            .unwrap();
9730            // Declare a Degree view so is_empty() returns false.
9731            db.create_view(ViewDef {
9732                name: "degree_out".into(),
9733                label: "Person".into(),
9734                view_prop: "degree_out".into(),
9735                source: ViewSource::Degree {
9736                    edge_type: "WORKS_AT".into(),
9737                    direction: Direction::Out,
9738                },
9739            })
9740            .unwrap();
9741            db.create_rule(fk_rule()).unwrap();
9742
9743            // At least one delta copy should have happened (CreateRule backfill).
9744            let copies = DELTA_COPY_COUNT.with(|c| c.get());
9745            assert!(
9746                copies > 0,
9747                "expected delta copy to fire when a view is defined"
9748            );
9749
9750            // View value should be computed: p1 has one WORKS_AT out-edge.
9751            let info = db.node_info("p1").unwrap();
9752            let degree = info.props.get("degree_out");
9753            assert!(
9754                degree.is_some(),
9755                "view prop should be written to node props"
9756            );
9757        }
9758        let _ = std::fs::remove_dir_all(&dir);
9759    }
9760
9761    /// Regression: `open_at_with` must call `rebuild_all` after WAL replay so
9762    /// derived-edge-driven view values reflect the as-of state rather than just
9763    /// the initial backfill written at `CreateView` time.
9764    ///
9765    /// Base WAL frames (indices 0..=5 before history markers):
9766    ///   0: insert Org "o1"
9767    ///   1: create_view "employee_count" (Degree / WORKS_AT / In) on Org
9768    ///   2: create_rule fk_rule (WORKS_AT, Person→Org via org_id)
9769    ///   3: insert Person "p1" → rule fires WORKS_AT p1→o1 (degree = 1)  ← mid
9770    ///   4: insert Person "p2" → rule fires WORKS_AT p2→o1 (degree = 2)
9771    ///   5: insert Person "p3" → rule fires WORKS_AT p3→o1 (degree = 3)  ← latest
9772    ///
9773    /// Each rule-fire also appends a DerivedEdgeAdded history-marker frame (state
9774    /// no-op), so the total commit count is higher than the base frame count.
9775    /// The "latest" open_at commit is computed dynamically via `wal_commit_count_at`.
9776    ///
9777    /// Without `rebuild_all`, the as-of instance's "emp" view stays at the
9778    /// initial backfill value (0) instead of reflecting the replayed derived edges.
9779    #[test]
9780    fn open_at_derived_edge_view_values_correct() {
9781        use core_rules::ViewSource;
9782        let dir = tmp_dir("open-at-view-rebuild");
9783        {
9784            let mut db = GraphDb::open(&dir).unwrap();
9785            // frame 0
9786            db.insert_node("Org", "o1", vec![]).unwrap();
9787            // frame 1: create view — initial backfill sees 0 derived edges (none fired yet)
9788            db.create_view(ViewDef {
9789                name: "employee_count".into(),
9790                label: "Org".into(),
9791                view_prop: "emp".into(),
9792                source: ViewSource::Degree {
9793                    edge_type: "WORKS_AT".into(),
9794                    direction: Direction::In,
9795                },
9796            })
9797            .unwrap();
9798            // frame 2: create rule — no Persons yet; backfill is a no-op
9799            db.create_rule(fk_rule()).unwrap();
9800            // frame 3: p1 — rule fires WORKS_AT p1→o1; degree = 1
9801            db.insert_node(
9802                "Person",
9803                "p1",
9804                vec![("org_id".into(), Value::Str("o1".into()))],
9805            )
9806            .unwrap();
9807            // frame 4: p2 — degree = 2
9808            db.insert_node(
9809                "Person",
9810                "p2",
9811                vec![("org_id".into(), Value::Str("o1".into()))],
9812            )
9813            .unwrap();
9814            // frame 5: p3 — degree = 3
9815            db.insert_node(
9816                "Person",
9817                "p3",
9818                vec![("org_id".into(), Value::Str("o1".into()))],
9819            )
9820            .unwrap();
9821            // Sanity: normal open sees degree = 3.
9822            assert_eq!(
9823                db.get_view_prop("o1", "emp"),
9824                Some(Value::Int(3)),
9825                "normal db must show degree 3 after 3 derived edges"
9826            );
9827        } // WAL flushed
9828
9829        // Re-open normally to get the authoritative reference value.
9830        let normal_db = GraphDb::open(&dir).unwrap();
9831        let normal_emp = normal_db.get_view_prop("o1", "emp");
9832        assert_eq!(
9833            normal_emp,
9834            Some(Value::Int(3)),
9835            "re-opened normal db must show degree 3"
9836        );
9837
9838        // Latest as-of (last WAL commit): must match the normal open.
9839        // History-marker frames are appended after each rule-fire, so the total
9840        // commit count is computed dynamically rather than hardcoded.
9841        let total = crate::wal_commit_count_at(&dir).unwrap();
9842        let aof_latest = GraphDb::open_at(&dir, total - 1).unwrap();
9843        assert_eq!(
9844            aof_latest.get_view_prop("o1", "emp"),
9845            normal_emp,
9846            "open_at latest: derived-edge view must equal normal open (rebuild_all required)"
9847        );
9848
9849        // Mid-history as-of (commit 3 = p1 insert Batch frame): only p1; degree = 1.
9850        // The DerivedEdgeAdded marker for p1 is at frame 4 (state no-op on replay),
9851        // so replaying 0..=3 correctly re-derives only the p1→o1 edge.
9852        let aof_mid = GraphDb::open_at(&dir, 3).unwrap();
9853        assert_eq!(
9854            aof_mid.get_view_prop("o1", "emp"),
9855            Some(Value::Int(1)),
9856            "open_at mid-history: only p1 exists at frame 3, degree must be 1"
9857        );
9858
9859        let _ = std::fs::remove_dir_all(&dir);
9860    }
9861
9862    /// Pin: subscribe_* on an as-of instance must return Err(ReadOnly) —
9863    /// as-of instances never commit, so distribute_events never runs and any
9864    /// subscription would wait forever.
9865    #[test]
9866    fn subscribe_on_as_of_returns_read_only_error() {
9867        let dir = tmp_dir("sub-as-of-read-only");
9868        {
9869            let mut db = GraphDb::open(&dir).unwrap();
9870            db.insert_node("Org", "o1", vec![]).unwrap();
9871            db.create_rule(fk_rule()).unwrap();
9872        }
9873        let mut aof = GraphDb::open_at(&dir, 0).unwrap();
9874
9875        assert!(
9876            matches!(
9877                aof.subscribe_all_rules(),
9878                Err(core_storage::GraphError::ReadOnly)
9879            ),
9880            "subscribe_all_rules on as-of must return ReadOnly"
9881        );
9882        assert!(
9883            matches!(
9884                aof.subscribe_writes(),
9885                Err(core_storage::GraphError::ReadOnly)
9886            ),
9887            "subscribe_writes on as-of must return ReadOnly"
9888        );
9889        assert!(
9890            matches!(
9891                aof.subscribe_rule("works_at"),
9892                Err(core_storage::GraphError::ReadOnly)
9893            ),
9894            "subscribe_rule on as-of must return ReadOnly"
9895        );
9896        let _ = std::fs::remove_dir_all(&dir);
9897    }
9898
9899    /// Regression: a failed dense WAL rewrite must not leave speculative
9900    /// interns in `syms`. If it does, the next successful mutation logs an
9901    /// `Intern` record with an inflated id; replay (which never saw the
9902    /// orphans) assigns a smaller id and the WAL becomes unreplayable.
9903    #[test]
9904    fn dense_rewrite_error_rolls_back_speculative_interns() {
9905        let dir = tmp_dir("dense-rewrite-rollback");
9906        {
9907            let mut db = GraphDb::open(&dir).unwrap();
9908            db.insert_node("Person", "a", vec![]).unwrap();
9909
9910            // Bypass MutPreview validation to hit the rewrite's own error path
9911            // (same shape as an id-exhaustion failure mid-rewrite). The
9912            // InsertEdge arm interns the edge type before it resolves keys.
9913            let err = db.rewrite_wal_dense(vec![WalRecord::InsertEdge {
9914                edge_type: "ORPHAN_TYPE".into(),
9915                src_key: "missing".into(),
9916                dst_key: "a".into(),
9917            }]);
9918            assert!(err.is_err(), "rewrite of a missing src key must fail");
9919            assert_eq!(
9920                db.syms.get("ORPHAN_TYPE"),
9921                None,
9922                "failed rewrite must roll back speculative interns"
9923            );
9924
9925            // A later successful mutation must produce a replayable WAL.
9926            db.set_prop("a", "later_field", Value::Int(2)).unwrap();
9927        }
9928        let db = GraphDb::open(&dir).expect("WAL must replay after failed rewrite");
9929        assert_eq!(db.get_prop("a", "later_field"), Some(Value::Int(2)));
9930        let _ = std::fs::remove_dir_all(&dir);
9931    }
9932}