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, is_subscribable, is_write_tokens, lex, parse, parse_write, plan, MatchDeleteNodeStmt,
9    NodePat, Operand, Params, Pattern, PlanOp, Query, RetItem, RetVal, WriteStatement,
10};
11use core_query::{eval_filter, expand, neighborhood, Dir, Filter, GraphView, ResultSet};
12use core_rules::{
13    decode_rule_def, evaluate, EngineEdgeDelta, GraphMut, NodeView, Predicate, RuleDef, RuleEngine,
14    ViewDef, ViewStore,
15};
16use core_storage::fs::{FileId, Fs, FsIntrospect, RealFs};
17use core_storage::fulltext::FulltextIndex;
18use core_storage::v8::encode::{
19    archived_hnsw_to_owned, archived_rules_meta_to_owned, archived_to_idmap, archived_to_interner,
20    archived_views_to_owned, decode_last_change_bytes, decode_meta, encode_v8, V8Meta,
21};
22use core_storage::v8::seam::TopologyView;
23use core_storage::wal::{decode_all, encode_record, WalRecord};
24use core_storage::EdgePropsView;
25use core_storage::{
26    ColumnStore, Direction, EdgeProps, GraphError, IdMap, Interner, Result, Topology, Value,
27};
28use serde::{Deserialize, Serialize};
29use std::collections::{BTreeMap, BTreeSet, HashMap};
30use std::sync::Arc;
31
32/// Print a timing checkpoint when MUSHROOMDB_TRACE_OPEN is set.
33/// Zero-cost when the env var is absent (the var check is O(1) after first call).
34macro_rules! trace_open {
35    ($phase:literal, $t:expr) => {
36        if std::env::var("MUSHROOMDB_TRACE_OPEN").is_ok() {
37            eprintln!(
38                "[MUSHROOMDB_TRACE_OPEN] {:40} {:>9.3?}",
39                $phase,
40                $t.elapsed()
41            );
42        }
43    };
44}
45
46/// Print a migration phase checkpoint when MUSHROOMDB_TRACE_MIGRATE is set.
47/// Zero-cost when the env var is absent (the var check is O(1) after first call).
48macro_rules! trace_migrate {
49    ($phase:literal, $t:expr) => {
50        if std::env::var("MUSHROOMDB_TRACE_MIGRATE").is_ok() {
51            eprintln!(
52                "[MUSHROOMDB_TRACE_MIGRATE] {:40} {:>9.3?}",
53                $phase,
54                $t.elapsed()
55            );
56        }
57    };
58}
59
60// Test-only: counts how many times `pending_deltas_since().to_vec()` actually
61// executes (i.e., at least one view is defined). Used to verify the fast-path
62// guard skips the allocation when `view_store.is_empty()`.
63#[cfg(test)]
64thread_local! {
65    static DELTA_COPY_COUNT: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
66}
67
68/// Internal state for a single `subscribe_query` subscription.
69///
70/// On every commit, `distribute_events` re-executes `ops` against the current
71/// graph state, diffs the result against `prev_rows`, and pushes
72/// `DbEvent::QueryRowAdded` / `QueryRowRemoved` events to `inner`.
73///
74/// **Full re-run per commit; use LIMIT to bound execution cost.**
75/// (Differential evaluation is roadmap / Phase 5.)
76pub(crate) struct QuerySubEntry {
77    /// Compiled plan for the subscribed Cypher query.
78    ops: Vec<PlanOp>,
79    /// Column names from the first execution (fixed for the subscription lifetime).
80    columns: Vec<String>,
81    /// Serialized (JSON) row key → row data, representing the result set at
82    /// the end of the last commit. Used to diff against the new result.
83    prev_row_map: std::collections::HashMap<String, Vec<Option<Value>>>,
84    /// Weak pointer to the subscriber queue; dead Weak → subscription dropped.
85    inner: std::sync::Weak<SubInner>,
86}
87
88/// A post-commit mutation notification.
89///
90/// Emitted from `log_then_apply` after the WAL append, fsync, and
91/// in-memory `apply` all succeed. Never emitted for rejected operations
92/// (validation errors, [`GraphError::RuleOwned`], duplicate keys, no-op
93/// deletes/removes). Event payloads carry user keys and rule names, never
94/// internal ids.
95///
96/// **Replay:** [`GraphDb::open`] / [`GraphDb::open_with`] replay the WAL via
97/// `apply` only. Emission lives exclusively in `log_then_apply`, so
98/// recovery is silent even if a sink were installed (it cannot be: the
99/// sink is in-memory and set after open).
100///
101/// **Ordering:** a `Batch` WAL frame emits one event per inner record, then
102/// [`MutationEvent::BatchApplied`]. An ingest commit emits those same inner
103/// events, then [`MutationEvent::Ingested`] (not `BatchApplied`). An empty
104/// or all-noop batch writes no WAL and emits nothing (including no summary).
105///
106/// **Derived edges:** rule-created or retracted edges are not individually
107/// evented — they are recoverable from the triggering mutation plus the live
108/// rule set. Only the triggering record is emitted.
109///
110/// **Wire form:** externally tagged snake_case JSON
111/// (`{"node_inserted":{"label":"A","key":"k"}}`).
112#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
113#[serde(rename_all = "snake_case")]
114pub enum MutationEvent {
115    NodeInserted {
116        label: String,
117        key: String,
118    },
119    PropSet {
120        key: String,
121        field: String,
122    },
123    PropRemoved {
124        key: String,
125        field: String,
126    },
127    EdgeInserted {
128        edge_type: String,
129        src: String,
130        dst: String,
131    },
132    EdgeDeleted {
133        edge_type: String,
134        src: String,
135        dst: String,
136    },
137    NodeDeleted {
138        key: String,
139    },
140    RuleCreated {
141        name: String,
142    },
143    RuleDeleted {
144        name: String,
145    },
146    RuleRebuilt {
147        name: String,
148    },
149    BatchApplied {
150        ops: usize,
151    },
152    Ingested {
153        label: String,
154        inserted: usize,
155    },
156}
157
158fn event_from_record(rec: &WalRecord, intern: &Interner, ids: &IdMap) -> Option<MutationEvent> {
159    match rec {
160        WalRecord::InsertNode { label, key, .. } => Some(MutationEvent::NodeInserted {
161            label: label.clone(),
162            key: key.clone(),
163        }),
164        WalRecord::InsertNodeId { label, key, .. } => Some(MutationEvent::NodeInserted {
165            label: intern.resolve(*label)?.to_string(),
166            key: key.clone(),
167        }),
168        WalRecord::SetProp { key, field, .. } => Some(MutationEvent::PropSet {
169            key: key.clone(),
170            field: field.clone(),
171        }),
172        WalRecord::SetPropId { id, field, .. } => Some(MutationEvent::PropSet {
173            key: ids.key_of(*id)?.to_string(),
174            field: intern.resolve(*field)?.to_string(),
175        }),
176        WalRecord::RemoveProp { key, field } => Some(MutationEvent::PropRemoved {
177            key: key.clone(),
178            field: field.clone(),
179        }),
180        WalRecord::InsertEdge {
181            edge_type,
182            src_key,
183            dst_key,
184        } => Some(MutationEvent::EdgeInserted {
185            edge_type: edge_type.clone(),
186            src: src_key.clone(),
187            dst: dst_key.clone(),
188        }),
189        WalRecord::InsertEdgeId { etype, src, dst } => Some(MutationEvent::EdgeInserted {
190            edge_type: intern.resolve(*etype)?.to_string(),
191            src: ids.key_of(*src)?.to_string(),
192            dst: ids.key_of(*dst)?.to_string(),
193        }),
194        WalRecord::DeleteEdge {
195            edge_type,
196            src_key,
197            dst_key,
198        } => Some(MutationEvent::EdgeDeleted {
199            edge_type: edge_type.clone(),
200            src: src_key.clone(),
201            dst: dst_key.clone(),
202        }),
203        WalRecord::DeleteNode { key } => Some(MutationEvent::NodeDeleted { key: key.clone() }),
204        WalRecord::CreateRule { def_bytes } => {
205            let def: RuleDef = decode_rule_def(def_bytes).ok()?;
206            Some(MutationEvent::RuleCreated { name: def.name })
207        }
208        WalRecord::DeleteRule { name } => Some(MutationEvent::RuleDeleted { name: name.clone() }),
209        WalRecord::RebuildRule { name } => Some(MutationEvent::RuleRebuilt { name: name.clone() }),
210        WalRecord::Batch(_)
211        | WalRecord::CreateView { .. }
212        | WalRecord::DeleteView { .. }
213        | WalRecord::EnableFulltext { .. }
214        | WalRecord::DisableFulltext { .. }
215        | WalRecord::Intern { .. }
216        // History markers are no-ops for mutation events — they carry no new
217        // state and rules re-derive deterministically on replay.
218        | WalRecord::DerivedEdgeAdded { .. }
219        | WalRecord::DerivedEdgeRetracted { .. }
220        // RenameNode carries no node/edge count change; no special event.
221        | WalRecord::RenameNode { .. } => None,
222    }
223}
224
225/// Database-wide counters plus per-rule budget/fire stats.
226#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
227pub struct Stats {
228    pub nodes_live: usize,
229    pub nodes_tombstoned: usize,
230    pub edges: u64,
231    pub rules: Vec<RuleStats>,
232}
233
234/// One rule's provenance size, trip latch, and fire counter.
235///
236/// `tripped` is a one-way latch: once set, the engine adds no new edges for
237/// that rule until [`GraphDb::rebuild_rule`] (and only if the full desired
238/// set then fits). `fires` counts `on_node_changed` evaluations plus
239/// backfill/rebuild participant ticks (rebuild counts even when it is a
240/// provenance no-op).
241#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
242pub struct RuleStats {
243    pub name: String,
244    pub edges: u64,
245    pub tripped: bool,
246    pub fires: u64,
247    /// Whether this rule uses the approximate IVF-Flat candidate path.
248    pub approximate: bool,
249}
250
251/// Wire summary of a [`Predicate`]. JSON only — `Explanation` is never
252/// bincode-persisted (WAL/snapshots store `RuleDef` bytes, not this type).
253#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
254pub struct PredicateSummary {
255    pub kind: String,
256    pub fields: Vec<String>,
257    pub min: Option<f64>,
258    pub tolerance: Option<f64>,
259    pub km: Option<f64>,
260    pub parts: Option<Vec<PredicateSummary>>,
261    /// True when the owning rule has `approximate=true` (IVF-Flat candidate path).
262    /// Always false for predicates reported without rule context (sub-predicates in `parts`).
263    #[serde(default)]
264    pub approximate: bool,
265}
266
267impl From<&Predicate> for PredicateSummary {
268    fn from(p: &Predicate) -> Self {
269        match p {
270            Predicate::KeyMatch { field } => PredicateSummary {
271                kind: "key_match".into(),
272                fields: vec![field.clone()],
273                min: None,
274                tolerance: None,
275                km: None,
276                parts: None,
277                approximate: false,
278            },
279            Predicate::FieldEqual { field } => PredicateSummary {
280                kind: "field_equal".into(),
281                fields: vec![field.clone()],
282                min: None,
283                tolerance: None,
284                km: None,
285                parts: None,
286                approximate: false,
287            },
288            Predicate::Overlap { field, min } => PredicateSummary {
289                kind: "overlap".into(),
290                fields: vec![field.clone()],
291                min: Some(*min),
292                tolerance: None,
293                km: None,
294                parts: None,
295                approximate: false,
296            },
297            Predicate::NumericWithin { field, tolerance } => PredicateSummary {
298                kind: "numeric_within".into(),
299                fields: vec![field.clone()],
300                min: None,
301                tolerance: Some(*tolerance),
302                km: None,
303                parts: None,
304                approximate: false,
305            },
306            Predicate::GeoRadius { field, km } => PredicateSummary {
307                kind: "geo_radius".into(),
308                fields: vec![field.clone()],
309                min: None,
310                tolerance: None,
311                km: Some(*km),
312                parts: None,
313                approximate: false,
314            },
315            Predicate::VectorSimilar { field, min } => PredicateSummary {
316                kind: "vector_similar".into(),
317                fields: vec![field.clone()],
318                min: Some(*min),
319                tolerance: None,
320                km: None,
321                parts: None,
322                approximate: false,
323            },
324            Predicate::All(inner) => {
325                let parts: Vec<PredicateSummary> = inner.iter().map(Self::from).collect();
326                let mut fields = Vec::new();
327                for part in &parts {
328                    for f in &part.fields {
329                        if !fields.contains(f) {
330                            fields.push(f.clone());
331                        }
332                    }
333                }
334                PredicateSummary {
335                    kind: "all".into(),
336                    fields,
337                    min: None,
338                    tolerance: None,
339                    km: None,
340                    parts: Some(parts),
341                    approximate: false,
342                }
343            }
344            Predicate::Any(inner) => {
345                let parts: Vec<PredicateSummary> = inner.iter().map(Self::from).collect();
346                let mut fields = Vec::new();
347                for part in &parts {
348                    for f in &part.fields {
349                        if !fields.contains(f) {
350                            fields.push(f.clone());
351                        }
352                    }
353                }
354                PredicateSummary {
355                    kind: "any".into(),
356                    fields,
357                    min: None,
358                    tolerance: None,
359                    km: None,
360                    parts: Some(parts),
361                    approximate: false,
362                }
363            }
364        }
365    }
366}
367
368/// Snapshot of a live node's key, label, and columnar properties.
369///
370/// `props` is a [`BTreeMap`] so field order is deterministic (sorted by name)
371/// regardless of insert order or the columnar store's `HashMap` iteration.
372///
373/// Deliberately does not derive `Serialize`: `Value`'s serde form is
374/// internally tagged. Wire JSON is built by `value_to_json` in the server.
375#[derive(Debug, Clone, PartialEq)]
376pub struct NodeInfo {
377    pub key: String,
378    pub label: String,
379    pub props: BTreeMap<String, Value>,
380}
381
382/// Counts returned by [`GraphDb::delete_node`].
383#[derive(Debug, Clone, PartialEq, Eq, Default)]
384pub struct DeleteReport {
385    /// Number of manual (user-inserted) edges removed.
386    pub manual_edges: u64,
387    /// Number of derived (rule-owned) edges retracted.
388    pub derived_edges: u64,
389}
390
391/// One directed edge incident on a node, with provenance membership.
392///
393/// `derived` is true iff `(edge_type, src, dst)` is in the rule engine's
394/// Plan-8 `by_node` provenance index.
395#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
396pub struct EdgeInfo {
397    pub edge_type: String,
398    pub src_key: String,
399    pub dst_key: String,
400    pub derived: bool,
401}
402
403/// An edge with mask-aware endpoint visibility.
404///
405/// Returned by [`GraphDb::node_edges_masked`] in [`crate::mask::MaskMode::Stub`]
406/// mode — hidden endpoints carry `*_restricted: true`.
407#[derive(Debug, Clone, PartialEq, Eq)]
408pub struct MaskedEdge {
409    pub edge_type: String,
410    pub src_key: String,
411    /// `true` when `src_key` is in the DB but hidden from the mask.
412    pub src_restricted: bool,
413    pub dst_key: String,
414    /// `true` when `dst_key` is in the DB but hidden from the mask.
415    pub dst_restricted: bool,
416    pub derived: bool,
417}
418
419/// Result of a mask-aware node lookup via [`GraphDb::node_info_masked`].
420///
421/// `None` from that method means the key does not exist (→ 404).
422/// `Some(Restricted)` is only produced when `mask.mode() == MaskMode::Stub`.
423#[derive(Debug, PartialEq)]
424pub enum MaskedNodeResult {
425    Visible(NodeInfo),
426    /// Node exists in the DB but is hidden from this mask.
427    Restricted,
428}
429
430/// One rule-owned edge between two nodes, with the rule name, edge type,
431/// direction (src_key → dst_key), and weight if the rule stores one.
432#[derive(Debug, Clone, PartialEq, Serialize)]
433pub struct Explanation {
434    pub rule: String,
435    pub edge_type: String,
436    pub src_key: String,
437    pub dst_key: String,
438    pub weight: Option<f64>,
439    pub predicate: PredicateSummary,
440}
441
442/// Report returned by [`GraphDb::backup_to`].
443#[derive(Debug, Clone)]
444pub struct BackupReport {
445    /// Filenames copied into the destination directory (sorted ascending).
446    pub files: Vec<String>,
447    /// Total bytes written across all copied files.
448    pub bytes: u64,
449    /// `true` when the destination opened cleanly and passed post-copy checks.
450    ///
451    /// For stores that have a `snapshot.bin` this means: all V8 section CRCs
452    /// matched **and** the destination opened without error.
453    ///
454    /// For WAL-only stores (no `snapshot.bin`) there is no snapshot to
455    /// CRC-check; `verified` is `true` when the destination opened and
456    /// replayed the WAL without error (record-level checksums in the WAL
457    /// provide the integrity signal, not section CRCs).
458    pub verified: bool,
459}
460
461/// One directed edge in export form, with optional rule attribution for derived edges.
462///
463/// Returned by [`GraphDb::all_edges_for_export`].
464#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
465pub struct ExportEdge {
466    pub edge_type: String,
467    pub src: String,
468    pub dst: String,
469    pub derived: bool,
470    /// Rule name that created this edge, if derived. `None` for manual edges.
471    pub rule: Option<String>,
472}
473
474/// Construct the standard write-query result set (columns: created, properties_set, deleted).
475fn write_result_set() -> ResultSet {
476    ResultSet::new(vec![
477        "created".into(),
478        "properties_set".into(),
479        "deleted".into(),
480    ])
481}
482
483fn resolve_merge_set_value(op: &Operand, params: &BTreeMap<String, Value>) -> Result<Value> {
484    match op {
485        Operand::Lit(v) => Ok(v.clone()),
486        Operand::Param(name) => params
487            .get(name)
488            .cloned()
489            .ok_or_else(|| GraphError::QueryError {
490                detail: format!("missing parameter `{name}`"),
491            }),
492        _ => Err(GraphError::QueryError {
493            detail: "ON CREATE/ON MATCH SET value must be a literal or $parameter".into(),
494        }),
495    }
496}
497
498fn operand_node_vars(op: &Operand, out: &mut Vec<String>) {
499    match op {
500        Operand::Prop { var, .. } | Operand::Var(var) => {
501            if !out.contains(var) {
502                out.push(var.clone());
503            }
504        }
505        Operand::FuncCall { args, .. } => {
506            for arg in args {
507                operand_node_vars(arg, out);
508            }
509        }
510        Operand::BinArith { left, right, .. } => {
511            operand_node_vars(left, out);
512            operand_node_vars(right, out);
513        }
514        Operand::Lit(_) | Operand::Param(_) => {}
515    }
516}
517
518fn ret_node_vars(items: &[RetItem]) -> Vec<String> {
519    let mut out = Vec::new();
520    for item in items {
521        match &item.value {
522            RetVal::Var(v) | RetVal::Prop { var: v, .. } => {
523                if !out.contains(v) {
524                    out.push(v.clone());
525                }
526            }
527            RetVal::FuncCall { args, .. } => {
528                for arg in args {
529                    operand_node_vars(arg, &mut out);
530                }
531            }
532            RetVal::ScalarExpr(op) => operand_node_vars(op, &mut out),
533            RetVal::Agg { .. } => {}
534        }
535    }
536    out
537}
538
539fn add_var(out: &mut Vec<String>, v: &str) {
540    if !out.iter().any(|x| x == v) {
541        out.push(v.to_string());
542    }
543}
544
545fn pattern_node_vars(pats: &[Pattern]) -> Vec<String> {
546    let mut out = Vec::new();
547    for p in pats {
548        if let Some(v) = &p.start.var {
549            add_var(&mut out, v);
550        }
551        for (_, dest) in &p.chain {
552            if let Some(v) = &dest.var {
553                add_var(&mut out, v);
554            }
555        }
556    }
557    out
558}
559
560fn pattern_rel_vars(pats: &[Pattern]) -> Vec<String> {
561    let mut out = Vec::new();
562    for p in pats {
563        for (rel, _) in &p.chain {
564            if rel.hops.is_none() {
565                if let Some(v) = &rel.var {
566                    add_var(&mut out, v);
567                }
568            }
569        }
570    }
571    out
572}
573
574fn rel_type_alias(var: &str) -> String {
575    format!("__rt_{var}")
576}
577
578fn ret_column_name(item: &RetItem) -> String {
579    if let Some(alias) = &item.alias {
580        return alias.clone();
581    }
582    match &item.value {
583        RetVal::Var(v) => v.clone(),
584        RetVal::Prop { var, field } => format!("{var}.{field}"),
585        RetVal::FuncCall { name, args } => {
586            let arg_strs: Vec<String> = args
587                .iter()
588                .map(|a| match a {
589                    Operand::Var(v) => v.clone(),
590                    Operand::Prop { var, field } => format!("{var}.{field}"),
591                    Operand::Lit(_) => "<lit>".to_string(),
592                    Operand::Param(p) => format!("${p}"),
593                    Operand::FuncCall { name: n, .. } => format!("{n}(...)"),
594                    Operand::BinArith { .. } => "<arith>".to_string(),
595                })
596                .collect();
597            format!("{name}({})", arg_strs.join(", "))
598        }
599        RetVal::ScalarExpr(_) => "<expr>".to_string(),
600        RetVal::Agg { .. } => "<agg>".to_string(),
601    }
602}
603
604fn eval_set_return_operand<F: Fs>(
605    db: &GraphDb<F>,
606    match_rs: &ResultSet,
607    row: usize,
608    rel_vars: &[String],
609    op: &Operand,
610    params: &BTreeMap<String, Value>,
611) -> Result<Option<Value>> {
612    match op {
613        Operand::Lit(v) => Ok(Some(v.clone())),
614        Operand::Param(name) => params.get(name).cloned().ok_or_else(|| GraphError::QueryError {
615            detail: format!("missing parameter `{name}`"),
616        }).map(Some),
617        Operand::Var(name) if rel_vars.iter().any(|r| r == name) => Err(GraphError::QueryError {
618            detail: format!(
619                "cannot return relationship variable '{name}' bare; return its properties ({name}.field) instead"
620            ),
621        }),
622        Operand::Var(name) => Ok(match_rs.get(row, name).cloned()),
623        Operand::Prop { var, field } => {
624            if rel_vars.iter().any(|r| r == var) {
625                return Ok(None);
626            }
627            let Some(Value::Str(key)) = match_rs.get(row, var) else {
628                return Ok(None);
629            };
630            Ok(db.get_prop(key, field))
631        }
632        Operand::FuncCall { name, args } => {
633            eval_set_return_func(db, match_rs, row, rel_vars, name, args, params)
634        }
635        Operand::BinArith { op, left, right } => {
636            let lv = eval_set_return_operand(db, match_rs, row, rel_vars, left, params)?;
637            let rv = eval_set_return_operand(db, match_rs, row, rel_vars, right, params)?;
638            eval_set_return_arith(op, lv, rv)
639        }
640    }
641}
642
643fn eval_set_return_arith(
644    op: &ArithOp,
645    lv: Option<Value>,
646    rv: Option<Value>,
647) -> Result<Option<Value>> {
648    match (lv, rv) {
649        (None, _) | (_, None) => Ok(None),
650        (Some(Value::Int(a)), Some(Value::Int(b))) => {
651            let result = match op {
652                ArithOp::Sub => a.saturating_sub(b),
653                ArithOp::Mul => a.saturating_mul(b),
654                ArithOp::Add => a.saturating_add(b),
655                ArithOp::Div => {
656                    if b == 0 {
657                        return Err(GraphError::QueryError {
658                            detail: "division by zero".into(),
659                        });
660                    }
661                    a.checked_div(b).unwrap_or(i64::MAX)
662                }
663            };
664            Ok(Some(Value::Int(result)))
665        }
666        (Some(lv), Some(rv)) => {
667            let a = match &lv {
668                Value::Float(f) => *f,
669                Value::Int(i) => *i as f64,
670                _ => {
671                    return Err(GraphError::QueryError {
672                        detail: format!("arithmetic operand must be numeric, got {lv:?}"),
673                    })
674                }
675            };
676            let b = match &rv {
677                Value::Float(f) => *f,
678                Value::Int(i) => *i as f64,
679                _ => {
680                    return Err(GraphError::QueryError {
681                        detail: format!("arithmetic operand must be numeric, got {rv:?}"),
682                    })
683                }
684            };
685            let result = match op {
686                ArithOp::Sub => a - b,
687                ArithOp::Mul => a * b,
688                ArithOp::Add => a + b,
689                ArithOp::Div => {
690                    if b == 0.0 {
691                        return Err(GraphError::QueryError {
692                            detail: "division by zero".into(),
693                        });
694                    }
695                    a / b
696                }
697            };
698            Ok(Some(Value::Float(result)))
699        }
700    }
701}
702
703fn eval_set_return_func<F: Fs>(
704    db: &GraphDb<F>,
705    match_rs: &ResultSet,
706    row: usize,
707    rel_vars: &[String],
708    name: &str,
709    args: &[Operand],
710    params: &BTreeMap<String, Value>,
711) -> Result<Option<Value>> {
712    let norm = name.to_ascii_lowercase();
713    if norm == "type" {
714        if args.len() != 1 {
715            return Err(GraphError::QueryError {
716                detail: format!("type() requires exactly 1 argument, got {}", args.len()),
717            });
718        }
719        let Operand::Var(rel) = &args[0] else {
720            return Err(GraphError::QueryError {
721                detail: "type() argument must be a relationship variable (e.g. type(r))".into(),
722            });
723        };
724        return Ok(match_rs.get(row, &rel_type_alias(rel)).cloned());
725    }
726    let mut vals = Vec::with_capacity(args.len());
727    for arg in args {
728        vals.push(eval_set_return_operand(
729            db, match_rs, row, rel_vars, arg, params,
730        )?);
731    }
732    match norm.as_str() {
733        "tolower" => {
734            if vals.len() != 1 {
735                return Err(GraphError::QueryError {
736                    detail: format!("toLower() requires exactly 1 argument, got {}", vals.len()),
737                });
738            }
739            Ok(vals[0].clone().map(|val| match val {
740                Value::Str(s) => Value::Str(s.to_ascii_lowercase()),
741                other => other,
742            }))
743        }
744        "toupper" => {
745            if vals.len() != 1 {
746                return Err(GraphError::QueryError {
747                    detail: format!("toUpper() requires exactly 1 argument, got {}", vals.len()),
748                });
749            }
750            Ok(vals[0].clone().map(|val| match val {
751                Value::Str(s) => Value::Str(s.to_ascii_uppercase()),
752                other => other,
753            }))
754        }
755        "size" => match vals.first().cloned().flatten() {
756            None => Ok(None),
757            Some(Value::Str(s)) => Ok(Some(Value::Int(s.len() as i64))),
758            Some(Value::List(items)) => Ok(Some(Value::Int(items.len() as i64))),
759            Some(_) => Ok(None),
760        },
761        "coalesce" => Ok(vals.into_iter().flatten().next()),
762        "abs" => match vals.first().cloned().flatten() {
763            None => Ok(None),
764            Some(Value::Int(n)) => Ok(Some(Value::Int(n.saturating_abs()))),
765            Some(Value::Float(f)) => Ok(Some(Value::Float(f.abs()))),
766            Some(_) => Ok(None),
767        },
768        "round" => match vals.first().cloned().flatten() {
769            None => Ok(None),
770            Some(Value::Float(f)) => Ok(Some(Value::Float(f.round()))),
771            Some(Value::Int(n)) => Ok(Some(Value::Int(n))),
772            Some(_) => Ok(None),
773        },
774        _ => Err(GraphError::QueryError {
775            detail: format!(
776                "unknown function `{name}`; supported: toLower, toUpper, size, coalesce, type, abs, round, textMatches"
777            ),
778        }),
779    }
780}
781
782fn eval_set_return_item<F: Fs>(
783    db: &GraphDb<F>,
784    match_rs: &ResultSet,
785    row: usize,
786    rel_vars: &[String],
787    item: &RetItem,
788    params: &BTreeMap<String, Value>,
789) -> Result<Option<Value>> {
790    match &item.value {
791        RetVal::Var(v) => eval_set_return_operand(
792            db,
793            match_rs,
794            row,
795            rel_vars,
796            &Operand::Var(v.clone()),
797            params,
798        ),
799        RetVal::Prop { var, field } => eval_set_return_operand(
800            db,
801            match_rs,
802            row,
803            rel_vars,
804            &Operand::Prop {
805                var: var.clone(),
806                field: field.clone(),
807            },
808            params,
809        ),
810        RetVal::FuncCall { name, args } => {
811            eval_set_return_func(db, match_rs, row, rel_vars, name, args, params)
812        }
813        RetVal::ScalarExpr(op) => eval_set_return_operand(db, match_rs, row, rel_vars, op, params),
814        RetVal::Agg { .. } => Err(GraphError::QueryError {
815            detail: "aggregates are not supported in MATCH … SET … RETURN".into(),
816        }),
817    }
818}
819
820/// Project user RETURN from original MATCH rows after SET. No rematch.
821fn project_set_return_rows<F: Fs>(
822    db: &GraphDb<F>,
823    rel_vars: &[String],
824    match_rs: &ResultSet,
825    returns: &[RetItem],
826    params: &BTreeMap<String, Value>,
827) -> Result<ResultSet> {
828    let columns: Vec<String> = returns.iter().map(ret_column_name).collect();
829    let mut out = ResultSet::new(columns);
830    for row in 0..match_rs.len() {
831        let mut cells = Vec::with_capacity(returns.len());
832        for item in returns {
833            cells.push(eval_set_return_item(
834                db, match_rs, row, rel_vars, item, params,
835            )?);
836        }
837        out.push_row(cells);
838    }
839    Ok(out)
840}
841
842/// Single construction point for a `GraphMut` view over the split-borrowed graph fields.
843/// Callers use `std::mem::take` on the engine before calling this, then restore it after.
844/// Extract a `Vec<f64>` from a `Value::List` whose items are all numeric.
845/// Returns `None` for non-list values or lists with non-numeric elements.
846fn value_as_float_list(v: &Value) -> Option<Vec<f64>> {
847    match v {
848        Value::List(items) => items
849            .iter()
850            .map(|item| match item {
851                Value::Float(f) => Some(*f),
852                Value::Int(i) => Some(*i as f64),
853                _ => None,
854            })
855            .collect(),
856        _ => None,
857    }
858}
859
860fn make_graph_mut<'a>(
861    ids: &'a IdMap,
862    syms: &'a mut Interner,
863    labels: &'a [u32],
864    props: core_storage::v8::seam::ColumnsView<'a>,
865    topo: &'a mut Topology,
866    edge_props: &'a mut EdgeProps,
867) -> GraphMut<'a> {
868    GraphMut {
869        ids,
870        syms,
871        labels,
872        props,
873        topo,
874        edge_props,
875    }
876}
877
878/// Build a `ColumnsView` from the disjoint `props` overlay and optional V8 base.
879///
880/// Takes explicit field references rather than `&self` so the caller can hold
881/// simultaneous mutable borrows of other fields (e.g. `syms`, `topo`).
882fn build_props_view<'a>(
883    props: &'a ColumnStore,
884    base: &'a Option<std::sync::Arc<core_storage::v8::MappedBase>>,
885) -> core_storage::v8::seam::ColumnsView<'a> {
886    match base {
887        None => core_storage::v8::seam::ColumnsView::owned(props),
888        Some(b) => {
889            let archived = b
890                .columns()
891                .expect("base columns section bounds validated at open");
892            core_storage::v8::seam::ColumnsView::with_base(props, archived)
893        }
894    }
895}
896
897fn build_topo_view<'a>(
898    overlay: &'a Topology,
899    base: &'a Option<std::sync::Arc<core_storage::v8::MappedBase>>,
900) -> core_storage::v8::seam::TopologyView<'a> {
901    match base {
902        None => core_storage::v8::seam::TopologyView::owned(overlay),
903        Some(b) => {
904            let archived_csr = b
905                .topology()
906                .expect("base topology section bounds validated at open");
907            core_storage::v8::seam::TopologyView::with_base(overlay, archived_csr)
908        }
909    }
910}
911
912/// When [`GraphDb`] calls `Fs::sync` after a WAL append.
913///
914/// Default is [`Strict`](FsyncPolicy::Strict): every `log_then_apply_with`
915/// fsyncs (single `insert_node` / `set_prop`). Ingest and `write_batch`
916/// emit one `WalRecord::Batch` and fsync once at that frame (Batched).
917/// [`Relaxed`](FsyncPolicy::Relaxed) skips WAL sync; [`GraphDb::snapshot`]
918/// is still durable via `write_atomic`. Crash-recovery DST stays Strict.
919#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
920pub enum FsyncPolicy {
921    /// Every WAL commit calls `fs.sync` (today's behavior).
922    #[default]
923    Strict,
924    /// Sync only at a `Batch` frame end. Single-op path stays Strict unless
925    /// this policy is set on the database.
926    Batched,
927    /// Never call `fs.sync`. [`GraphDb::snapshot`] still syncs via `write_atomic`.
928    Relaxed,
929}
930
931/// A precondition for a compare-and-set batch write.
932///
933/// All preconditions in a [`GraphDb::write_batch_cas`] or
934/// [`crate::SharedDb::submit_batch_cas`] call are checked atomically before
935/// any operation in the batch is applied.  If any precondition fails, the
936/// entire batch is rejected with [`GraphError::CasConflict`] and no WAL frame
937/// is written.
938///
939/// # Touch definition
940///
941/// A node's last-change commit (`last_changed`) is updated when any of the
942/// following state-changing WAL records touch it:
943///
944/// - `InsertNode` / `InsertNodeId` — the newly-inserted node.
945/// - `SetProp` / `SetPropId` / `RemoveProp` — the property-bearing node.
946/// - `InsertEdge` / `InsertEdgeId` / `DeleteEdge` — **both** src and dst
947///   endpoints (an edge change touches both sides).
948/// - `DeleteNode` — the node is tombstoned; `last_changed` returns `None`
949///   for deleted keys so the pre-deletion entry is never observed.
950///
951/// History markers (`DerivedEdgeAdded` / `DerivedEdgeRetracted`) are
952/// state no-ops.  The underlying mutation that triggered rule firing already
953/// updated the relevant nodes' last-change entries.  Rule-management records
954/// (`CreateRule`, `DeleteRule`, `RebuildRule`) and view/full-text declarations
955/// do not touch any node's last-change.
956#[derive(Debug, Clone, PartialEq, Eq)]
957pub enum Precondition {
958    /// The node's last-change commit must equal `expected`.
959    ///
960    /// Fails with [`GraphError::CasConflict`] when:
961    /// - The node does not exist (`last_changed` returns `None`), or
962    /// - The recorded commit seq does not match `expected`.
963    NodeUnchangedSince { key: String, expected: u64 },
964    /// The node must not exist (not inserted, or already deleted).
965    ///
966    /// Fails with [`GraphError::CasConflict`] (expected=`u64::MAX`,
967    /// actual=`last_changed(key).unwrap_or(0)`) when the node is live.
968    NodeAbsent { key: String },
969}
970
971pub struct GraphDb<F: Fs> {
972    fs: F,
973    ids: IdMap,
974    syms: Interner,
975    topo: Topology,
976    props: ColumnStore,
977    labels: Vec<u32>, // node id -> label symbol
978    edge_props: EdgeProps,
979    engine: RuleEngine,
980    view_store: ViewStore,
981    /// Incremental inverted index for full-text-lite search.
982    /// Rebuild-on-open: populated from WAL replay + rebuild_all at open end.
983    fulltext: FulltextIndex,
984    event_sink: Option<Box<dyn Fn(MutationEvent) + Send + Sync>>,
985    /// WAL fsync cadence. Default [`FsyncPolicy::Strict`].
986    fsync: FsyncPolicy,
987    /// Monotonically increasing per-commit counter.  A single `log_then_apply_with`
988    /// call increments this once; all events emitted from that call share the same
989    /// `commit_seq` value.
990    commit_seq: u64,
991    /// RBAC role definitions loaded from `roles.json` at open.
992    ///
993    /// `Some(roles)` — loaded successfully (may be empty when no roles are defined).
994    /// `None` — `roles.json` was present but corrupt; `mask_for_role` returns
995    /// `Err` for any request (fail-loud, never silently grant empty visibility).
996    roles: Option<Vec<RoleDef>>,
997    /// Live subscriptions.  Entries with a dead `Weak` are pruned on the next
998    /// distribute_events call.
999    subscriptions: Vec<SubEntry>,
1000    /// Live query subscriptions. Re-executed on every commit when non-empty.
1001    /// Dead `Weak` entries are pruned inside `distribute_events`.
1002    query_subscriptions: Vec<QuerySubEntry>,
1003    /// Queue capacity for new subscriptions created by this db.  Default is
1004    /// [`DEFAULT_SUB_CAPACITY`]; can be overridden via [`set_sub_capacity`]
1005    /// to test Lagged behaviour with small queues.
1006    sub_capacity: usize,
1007    /// True for as-of instances opened via [`GraphDb::open_at`].
1008    /// Every mutation method and `snapshot()` returns [`GraphError::ReadOnly`]
1009    /// when this flag is set.
1010    read_only: bool,
1011    /// Total WAL commit count at the time [`open_at`] was called.
1012    /// 0 for normal (non-as-of) instances.
1013    total_wal_commits: u64,
1014    /// Immutable mmap-backed base snapshot (V8).  When `Some`, `self.topo` is
1015    /// the WAL-replay overlay (empty at open time, populated by apply()) and
1016    /// reads go through a merged `TopologyView`.  `self.props` is always
1017    /// fully materialized (base + WAL replay) for HNSW/IVF and view compat.
1018    base: Option<Arc<core_storage::v8::MappedBase>>,
1019    // ── MVCC epoch reader state ───────────────────────────────────────────────
1020    /// Most-recent full overlay clone.  Initialized at end of `open_with` /
1021    /// `open_at_with`; refreshed every `FOLD_EVERY_K` commits.
1022    /// `None` only between struct creation and the first fold.
1023    fold_overlay: Option<Arc<crate::reader::FrozenOverlay>>,
1024    /// Per-commit deltas accumulated since the last fold.
1025    delta_tail: Vec<Arc<crate::reader::CommitDelta>>,
1026    /// How many commits have occurred since the last fold.
1027    commits_since_fold: usize,
1028    /// When true, `log_then_apply_with` buffers event notifications instead of
1029    /// firing them immediately.  Used by the group-commit drain thread to defer
1030    /// events until after the group fsync (R2: durability before notification).
1031    /// Cleared to false once the drain thread flushes or discards the buffer.
1032    defer_events: bool,
1033    /// Buffered events accumulated while `defer_events` is true.
1034    deferred_events: Vec<DeferredEvent>,
1035    /// Set to true by the group-commit drain thread when a group fsync fails
1036    /// after WAL truncation.  All subsequent mutation attempts return an IO
1037    /// error until the database is reopened.
1038    degraded: bool,
1039    /// Set to `true` after `ensure_v8_base_sections_loaded` has read provenance,
1040    /// HNSW, and IVF sections from the mmap base into the engine's retained
1041    /// fields.  `false` on all opens until first use; always `true` for non-V8
1042    /// opens (base is None, fast-path sets flag immediately).
1043    v8_sections_loaded: std::sync::atomic::AtomicBool,
1044    /// Serializes the one-time section population in `ensure_v8_base_sections_loaded`.
1045    v8_sections_mutex: std::sync::Mutex<()>,
1046    /// Per-node last-change commit sequence.  `last_change[node_id] = seq` means
1047    /// the node was last modified by commit `seq`.
1048    ///
1049    /// Loaded from V8 section 11 at open; updated on every state-changing commit
1050    /// and WAL replay frame.  V5-V7 stores start with an empty map; pre-WAL-horizon
1051    /// nodes return `None` from `last_changed` until they are next mutated.
1052    ///
1053    /// See [`Precondition`] for the full touch definition.
1054    last_change: HashMap<u32, u64>,
1055    /// WAL archive retention policy set by [`set_wal_archive_retention`].
1056    /// `None` = unlimited (keep all archives); `Some(N)` = keep N newest archives,
1057    /// pruning older ones at snapshot time.  0 is treated as unlimited.
1058    wal_archive_retention: Option<u32>,
1059    /// Global frame index of the first commit that is still reachable through
1060    /// surviving archives.  Persisted to `wal.floor` sidecar when pruning occurs.
1061    /// Default 0 = all history reachable.
1062    wal_horizon_floor: u64,
1063    /// True when the surviving archive chain forms a continuous WAL history
1064    /// starting from the store's first commit (the genesis chain).
1065    ///
1066    /// `open_at` may replay archive-resident commits from empty state only when
1067    /// this flag is true AND `wal_horizon_floor == 0`.  Cleared whenever:
1068    ///   - a WAL-truncating snapshot (`keep_wal=false`) is taken after archives
1069    ///     already exist (breaks the chain for subsequent archives), or
1070    ///   - any archive is pruned (floor advances past zero).
1071    ///
1072    /// Persisted via the `wal.genesis` marker file; loaded from it at open.
1073    archive_genesis_chain: bool,
1074    /// Transient write-authz context set by `write_batch_authz` /
1075    /// `query_write_authz` for the duration of ONE mutation call.
1076    /// Always `None` at rest.  Never serialized, never WAL-replayed.
1077    pending_write_authz: Option<WriteAuthz>,
1078}
1079
1080/// One group of deferred event notifications, held until the group fsync
1081/// completes.  Replayed by [`GraphDb::flush_deferred_events`].
1082struct DeferredEvent {
1083    rec: core_storage::WalRecord,
1084    engine_deltas: Vec<EngineEdgeDelta>,
1085    seq: u64,
1086    ingest: Option<(String, usize)>,
1087}
1088
1089/// Options for [`GraphDb::open_with_options`].
1090#[derive(Clone, Copy, Debug)]
1091pub struct OpenOptions {
1092    /// Rewrite an old-format snapshot to the current VERSION after a
1093    /// successful load (default `true`). The old snapshot is kept as
1094    /// `snapshot.bin.bak` until the next clean open at the current version,
1095    /// at which point the `.bak` is deleted.
1096    ///
1097    /// Set to `false` to open a store without touching any on-disk files
1098    /// (useful for read-only inspection of a store at an older format).
1099    pub auto_migrate: bool,
1100}
1101
1102impl Default for OpenOptions {
1103    fn default() -> Self {
1104        Self { auto_migrate: true }
1105    }
1106}
1107
1108/// Authorization context carried by `write_batch_authz` / `query_write_authz`.
1109///
1110/// `None` at the call site = full authority (today's zero-cost behavior).
1111/// `Some(WriteAuthz)` = role-scoped: the decision table (plan §"authz decision
1112/// table") is evaluated per-op inside `commit_logged_batch` BEFORE any WAL
1113/// record is built.  A denial returns an error with no WAL frame written.
1114///
1115/// The mask is ALWAYS `Omit`-mode: role-token paths must never acknowledge
1116/// hidden-node existence to callers.
1117#[derive(Clone, Debug)]
1118pub struct WriteAuthz {
1119    pub role: String,
1120    pub scope: WriteScope,
1121    /// Resolved by `mask_for_role` under the same write guard as the mutation.
1122    /// Always `Omit`-mode — never `Stub`.
1123    pub mask: crate::mask::NodeMask,
1124}
1125
1126/// Write `bytes` to `snapshot.bin.bak` atomically with full fsync.
1127///
1128/// Uses [`RealFs::write_atomic`] which applies `F_FULLFSYNC` on macOS and
1129/// `sync_all` on other platforms, then renames the `.tmp` file into place and
1130/// syncs the directory entry. This is the only correct path for writing the
1131/// `.bak` — plain `std::fs::write + sync_all` misses both `F_FULLFSYNC` and
1132/// the directory sync.
1133pub fn write_snapshot_bak(dir: &std::path::Path, bytes: &[u8]) -> crate::Result<()> {
1134    use core_storage::fs::{FileId, Fs as _};
1135    RealFs::new(dir)
1136        .map_err(core_storage::GraphError::Io)?
1137        .write_atomic(FileId::SnapshotBak, bytes)
1138        .map_err(core_storage::GraphError::Io)
1139}
1140
1141/// Return the on-disk snapshot format version without decoding the full snapshot.
1142///
1143/// Reads only the 6-byte header (magic + version LE). Returns `None` when no
1144/// snapshot file exists (WAL-only store). Returns an error if the header is
1145/// malformed.
1146pub fn snapshot_version_at(dir: &std::path::Path) -> crate::Result<Option<u16>> {
1147    use std::io::Read as _;
1148    let path = dir.join("snapshot.bin");
1149    let mut header = [0u8; 6];
1150    let n = match std::fs::File::open(&path) {
1151        Ok(mut f) => f.read(&mut header).map_err(core_storage::GraphError::Io)?,
1152        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
1153        Err(e) => return Err(core_storage::GraphError::Io(e)),
1154    };
1155    core_storage::snapshot::peek_version(&header[..n])
1156}
1157
1158/// Options for [`GraphDb::snapshot_with`].
1159#[derive(Debug, Clone, Default)]
1160pub struct SnapshotOptions {
1161    /// When `true`, the WAL is preserved after the snapshot write.
1162    /// Pre-snapshot commits remain reachable via [`GraphDb::open_at`].
1163    /// When `false` (the default), the WAL is truncated to a minimal
1164    /// baseline so cold-start replay stays fast.
1165    pub keep_wal: bool,
1166    /// When `true`, the current WAL is renamed to `wal.<commit_seq>.archive`
1167    /// before a fresh WAL baseline is written (history-preserving snapshot).
1168    ///
1169    /// This is the feature opt-in: `false` (the default) leaves the existing
1170    /// truncation / keep-wal behaviour byte-identical.  `archive_wal` takes
1171    /// precedence over `keep_wal` when both are set.
1172    ///
1173    /// Archives can be scanned by [`GraphDb::node_history`],
1174    /// [`GraphDb::edge_history`], [`GraphDb::was_linked`], and
1175    /// [`GraphDb::open_at`], extending the reachable history horizon across
1176    /// snapshot boundaries.
1177    pub archive_wal: bool,
1178}
1179
1180impl GraphDb<RealFs> {
1181    /// Open the database at `dir` with default options.
1182    ///
1183    /// Equivalent to `open_with_options(dir, OpenOptions::default())`.
1184    /// Old-format snapshots (V5, V6) are automatically migrated to the
1185    /// current version on a successful load (see [`OpenOptions::auto_migrate`]).
1186    pub fn open(dir: &std::path::Path) -> Result<Self> {
1187        Self::open_with_options(dir, OpenOptions::default())
1188    }
1189
1190    /// Open the database at `dir` with explicit options.
1191    ///
1192    /// When `opts.auto_migrate` is `true` (the default) and the on-disk
1193    /// snapshot is an older format version, this function:
1194    ///   1. Copies the current `snapshot.bin` to `snapshot.bin.bak` (atomic
1195    ///      + fsynced) before any modification.
1196    ///   2. Rewrites `snapshot.bin` at the current format version via
1197    ///      [`GraphDb::snapshot_with`] with `keep_wal: true` (WAL preserved).
1198    ///
1199    /// If migration fails the error is returned and the original files are
1200    /// intact (the `.bak` was written before the new snapshot was attempted).
1201    ///
1202    /// A clean open that finds the snapshot already at the current version
1203    /// deletes any leftover `.bak` file.
1204    ///
1205    /// WAL-only stores (no snapshot) are never auto-migrated on open.
1206    pub fn open_with_options(dir: &std::path::Path, opts: OpenOptions) -> Result<Self> {
1207        // Header-only peek — 6 bytes, no full decode.
1208        let snap_version = snapshot_version_at(dir)?;
1209
1210        // Full load: decode snapshot + replay WAL + rebuild indexes.
1211        let mut db = Self::open_with(RealFs::new(dir)?)?;
1212
1213        if opts.auto_migrate {
1214            match snap_version {
1215                Some(ver) if ver < core_storage::snapshot::VERSION => {
1216                    let _tm = std::time::Instant::now();
1217                    // Copy the original snapshot to .bak at OS level — no in-memory
1218                    // buffer required for a 2+ GiB file.
1219                    //
1220                    // Crash-safety: snapshot.bin remains intact (write_atomic inside
1221                    // snapshot_with uses a .tmp+rename) until the V8 write succeeds.
1222                    // A torn .bak on crash is acceptable because the original
1223                    // snapshot.bin is the authoritative source until after the rename.
1224                    std::fs::copy(dir.join("snapshot.bin"), dir.join("snapshot.bin.bak"))
1225                        .map_err(core_storage::GraphError::Io)?;
1226                    trace_migrate!("bak copy done", _tm);
1227                    // Rewrite snapshot at current version; keep WAL intact.
1228                    db.snapshot_with(SnapshotOptions {
1229                        keep_wal: true,
1230                        ..SnapshotOptions::default()
1231                    })?;
1232                    trace_migrate!("snapshot_with done", _tm);
1233                }
1234                Some(_) => {
1235                    // Already current version: remove any leftover .bak.
1236                    let bak = dir.join("snapshot.bin.bak");
1237                    if bak.exists() {
1238                        std::fs::remove_file(&bak).map_err(core_storage::GraphError::Io)?;
1239                    }
1240                }
1241                None => {
1242                    // WAL-only store — nothing to migrate on open.
1243                }
1244            }
1245        }
1246
1247        Ok(db)
1248    }
1249
1250    /// Open a read-only view of the database as it existed after `commit`.
1251    ///
1252    /// Commit indices are 0-based over the current WAL: commit 0 is the state
1253    /// after the first WAL frame, commit N-1 is the state after the N-th (most
1254    /// recent) frame.  Call [`GraphDb::open`] to read the full current state.
1255    ///
1256    /// **Replay base.** [`GraphDb::snapshot`] truncates the WAL when it runs,
1257    /// so as-of can only reach commits recorded in the current WAL (those
1258    /// written after the most recent snapshot, or all commits if no snapshot
1259    /// was ever taken).  Commit 0 in `open_at` always refers to the first
1260    /// frame in the WAL that exists on disk, not the first ever write to the
1261    /// database.  When the on-disk snapshot recorded that it truncated the
1262    /// WAL (V7, default `keep_wal: false`), it is loaded as the base state
1263    /// before frame replay, so the as-of view includes all pre-snapshot data.
1264    /// Snapshots written with `keep_wal: true` (and legacy V5/V6 snapshots)
1265    /// are ignored and replay is WAL-only, as before.
1266    ///
1267    /// **Read-only.** Every mutation method and `snapshot()` on the returned
1268    /// instance returns [`GraphError::ReadOnly`].  Queries, `explain()`, and
1269    /// `stats()` work normally.
1270    ///
1271    /// # Errors
1272    /// - [`GraphError::CommitOutOfRange`] if `commit >= wal_commit_count` (including
1273    ///   when the WAL is empty after a snapshot).
1274    pub fn open_at(dir: &std::path::Path, commit: u64) -> Result<Self> {
1275        Self::open_at_with(RealFs::new(dir)?, commit)
1276    }
1277}
1278
1279impl<F: Fs> GraphDb<F> {
1280    pub fn open_with(fs: F) -> Result<Self> {
1281        let mut db = Self {
1282            fs,
1283            ids: IdMap::new(),
1284            syms: Interner::new(),
1285            topo: Topology::new(),
1286            props: ColumnStore::new(),
1287            labels: Vec::new(),
1288            edge_props: EdgeProps::new(),
1289            engine: RuleEngine::new(),
1290            view_store: ViewStore::new(),
1291            fulltext: FulltextIndex::new(),
1292            event_sink: None,
1293            fsync: FsyncPolicy::Strict,
1294            commit_seq: 0,
1295            roles: Some(vec![]),
1296            subscriptions: Vec::new(),
1297            query_subscriptions: Vec::new(),
1298            sub_capacity: DEFAULT_SUB_CAPACITY,
1299            read_only: false,
1300            total_wal_commits: 0,
1301            base: None,
1302            fold_overlay: None,
1303            delta_tail: Vec::new(),
1304            commits_since_fold: 0,
1305            defer_events: false,
1306            deferred_events: Vec::new(),
1307            degraded: false,
1308            v8_sections_loaded: std::sync::atomic::AtomicBool::new(false),
1309            v8_sections_mutex: std::sync::Mutex::new(()),
1310            last_change: HashMap::new(),
1311            wal_archive_retention: None,
1312            wal_horizon_floor: 0,
1313            archive_genesis_chain: false,
1314            pending_write_authz: None,
1315        };
1316        db.wal_horizon_floor = db.fs.read_horizon_floor()?;
1317        db.archive_genesis_chain = db.fs.has_genesis_marker();
1318        // Opening cleanup: remove orphaned archives — archives whose frames all
1319        // fall below the horizon floor.  Orphans arise when a crash interrupted
1320        // the retention-prune sequence after the floor was written but before
1321        // all surplus archives were deleted.  Safe to delete: floor already
1322        // accounts for their frames.
1323        db.cleanup_orphaned_archives()?;
1324        let _t0 = std::time::Instant::now();
1325        // Peek 6 bytes to determine snapshot version without reading the full
1326        // file. For RealFs this is a true partial read (O(1)); for SimFs the
1327        // default impl reads all bytes and truncates (still correct).
1328        let snap_header = db.fs.read_prefix(FileId::Snapshot, 6)?;
1329        let is_v8 = snap_header.len() >= 6
1330            && &snap_header[0..4] == b"GDB1"
1331            && u16::from_le_bytes([snap_header[4], snap_header[5]])
1332                == core_storage::snapshot::VERSION_8;
1333        if is_v8 {
1334            // V8: map the file zero-copy (RealFs) or read full bytes (SimFs).
1335            // No 2.4GB heap Vec is allocated on RealFs.
1336            let mapped = Arc::new(
1337                if let Some(snap_path) = db.fs.snapshot_path() {
1338                    core_storage::v8::MappedBase::map(&snap_path)
1339                } else {
1340                    let snap_bytes = db.fs.read(FileId::Snapshot)?;
1341                    core_storage::v8::MappedBase::from_bytes(snap_bytes)
1342                }
1343                .map_err(|e| GraphError::Corrupt {
1344                    detail: format!("v8: mmap open: {e:?}"),
1345                })?,
1346            );
1347            db.restore_v8_base(Arc::clone(&mapped))?;
1348            trace_open!("restore_v8_base", _t0);
1349            db.base = Some(mapped);
1350            trace_open!("base assigned", _t0);
1351        } else if !snap_header.is_empty() {
1352            // Legacy V5-V7: full read required for decode.
1353            let snap_bytes = db.fs.read(FileId::Snapshot)?;
1354            if let Some(state) = core_storage::snapshot::decode(&snap_bytes)? {
1355                db.restore_snapshot_state(state)?;
1356            }
1357        }
1358        // else: snap_header is empty = no snapshot file, fresh store.
1359        //
1360        // Seed commit_seq from the highest seq persisted in last_change so that
1361        // WAL-replay frames (which start at commit_seq+1) always exceed any seq
1362        // already stored in the snapshot.  Without this, a db with one snapshot
1363        // commit would save last_change["a"]=1, then on reopen the first WAL
1364        // frame would replay at seq=1 again — colliding and making WAL-tail
1365        // mutations indistinguishable from the snapshot baseline.
1366        //
1367        // Safety invariant (seq-recycling):
1368        //   Recycled seqs (those below the seeded baseline) were NEVER stored in
1369        //   last_change because they belonged to a previous db lifetime — a new
1370        //   db starts at commit_seq=0 with an empty last_change.  Therefore no
1371        //   CAS precondition can carry a recycled seq as its `expected` value
1372        //   and accidentally match a live node's last_change entry.
1373        //
1374        // `expected:0` on a deleted-then-reinserted node:
1375        //   After deletion, last_changed() returns None; callers that call
1376        //   last_changed() and then use NodeUnchangedSince get None.unwrap_or(0)
1377        //   = 0.  The reinserted node gets seq > 0, so a subsequent CAS with
1378        //   expected=0 correctly conflicts.  The only way to observe actual=0 in
1379        //   a CasConflict would be a caller that invented expected=0 without ever
1380        //   calling last_changed() — unreachable via the documented API contract.
1381        if let Some(&max_seq) = db.last_change.values().max() {
1382            db.commit_seq = db.commit_seq.max(max_seq);
1383        }
1384        let bytes = db.fs.read(FileId::Wal)?;
1385        let (records, valid_len) = decode_all(&bytes);
1386        if valid_len < bytes.len() {
1387            db.fs.write_atomic(FileId::Wal, &bytes[..valid_len])?;
1388        }
1389        // WAL-present path: build indexes eagerly BEFORE replay so that the
1390        // first replayed record does not trigger the lazy-init guard (which
1391        // would call reindex_all_load_ivf on an empty graph, defeating the
1392        // point of restoring IVF/HNSW blobs from the snapshot).
1393        if !records.is_empty() {
1394            db.ensure_v8_base_sections_loaded();
1395            trace_open!("lazy sections loaded (WAL path)", _t0);
1396            db.engine.consume_retained_state_eager(
1397                &db.ids,
1398                &db.syms,
1399                &db.labels,
1400                build_props_view(&db.props, &db.base),
1401            );
1402        }
1403        for rec in records {
1404            db.apply(&rec)?;
1405            // Drain per-frame to keep pending_deltas O(1) during replay (I-2).
1406            // No subscriber exists yet; discard is correct.
1407            let _ = db.engine.drain_deltas();
1408            // Track commit_seq during replay so last_change entries are
1409            // consistent with the seqs assigned by log_then_apply_with on
1410            // subsequent live commits.  After N replayed frames, commit_seq=N;
1411            // live commits begin at N+1.
1412            db.commit_seq += 1;
1413            let replay_seq = db.commit_seq;
1414            db.update_last_change_from_rec(&rec, replay_seq);
1415        }
1416        // Enforce I-2: if the per-frame drain above is ever removed or skipped,
1417        // this assert catches the regression in debug builds immediately.
1418        debug_assert_eq!(
1419            db.engine.pending_delta_count(),
1420            0,
1421            "pending_deltas non-empty after replay — \
1422             per-frame drain must run inside the loop to keep memory O(1)"
1423        );
1424        // T2 note: the per-frame drain IS the suppression seam for replay.
1425        // Any future as-of replay path (Plan-15 T2) must drain here to feed
1426        // replaying subscribers; the mechanism is already in place.
1427        let _ = db.engine.drain_deltas(); // belt-and-braces no-op after loop drain
1428        trace_open!("wal replay done", _t0);
1429        // Rebuild view values after WAL replay only when there is no V8 base.
1430        // With a V8 base, view values are correct in the snapshot and are updated
1431        // incrementally during WAL replay (on_edge_changed / on_prop_changed).
1432        // A full rebuild would read overlay-only props (empty after restore_v8_base)
1433        // and overwrite correct base values with wrong results (e.g. NeighborAgg
1434        // Sum reads no "score" in overlay → writes 0.0, shadowing the correct
1435        // base value).
1436        if db.base.is_none() {
1437            let topo_view = TopologyView::owned(&db.topo);
1438            db.view_store
1439                .rebuild_all(&mut db.props, &topo_view, &db.ids, &db.syms, &db.labels);
1440        }
1441        // Rebuild full-text index after WAL replay.  Corrects drift from
1442        // per-record incremental apply during replay.
1443        db.fulltext.rebuild_all(
1444            &db.ids,
1445            &db.labels,
1446            &db.syms,
1447            build_props_view(&db.props, &db.base),
1448        );
1449        // Load roles sidecar. Missing file = no roles (Some(vec![])).
1450        // Corrupt/unparseable = poisoned (None); mask_for_role will fail-loud.
1451        db.roles = Self::load_roles_from_fs(&db.fs)?;
1452        // Capture the initial MVCC fold so reader() is ready immediately.
1453        db.fold_now();
1454        trace_open!("open_with complete", _t0);
1455        Ok(db)
1456    }
1457
1458    /// As-of replay for [`GraphDb::open_at`]: snapshot base (only when the
1459    /// snapshot truncated the WAL) plus the first `commit + 1` WAL frames;
1460    /// see [`GraphDb::open_at`] for the semantics.  The per-frame drain
1461    /// mirrors `open_with` exactly so pending_delta_count is 0 on exit.
1462    /// Restore all persisted state from a decoded snapshot. Shared by
1463    /// `open_with` and (when the snapshot truncated the WAL) `open_at_with`.
1464    fn restore_snapshot_state(
1465        &mut self,
1466        state: core_storage::snapshot::SnapshotState,
1467    ) -> Result<()> {
1468        self.ids = state.ids;
1469        self.syms = state.syms;
1470        self.topo = state.topo;
1471        self.props = state.props;
1472        self.labels = state.labels;
1473        self.edge_props = state.edge_props;
1474        // Cross-section label integrity for V5/V7 snapshots: same invariants as
1475        // restore_v8_base.  A crafted bincode snapshot with a short `labels` vec,
1476        // out-of-range sym ids, or a sentinel label on a live node would otherwise
1477        // open successfully and panic later in `NodeRef::label()` or
1478        // `neighborhood_masked()`.  Catching it here turns those into typed
1479        // `GraphError::Corrupt` at open time.
1480        {
1481            let ids_len = self.ids.len();
1482            if self.labels.len() != ids_len {
1483                return Err(GraphError::Corrupt {
1484                    detail: format!(
1485                        "snapshot: labels vec has {} entries but id table has {} total slots",
1486                        self.labels.len(),
1487                        ids_len,
1488                    ),
1489                });
1490            }
1491            let syms_len = self.syms.len() as u32;
1492            for (i, &sym) in self.labels.iter().enumerate() {
1493                let is_tombstoned = self.ids.is_tombstoned(i as u32);
1494                if sym == u32::MAX {
1495                    if !is_tombstoned {
1496                        return Err(GraphError::Corrupt {
1497                            detail: format!(
1498                                "snapshot: live node at id slot {i} has sentinel label (u32::MAX)"
1499                            ),
1500                        });
1501                    }
1502                } else if sym >= syms_len {
1503                    return Err(GraphError::Corrupt {
1504                        detail: format!(
1505                            "snapshot: label at id slot {i} references sym {sym} \
1506                             which is out of interner range ({syms_len})"
1507                        ),
1508                    });
1509                }
1510            }
1511        }
1512        let defs: Vec<RuleDef> = state
1513            .rule_defs
1514            .iter()
1515            .map(|b| {
1516                decode_rule_def(b).map_err(|e| GraphError::Corrupt {
1517                    detail: format!("snapshot rule_def deserialize: {e}"),
1518                })
1519            })
1520            .collect::<Result<Vec<_>>>()?;
1521        self.engine =
1522            RuleEngine::from_persist(defs, state.provenance, state.rule_tripped, state.rule_fires);
1523        // Candidate indexes are rebuilt lazily on the first mutation (see
1524        // RuleEngine::on_node_changed).  HNSW blobs and IVF centroids from the
1525        // snapshot are retained without deserializing so that:
1526        //   - clean-open (empty WAL): indexes stay empty; blobs load on first
1527        //     ANN query via ensure_hnsw_loaded, or on first mutation via the
1528        //     lazy-init guard which calls reindex_all_load_ivf + load_hnsw_state.
1529        //   - WAL-present: open_with calls consume_retained_state_eager before
1530        //     replay so HNSW/IVF are live before any record fires the hooks.
1531        let ivf_bytes = if state.ivf_state.is_empty() {
1532            Vec::new()
1533        } else {
1534            bincode::serialize(&state.ivf_state).expect("IVF state serialize cannot fail")
1535        };
1536        // Store blobs without eagerly deserializing them.
1537        self.engine
1538            .store_snapshot_state(state.hnsw_state, ivf_bytes);
1539        // Restore view defs from snapshot (V5).
1540        // The ColumnStore already contains view values from the snapshot;
1541        // use restore_view (no collision check, no backfill) so the store
1542        // is aware of the definitions.  rebuild_all runs after WAL replay.
1543        for def_bytes in &state.view_defs {
1544            let def: ViewDef =
1545                bincode::deserialize(def_bytes).map_err(|e| GraphError::Corrupt {
1546                    detail: format!("snapshot view_def deserialize: {e}"),
1547                })?;
1548            self.view_store
1549                .restore_view(def)
1550                .map_err(|e| GraphError::Corrupt {
1551                    detail: format!("snapshot view restore: {e}"),
1552                })?;
1553        }
1554        Ok(())
1555    }
1556
1557    /// Restore all persisted state from a V8 `MappedBase` snapshot, **except**
1558    /// topology (`self.topo` stays empty and serves as the WAL-replay overlay).
1559    ///
1560    /// `self.props` IS fully materialised from the base so that HNSW/IVF blob
1561    /// deserialization and view rebuild have access to all column data.
1562    fn restore_v8_base(&mut self, mapped: Arc<core_storage::v8::MappedBase>) -> Result<()> {
1563        self.ids = archived_to_idmap(mapped.ids().map_err(|e| GraphError::Corrupt {
1564            detail: format!("v8: ids section: {e:?}"),
1565        })?);
1566        self.syms = archived_to_interner(mapped.syms().map_err(|e| GraphError::Corrupt {
1567            detail: format!("v8: syms section: {e:?}"),
1568        })?);
1569
1570        // C1: self.props is left as an empty overlay. Column reads go through
1571        // props_view() (ColumnsView::with_base), which consults the archived base
1572        // section zero-copy. This avoids the O(columns) heap copy at every open.
1573
1574        // self.topo deliberately left as Topology::new() — overlay path.
1575
1576        let meta = decode_meta(mapped.meta_bytes().map_err(|e| GraphError::Corrupt {
1577            detail: format!("v8: meta section: {e:?}"),
1578        })?)
1579        .map_err(|e| GraphError::Corrupt {
1580            detail: format!("v8: meta decode: {e:?}"),
1581        })?;
1582        self.labels = meta.labels;
1583        // Cross-section label integrity: labels must cover every id slot (live
1584        // and tombstoned), every non-sentinel sym must be within the interner's
1585        // bound, and no live (non-tombstoned) node may carry the u32::MAX
1586        // sentinel label.  Without this check, a crafted snapshot where the META
1587        // section (small, CRC-validated) holds a short `labels` vec, out-of-range
1588        // sym ids, or a sentinel label on a live node, would open successfully
1589        // and then panic in `NodeRef::label()`, `neighborhood_masked()`, and
1590        // related read paths.  Catching the inconsistency here converts those
1591        // panics into typed `GraphError::Corrupt` at open time.
1592        {
1593            let ids_len = self.ids.len();
1594            if self.labels.len() != ids_len {
1595                return Err(GraphError::Corrupt {
1596                    detail: format!(
1597                        "v8: labels section has {} entries but id table has {} total slots",
1598                        self.labels.len(),
1599                        ids_len,
1600                    ),
1601                });
1602            }
1603            let syms_len = self.syms.len() as u32;
1604            for (i, &sym) in self.labels.iter().enumerate() {
1605                let is_tombstoned = self.ids.is_tombstoned(i as u32);
1606                if sym == u32::MAX {
1607                    // Sentinel is only valid for tombstoned slots.
1608                    if !is_tombstoned {
1609                        return Err(GraphError::Corrupt {
1610                            detail: format!(
1611                                "v8: live node at id slot {i} has sentinel label (u32::MAX)"
1612                            ),
1613                        });
1614                    }
1615                } else if sym >= syms_len {
1616                    return Err(GraphError::Corrupt {
1617                        detail: format!(
1618                            "v8: label at id slot {i} references sym {sym} \
1619                             which is out of interner range ({syms_len})"
1620                        ),
1621                    });
1622                }
1623            }
1624        }
1625        // C3: self.edge_props stays as an empty overlay.  Reads go through
1626        // edge_props_view() which consults the mmap'd base section zero-copy
1627        // via EdgePropsView::with_base.  No heap decode at open time.
1628
1629        // Restore rule engine.
1630        let (rule_def_bytes, rule_tripped, rule_fires) =
1631            archived_rules_meta_to_owned(mapped.rules_meta_section().map_err(|e| {
1632                GraphError::Corrupt {
1633                    detail: format!("v8: rules_meta section: {e:?}"),
1634                }
1635            })?);
1636        let defs: Vec<RuleDef> = rule_def_bytes
1637            .iter()
1638            .map(|b| {
1639                decode_rule_def(b).map_err(|e| GraphError::Corrupt {
1640                    detail: format!("v8: rule_def deserialize: {e}"),
1641                })
1642            })
1643            .collect::<Result<Vec<_>>>()?;
1644        self.engine = RuleEngine::from_persist(defs, BTreeMap::new(), rule_tripped, rule_fires);
1645        // C4+C5: provenance, HNSW, and IVF sections are NOT read here.
1646        // `ensure_v8_base_sections_loaded` reads them on first use from
1647        // `self.base` (set by the caller immediately after this returns).
1648        // A clean open touches only: header + IDS + SYMS + META + RULES_META.
1649
1650        // Restore view definitions.
1651        let view_defs =
1652            archived_views_to_owned(mapped.views_section().map_err(|e| GraphError::Corrupt {
1653                detail: format!("v8: views section: {e:?}"),
1654            })?);
1655        for def_bytes in &view_defs {
1656            let def: ViewDef =
1657                bincode::deserialize(def_bytes).map_err(|e| GraphError::Corrupt {
1658                    detail: format!("v8: view_def deserialize: {e}"),
1659                })?;
1660            self.view_store
1661                .restore_view(def)
1662                .map_err(|e| GraphError::Corrupt {
1663                    detail: format!("v8: view restore: {e}"),
1664                })?;
1665        }
1666        // Load the last-change map from section 11 (small section; load eagerly).
1667        // Pre-Task-3 snapshots lack this section; `last_change_bytes` returns &[]
1668        // in that case and `decode_last_change_bytes` returns an empty map.
1669        let last_change_raw = mapped
1670            .last_change_bytes()
1671            .map_err(|e| GraphError::Corrupt {
1672                detail: format!("v8: last_change section: {e:?}"),
1673            })?;
1674        self.last_change = decode_last_change_bytes(last_change_raw);
1675
1676        // Validate that all deferred sections (provenance, HNSW, IVF) fit within
1677        // the file.  Pure bounds check — no bytes read, no page faults triggered.
1678        // Catches truncated snapshots at open time before the lazy deferred reads.
1679        mapped.validate_section_bounds().map_err(|e| match e {
1680            GraphError::Corrupt { detail } => GraphError::Corrupt {
1681                detail: format!("v8: section bounds: {detail}"),
1682            },
1683            other => other,
1684        })?;
1685        Ok(())
1686    }
1687
1688    /// Read provenance, HNSW, and IVF sections from the mmap base into the
1689    /// engine's retained fields on first call.  Subsequent calls are a no-op
1690    /// (AtomicBool fast-path).
1691    ///
1692    /// Must be called before any code path that reads or mutates engine
1693    /// provenance, HNSW, or IVF state:
1694    /// - WAL replay (before `consume_retained_state_eager`)
1695    /// - First mutation (`log_then_apply_with`)
1696    /// - Read-only paths (`stats`, `explain`, `node_edges`)
1697    /// - Snapshot (`snapshot_with`)
1698    ///
1699    /// No-op for fresh stores and V5-V7 opens (`self.base` is `None`).
1700    fn ensure_v8_base_sections_loaded(&self) {
1701        use std::sync::atomic::Ordering;
1702        if self.v8_sections_loaded.load(Ordering::Acquire) {
1703            return;
1704        }
1705        let _guard = self
1706            .v8_sections_mutex
1707            .lock()
1708            .expect("v8 sections mutex poisoned");
1709        if self.v8_sections_loaded.load(Ordering::Acquire) {
1710            return; // another caller populated while we waited
1711        }
1712        let _t = std::time::Instant::now();
1713        if let Some(base) = &self.base {
1714            // Provenance: raw rkyv bytes; CRC validated inside section_bytes.
1715            // Bounds are already validated at open time (restore_v8_base →
1716            // validate_section_bounds) — unreachable post-validate_section_bounds;
1717            // unwrap_or_default is a safety belt against impossible errors.
1718            let prov_bytes = base
1719                .provenance_raw_bytes()
1720                .map(|b| b.to_vec())
1721                .unwrap_or_default();
1722            self.engine.store_provenance_bytes(prov_bytes);
1723            // HNSW: decode rkyv blobs into owned map.
1724            let hnsw_state = base
1725                .hnsw_section()
1726                .map(archived_hnsw_to_owned)
1727                .unwrap_or_default();
1728            // IVF: raw bincode bytes; deserialized on first mutation/query.
1729            let ivf_bytes = base.ivf_bytes().map(|b| b.to_vec()).unwrap_or_default();
1730            self.engine.store_snapshot_state(hnsw_state, ivf_bytes);
1731        }
1732        self.v8_sections_loaded.store(true, Ordering::Release);
1733        if std::env::var("MUSHROOMDB_TRACE_OPEN").is_ok() {
1734            eprintln!(
1735                "[MUSHROOMDB_TRACE_OPEN] ensure_v8_base_sections_loaded: {:>9.3?}",
1736                _t.elapsed()
1737            );
1738        }
1739    }
1740
1741    /// Return a `TopologyView` that merges the mmap'd base (when present) with
1742    /// the in-memory WAL overlay.  Used by all read paths in db.rs that need
1743    /// the full merged topology without going through `self.view()`.
1744    fn topo_view(&self) -> TopologyView<'_> {
1745        match self.base {
1746            None => TopologyView::owned(&self.topo),
1747            Some(ref base) => {
1748                // SAFETY: base lives as long as self; section bounds validated at open.
1749                // topology() uses access_unchecked; all field reads are bounds-checked in seam.rs.
1750                let archived = base
1751                    .topology()
1752                    .expect("base topology section bounds validated at open");
1753                TopologyView::with_base(&self.topo, archived)
1754            }
1755        }
1756    }
1757
1758    /// Return a `ColumnsView` that merges the mmap'd base columns (when a V8
1759    /// snapshot is open) with the in-memory WAL overlay.  Reads consult the
1760    /// overlay first, then fall through to the archived base section zero-copy.
1761    fn props_view(&self) -> core_storage::v8::seam::ColumnsView<'_> {
1762        match self.base {
1763            None => core_storage::v8::seam::ColumnsView::owned(&self.props),
1764            Some(ref base) => {
1765                // columns() uses access_unchecked; field reads are bounds-checked in seam.rs.
1766                let archived = base
1767                    .columns()
1768                    .expect("base columns section bounds validated at open");
1769                core_storage::v8::seam::ColumnsView::with_base(&self.props, archived)
1770            }
1771        }
1772    }
1773
1774    /// Return an `EdgePropsView` that merges the mmap'd base edge-props section
1775    /// (when a V8 snapshot is open) with the in-memory WAL overlay.
1776    ///
1777    /// Reads consult the overlay first (for post-snapshot mutations), then fall
1778    /// through to the archived base section zero-copy.  Tombstones in the
1779    /// overlay mask deleted-from-base entries.
1780    fn edge_props_view(&self) -> EdgePropsView<'_> {
1781        match self.base {
1782            None => EdgePropsView::owned(&self.edge_props),
1783            Some(ref base) => {
1784                // edge_props_section() uses access_unchecked; field reads bounds-checked in seam.rs.
1785                let archived = base
1786                    .edge_props_section()
1787                    .expect("base edge_props section bounds validated at open");
1788                EdgePropsView::with_base(&self.edge_props, archived)
1789            }
1790        }
1791    }
1792
1793    fn open_at_with(fs: F, commit: u64) -> Result<Self> {
1794        let mut db = Self {
1795            fs,
1796            ids: IdMap::new(),
1797            syms: Interner::new(),
1798            topo: Topology::new(),
1799            props: ColumnStore::new(),
1800            labels: Vec::new(),
1801            edge_props: EdgeProps::new(),
1802            engine: RuleEngine::new(),
1803            view_store: ViewStore::new(),
1804            fulltext: FulltextIndex::new(),
1805            event_sink: None,
1806            fsync: FsyncPolicy::Strict,
1807            commit_seq: 0,
1808            roles: Some(vec![]),
1809            subscriptions: Vec::new(),
1810            query_subscriptions: Vec::new(),
1811            sub_capacity: DEFAULT_SUB_CAPACITY,
1812            read_only: false, // set to true after replay
1813            total_wal_commits: 0,
1814            base: None,
1815            fold_overlay: None,
1816            delta_tail: Vec::new(),
1817            commits_since_fold: 0,
1818            defer_events: false,
1819            deferred_events: Vec::new(),
1820            degraded: false,
1821            v8_sections_loaded: std::sync::atomic::AtomicBool::new(false),
1822            v8_sections_mutex: std::sync::Mutex::new(()),
1823            last_change: HashMap::new(),
1824            wal_archive_retention: None,
1825            wal_horizon_floor: 0,
1826            archive_genesis_chain: false,
1827            pending_write_authz: None,
1828        };
1829        db.wal_horizon_floor = db.fs.read_horizon_floor()?;
1830        db.archive_genesis_chain = db.fs.has_genesis_marker();
1831        // Same orphaned-archive cleanup as open_with: floor was written first
1832        // during pruning, so a crash may have left stale archives below floor.
1833        db.cleanup_orphaned_archives()?;
1834        // Collect archive frames (oldest-first) and live WAL frames.
1835        // Archives represent pre-snapshot history; the snapshot captures the
1836        // cumulative state at the time of archiving.  Crash-window guarantee:
1837        //   A: crash before rename → WAL intact, no archive. Reopen: normal.
1838        //   B: crash after rename, before new WAL → archive present, WAL
1839        //      absent. Reopen: snapshot loaded (full state), no WAL replay.
1840        //   C: crash after new baseline WAL written → normal post-archive.
1841        let archive_ns = db.fs.list_archives()?;
1842        let mut archive_frames_all: Vec<WalRecord> = Vec::new();
1843        for n in &archive_ns {
1844            let arc_bytes = db.fs.read_archive(*n)?;
1845            let (arc_frames, _) = decode_all(&arc_bytes);
1846            archive_frames_all.extend(arc_frames);
1847        }
1848        let total_archive_frames = archive_frames_all.len() as u64;
1849
1850        let live_bytes = db.fs.read(FileId::Wal)?;
1851        let (live_records, _valid_len) = decode_all(&live_bytes);
1852        let total_surviving = total_archive_frames + live_records.len() as u64;
1853        // Global total including any pruned history below the horizon floor.
1854        let total = db.wal_horizon_floor + total_surviving;
1855
1856        // Horizon and range check.
1857        if commit < db.wal_horizon_floor {
1858            return Err(GraphError::CommitOutOfRange { commit, total });
1859        }
1860        if commit >= total {
1861            return Err(GraphError::CommitOutOfRange { commit, total });
1862        }
1863
1864        // Local index into surviving frames (0 = first frame of oldest archive).
1865        let local = commit - db.wal_horizon_floor;
1866
1867        if local < total_archive_frames {
1868            // Target commit is in an archive.  Correct replay from empty state
1869            // is only possible when the archive chain is an uninterrupted
1870            // genesis chain (first archive taken from a fresh store, no prior
1871            // WAL truncation) and no archives have been pruned (floor == 0).
1872            //
1873            // If either condition is violated the prefix needed to reconstruct
1874            // the requested state is gone; refuse rather than return wrong data.
1875            if db.wal_horizon_floor > 0 || !db.archive_genesis_chain {
1876                return Err(GraphError::CommitOutOfRange { commit, total });
1877            }
1878            // Replay all archive frames up to and including the target commit
1879            // from an empty database state.  Archives must be replayed in order
1880            // so that dense-id intern tables are built up correctly.
1881            for rec in archive_frames_all.into_iter().take((local + 1) as usize) {
1882                db.apply(&rec)?;
1883                let _ = db.engine.drain_deltas();
1884            }
1885        } else {
1886            // Target commit is in the live WAL: load snapshot as base, then
1887            // replay the needed live WAL prefix.
1888            //
1889            // Base state: a truncating snapshot (wal_truncated=true) compacts
1890            // all pre-truncation / pre-archive commits.  Dense-id records in
1891            // the live WAL reference ids/interns that the snapshot provides.
1892            // Peek 6 bytes (same pattern as open_with).
1893            let snap_header = db.fs.read_prefix(FileId::Snapshot, 6)?;
1894            let is_v8 = snap_header.len() >= 6
1895                && &snap_header[0..4] == b"GDB1"
1896                && u16::from_le_bytes([snap_header[4], snap_header[5]])
1897                    == core_storage::snapshot::VERSION_8;
1898            if is_v8 {
1899                let state = if let Some(snap_path) = db.fs.snapshot_path() {
1900                    let mapped = core_storage::v8::MappedBase::map(&snap_path).map_err(|e| {
1901                        GraphError::Corrupt {
1902                            detail: format!("v8: open_at mmap: {e:?}"),
1903                        }
1904                    })?;
1905                    core_storage::snapshot::decode_v8_from_mapped(&mapped)?
1906                } else {
1907                    let snap_bytes = db.fs.read(FileId::Snapshot)?;
1908                    core_storage::snapshot::decode(&snap_bytes)?
1909                };
1910                if let Some(state) = state {
1911                    if state.wal_truncated {
1912                        db.restore_snapshot_state(state)?;
1913                    }
1914                }
1915            } else if !snap_header.is_empty() {
1916                let snap_bytes = db.fs.read(FileId::Snapshot)?;
1917                if let Some(state) = core_storage::snapshot::decode(&snap_bytes)? {
1918                    if state.wal_truncated {
1919                        db.restore_snapshot_state(state)?;
1920                    }
1921                }
1922            }
1923            // else: snap_header empty = no snapshot file.
1924            let live_local = local - total_archive_frames;
1925            for rec in live_records.into_iter().take((live_local + 1) as usize) {
1926                db.apply(&rec)?;
1927                let _ = db.engine.drain_deltas();
1928            }
1929        }
1930        // Pin: pending_delta_count must be 0 after as-of replay, mirroring T1's
1931        // post-loop assert in open_with.
1932        debug_assert_eq!(
1933            db.engine.pending_delta_count(),
1934            0,
1935            "pending_deltas non-empty after open_at replay — \
1936             per-frame drain must run inside the loop to keep memory O(1)"
1937        );
1938        let _ = db.engine.drain_deltas(); // belt-and-braces no-op
1939                                          // Rebuild view values after WAL replay so derived-edge-driven views
1940                                          // reflect the as-of state.  open_at always uses the legacy path (no V8
1941                                          // base), so topo_view is always owned.
1942        {
1943            let topo_view = TopologyView::owned(&db.topo);
1944            db.view_store
1945                .rebuild_all(&mut db.props, &topo_view, &db.ids, &db.syms, &db.labels);
1946        }
1947        // Rebuild full-text index for as-of view (mirrors open_with pattern).
1948        db.fulltext.rebuild_all(
1949            &db.ids,
1950            &db.labels,
1951            &db.syms,
1952            build_props_view(&db.props, &db.base),
1953        );
1954        // Load roles sidecar (current roles, not point-in-time).
1955        db.roles = Self::load_roles_from_fs(&db.fs)?;
1956        db.read_only = true;
1957        db.total_wal_commits = total;
1958        // Capture initial fold so reader() is immediately usable.
1959        db.fold_now();
1960        Ok(db)
1961    }
1962
1963    /// Whether this instance is a read-only as-of view.
1964    pub fn is_read_only(&self) -> bool {
1965        self.read_only
1966    }
1967
1968    // ── MVCC epoch reader ─────────────────────────────────────────────────────
1969
1970    /// Clone the current overlay state into a new `FrozenOverlay` and reset
1971    /// the delta tail. Called automatically every `FOLD_EVERY_K` commits and at
1972    /// the end of `open_with` / `open_at_with` to prime the reader.
1973    fn fold_now(&mut self) {
1974        let frozen = crate::reader::FrozenOverlay {
1975            ids: self.ids.clone(),
1976            syms: self.syms.clone(),
1977            topo: self.topo.clone(),
1978            props: self.props.clone(),
1979            labels: self.labels.clone(),
1980            edge_props: self.edge_props.clone(),
1981            roles: self.roles.clone(),
1982            fulltext: self.fulltext.clone(),
1983        };
1984        self.fold_overlay = Some(Arc::new(frozen));
1985        self.delta_tail.clear();
1986        self.commits_since_fold = 0;
1987    }
1988
1989    /// Capture a lock-free reader snapshot of the current db state.
1990    ///
1991    /// The read lock is held only for the duration of this call (to clone a
1992    /// handful of `Arc` handles). Subsequent query operations run without any
1993    /// lock.
1994    pub fn reader(&self) -> crate::reader::ReaderSnapshot {
1995        crate::reader::ReaderSnapshot::new(
1996            self.fold_overlay
1997                .clone()
1998                .expect("fold_overlay is always Some after open_with; call reader() after open"),
1999            self.base.clone(),
2000            self.delta_tail.clone(),
2001        )
2002    }
2003
2004    /// Total number of WAL commits at the time [`open_at`] was called.
2005    /// Returns 0 for normal (non-as-of) instances.
2006    pub fn total_wal_commits(&self) -> u64 {
2007        self.total_wal_commits
2008    }
2009
2010    /// Apply a record to in-memory state. Used by both live writes and replay,
2011    /// so replay is definitionally identical to the original execution.
2012    fn apply(&mut self, rec: &WalRecord) -> Result<()> {
2013        match rec {
2014            WalRecord::InsertNode { label, key, props } => {
2015                let id = self.ids.try_insert(key)?;
2016                let sym = self.syms.intern(label);
2017                if self.labels.len() <= id as usize {
2018                    // gap slots are sentinels, never valid label symbols
2019                    self.labels.resize(id as usize + 1, u32::MAX);
2020                }
2021                self.labels[id as usize] = sym;
2022                for (field, value) in props {
2023                    self.props.set(id, field, value.clone());
2024                }
2025                // Initialize view values for the new node before the engine runs so
2026                // delta-based increments start from a known zero baseline.
2027                self.view_store
2028                    .init_node_views(id, &mut self.props, &self.syms, &self.labels);
2029                // Fire rules for the newly inserted node.
2030                let cursor = self.engine.pending_delta_count();
2031                let mut eng = std::mem::take(&mut self.engine);
2032                {
2033                    let mut gm = make_graph_mut(
2034                        &self.ids,
2035                        &mut self.syms,
2036                        &self.labels,
2037                        build_props_view(&self.props, &self.base),
2038                        &mut self.topo,
2039                        &mut self.edge_props,
2040                    );
2041                    eng.on_node_changed(id, None, &mut gm);
2042                }
2043                self.engine = eng;
2044                // Process derived-edge deltas for view maintenance.
2045                // Fast path: skip the O(delta_count) allocation when no views exist.
2046                if !self.view_store.is_empty() {
2047                    #[cfg(test)]
2048                    DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
2049                    let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
2050                    for d in &new_deltas {
2051                        self.view_store.on_edge_changed(
2052                            d.etype_sym,
2053                            d.src_id,
2054                            d.dst_id,
2055                            d.fired,
2056                            &mut self.props,
2057                            &build_topo_view(&self.topo, &self.base),
2058                            &self.ids,
2059                            &self.syms,
2060                            &self.labels,
2061                            self.base.as_ref().map(|b| {
2062                                b.columns()
2063                                    .expect("base columns section bounds validated at open")
2064                            }),
2065                        );
2066                    }
2067                }
2068                // Full-text index maintenance: index enabled fields for this label.
2069                if self.fulltext.has_label(label) {
2070                    for (field, value) in props {
2071                        if self.fulltext.is_enabled(label, field) {
2072                            self.fulltext.add_tokens(id, field, value);
2073                        }
2074                    }
2075                }
2076            }
2077            WalRecord::InsertEdge {
2078                edge_type,
2079                src_key,
2080                dst_key,
2081            } => {
2082                let src = self.ids.get(src_key).ok_or_else(|| GraphError::Corrupt {
2083                    detail: format!("wal replay references unknown key {src_key}"),
2084                })?;
2085                let dst = self.ids.get(dst_key).ok_or_else(|| GraphError::Corrupt {
2086                    detail: format!("wal replay references unknown key {dst_key}"),
2087                })?;
2088                let etype = self.syms.intern(edge_type);
2089                // Skip if the edge is already visible in the merged base+overlay
2090                // view.  This keeps WAL replay idempotent when the WAL contains
2091                // pre-snapshot records that are already encoded in a V8 base
2092                // (keep_wal=true opens and crash-before-truncation scenarios).
2093                if self.base.is_some()
2094                    && self
2095                        .topo_view()
2096                        .neighbors(etype, Direction::Out, src)
2097                        .contains(&dst)
2098                {
2099                    return Ok(());
2100                }
2101                self.topo.add_edge(etype, src, dst);
2102                // View maintenance for manual edge insert.
2103                self.view_store.on_edge_changed(
2104                    etype,
2105                    src,
2106                    dst,
2107                    true,
2108                    &mut self.props,
2109                    &build_topo_view(&self.topo, &self.base),
2110                    &self.ids,
2111                    &self.syms,
2112                    &self.labels,
2113                    self.base.as_ref().map(|b| {
2114                        b.columns()
2115                            .expect("base columns section bounds validated at open")
2116                    }),
2117                );
2118                // Rule engine: via-hop rules must update when user edges change.
2119                let cursor = self.engine.pending_delta_count();
2120                let mut eng = std::mem::take(&mut self.engine);
2121                {
2122                    let mut gm = make_graph_mut(
2123                        &self.ids,
2124                        &mut self.syms,
2125                        &self.labels,
2126                        build_props_view(&self.props, &self.base),
2127                        &mut self.topo,
2128                        &mut self.edge_props,
2129                    );
2130                    eng.on_edge_changed(edge_type, src, dst, &mut gm);
2131                }
2132                self.engine = eng;
2133                if !self.view_store.is_empty() {
2134                    let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
2135                    for d in &new_deltas {
2136                        self.view_store.on_edge_changed(
2137                            d.etype_sym,
2138                            d.src_id,
2139                            d.dst_id,
2140                            d.fired,
2141                            &mut self.props,
2142                            &build_topo_view(&self.topo, &self.base),
2143                            &self.ids,
2144                            &self.syms,
2145                            &self.labels,
2146                            self.base.as_ref().map(|b| {
2147                                b.columns()
2148                                    .expect("base columns section bounds validated at open")
2149                            }),
2150                        );
2151                    }
2152                }
2153            }
2154            WalRecord::SetProp { key, field, value } => {
2155                let id = self.ids.get(key).ok_or_else(|| GraphError::Corrupt {
2156                    detail: format!("wal replay references unknown key {key}"),
2157                })?;
2158                let old_value = build_props_view(&self.props, &self.base)
2159                    .get(id, field)
2160                    .map(|vr| vr.into_value());
2161                self.props.set(id, field, value.clone());
2162                // Fire rules for the changed field.
2163                let cursor = self.engine.pending_delta_count();
2164                let mut eng = std::mem::take(&mut self.engine);
2165                {
2166                    let mut gm = make_graph_mut(
2167                        &self.ids,
2168                        &mut self.syms,
2169                        &self.labels,
2170                        build_props_view(&self.props, &self.base),
2171                        &mut self.topo,
2172                        &mut self.edge_props,
2173                    );
2174                    eng.on_node_changed(id, Some((field, old_value)), &mut gm);
2175                }
2176                self.engine = eng;
2177                // Derived-edge deltas → view updates.
2178                if !self.view_store.is_empty() {
2179                    #[cfg(test)]
2180                    DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
2181                    let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
2182                    for d in &new_deltas {
2183                        self.view_store.on_edge_changed(
2184                            d.etype_sym,
2185                            d.src_id,
2186                            d.dst_id,
2187                            d.fired,
2188                            &mut self.props,
2189                            &build_topo_view(&self.topo, &self.base),
2190                            &self.ids,
2191                            &self.syms,
2192                            &self.labels,
2193                            self.base.as_ref().map(|b| {
2194                                b.columns()
2195                                    .expect("base columns section bounds validated at open")
2196                            }),
2197                        );
2198                    }
2199                }
2200                // Neighbor-aggregate views that read `field` must also update.
2201                self.view_store.on_prop_changed(
2202                    id,
2203                    field,
2204                    &mut self.props,
2205                    &build_topo_view(&self.topo, &self.base),
2206                    &self.ids,
2207                    &self.syms,
2208                    &self.labels,
2209                    self.base.as_ref().map(|b| {
2210                        b.columns()
2211                            .expect("base columns section bounds validated at open")
2212                    }),
2213                );
2214                // Full-text index maintenance: update tokens for this field if indexed.
2215                if self.fulltext.field_indexed(field) {
2216                    let label_opt = self.labels.get(id as usize).and_then(|&sym| {
2217                        if sym == u32::MAX {
2218                            None
2219                        } else {
2220                            self.syms.resolve(sym)
2221                        }
2222                    });
2223                    if let Some(label) = label_opt {
2224                        if self.fulltext.is_enabled(label, field) {
2225                            self.fulltext.remove_node_field(id, field);
2226                            self.fulltext.add_tokens(id, field, value);
2227                        }
2228                    }
2229                }
2230            }
2231            WalRecord::Intern { id, text } => {
2232                if let Some(existing) = self.syms.get(text) {
2233                    if existing != *id {
2234                        return Err(GraphError::Corrupt {
2235                            detail: format!(
2236                                "wal intern mismatch for {text:?}: have {existing}, record {id}"
2237                            ),
2238                        });
2239                    }
2240                } else {
2241                    let got = self.syms.intern(text);
2242                    if got != *id {
2243                        return Err(GraphError::Corrupt {
2244                            detail: format!(
2245                                "wal intern assigned {got} for {text:?}, record wanted {id}"
2246                            ),
2247                        });
2248                    }
2249                }
2250            }
2251            WalRecord::InsertNodeId { label, key, props } => {
2252                let id = self.ids.try_insert(key)?;
2253                if self.labels.len() <= id as usize {
2254                    self.labels.resize(id as usize + 1, u32::MAX);
2255                }
2256                self.labels[id as usize] = *label;
2257                let label_str = self
2258                    .syms
2259                    .resolve(*label)
2260                    .ok_or_else(|| GraphError::Corrupt {
2261                        detail: format!("wal InsertNodeId unknown label intern {label}"),
2262                    })?
2263                    .to_string();
2264                for (field_sym, value) in props {
2265                    let field =
2266                        self.syms
2267                            .resolve(*field_sym)
2268                            .ok_or_else(|| GraphError::Corrupt {
2269                                detail: format!(
2270                                    "wal InsertNodeId unknown field intern {field_sym}"
2271                                ),
2272                            })?;
2273                    self.props.set(id, field, value.clone());
2274                }
2275                self.view_store
2276                    .init_node_views(id, &mut self.props, &self.syms, &self.labels);
2277                let cursor = self.engine.pending_delta_count();
2278                let mut eng = std::mem::take(&mut self.engine);
2279                {
2280                    let mut gm = make_graph_mut(
2281                        &self.ids,
2282                        &mut self.syms,
2283                        &self.labels,
2284                        build_props_view(&self.props, &self.base),
2285                        &mut self.topo,
2286                        &mut self.edge_props,
2287                    );
2288                    eng.on_node_changed(id, None, &mut gm);
2289                }
2290                self.engine = eng;
2291                if !self.view_store.is_empty() {
2292                    #[cfg(test)]
2293                    DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
2294                    let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
2295                    for d in &new_deltas {
2296                        self.view_store.on_edge_changed(
2297                            d.etype_sym,
2298                            d.src_id,
2299                            d.dst_id,
2300                            d.fired,
2301                            &mut self.props,
2302                            &build_topo_view(&self.topo, &self.base),
2303                            &self.ids,
2304                            &self.syms,
2305                            &self.labels,
2306                            self.base.as_ref().map(|b| {
2307                                b.columns()
2308                                    .expect("base columns section bounds validated at open")
2309                            }),
2310                        );
2311                    }
2312                }
2313                if self.fulltext.has_label(&label_str) {
2314                    for (field_sym, value) in props {
2315                        let Some(field) = self.syms.resolve(*field_sym) else {
2316                            continue;
2317                        };
2318                        if self.fulltext.is_enabled(&label_str, field) {
2319                            self.fulltext.add_tokens(id, field, value);
2320                        }
2321                    }
2322                }
2323            }
2324            WalRecord::InsertEdgeId { etype, src, dst } => {
2325                // Replay-over-snapshot: dense ids in the pre-snapshot WAL may
2326                // already be tombstoned. Skip rather than attaching edges to
2327                // dead ids (DeleteNode keys the live re-insert, not the old id).
2328                if self.ids.is_tombstoned(*src)
2329                    || self.ids.is_tombstoned(*dst)
2330                    || self.ids.key_of(*src).is_none()
2331                    || self.ids.key_of(*dst).is_none()
2332                {
2333                    return Ok(());
2334                }
2335                // Skip if already visible in the merged view (same idempotency
2336                // guard as InsertEdge above: prevents double-counting when
2337                // pre-snapshot WAL records are replayed over a V8 base).
2338                if self.base.is_some()
2339                    && self
2340                        .topo_view()
2341                        .neighbors(*etype, Direction::Out, *src)
2342                        .contains(dst)
2343                {
2344                    return Ok(());
2345                }
2346                self.topo.add_edge(*etype, *src, *dst);
2347                self.view_store.on_edge_changed(
2348                    *etype,
2349                    *src,
2350                    *dst,
2351                    true,
2352                    &mut self.props,
2353                    &build_topo_view(&self.topo, &self.base),
2354                    &self.ids,
2355                    &self.syms,
2356                    &self.labels,
2357                    self.base.as_ref().map(|b| {
2358                        b.columns()
2359                            .expect("base columns section bounds validated at open")
2360                    }),
2361                );
2362                // Rule engine: via-hop rules fire when user via-edges are inserted.
2363                // Resolve etype back to string so on_edge_changed can match rules by name.
2364                if let Some(etype_str) = self.syms.resolve(*etype).map(|s| s.to_string()) {
2365                    let cursor = self.engine.pending_delta_count();
2366                    let mut eng = std::mem::take(&mut self.engine);
2367                    {
2368                        let mut gm = make_graph_mut(
2369                            &self.ids,
2370                            &mut self.syms,
2371                            &self.labels,
2372                            build_props_view(&self.props, &self.base),
2373                            &mut self.topo,
2374                            &mut self.edge_props,
2375                        );
2376                        eng.on_edge_changed(&etype_str, *src, *dst, &mut gm);
2377                    }
2378                    self.engine = eng;
2379                    if !self.view_store.is_empty() {
2380                        let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
2381                        for d in &new_deltas {
2382                            self.view_store.on_edge_changed(
2383                                d.etype_sym,
2384                                d.src_id,
2385                                d.dst_id,
2386                                d.fired,
2387                                &mut self.props,
2388                                &build_topo_view(&self.topo, &self.base),
2389                                &self.ids,
2390                                &self.syms,
2391                                &self.labels,
2392                                self.base.as_ref().map(|b| {
2393                                    b.columns()
2394                                        .expect("base columns section bounds validated at open")
2395                                }),
2396                            );
2397                        }
2398                    }
2399                }
2400            }
2401            WalRecord::SetPropId { id, field, value } => {
2402                if self.ids.is_tombstoned(*id) || self.ids.key_of(*id).is_none() {
2403                    return Ok(());
2404                }
2405                let field_str = self
2406                    .syms
2407                    .resolve(*field)
2408                    .ok_or_else(|| GraphError::Corrupt {
2409                        detail: format!("wal SetPropId unknown field intern {field}"),
2410                    })?
2411                    .to_string();
2412                let old_value = build_props_view(&self.props, &self.base)
2413                    .get(*id, &field_str)
2414                    .map(|vr| vr.into_value());
2415                self.props.set(*id, &field_str, value.clone());
2416                let cursor = self.engine.pending_delta_count();
2417                let mut eng = std::mem::take(&mut self.engine);
2418                {
2419                    let mut gm = make_graph_mut(
2420                        &self.ids,
2421                        &mut self.syms,
2422                        &self.labels,
2423                        build_props_view(&self.props, &self.base),
2424                        &mut self.topo,
2425                        &mut self.edge_props,
2426                    );
2427                    eng.on_node_changed(*id, Some((field_str.as_str(), old_value)), &mut gm);
2428                }
2429                self.engine = eng;
2430                if !self.view_store.is_empty() {
2431                    #[cfg(test)]
2432                    DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
2433                    let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
2434                    for d in &new_deltas {
2435                        self.view_store.on_edge_changed(
2436                            d.etype_sym,
2437                            d.src_id,
2438                            d.dst_id,
2439                            d.fired,
2440                            &mut self.props,
2441                            &build_topo_view(&self.topo, &self.base),
2442                            &self.ids,
2443                            &self.syms,
2444                            &self.labels,
2445                            self.base.as_ref().map(|b| {
2446                                b.columns()
2447                                    .expect("base columns section bounds validated at open")
2448                            }),
2449                        );
2450                    }
2451                }
2452                self.view_store.on_prop_changed(
2453                    *id,
2454                    &field_str,
2455                    &mut self.props,
2456                    &build_topo_view(&self.topo, &self.base),
2457                    &self.ids,
2458                    &self.syms,
2459                    &self.labels,
2460                    self.base.as_ref().map(|b| {
2461                        b.columns()
2462                            .expect("base columns section bounds validated at open")
2463                    }),
2464                );
2465                if self.fulltext.field_indexed(&field_str) {
2466                    let label_opt = self.labels.get(*id as usize).and_then(|&sym| {
2467                        if sym == u32::MAX {
2468                            None
2469                        } else {
2470                            self.syms.resolve(sym)
2471                        }
2472                    });
2473                    if let Some(label) = label_opt {
2474                        if self.fulltext.is_enabled(label, &field_str) {
2475                            self.fulltext.remove_node_field(*id, &field_str);
2476                            self.fulltext.add_tokens(*id, &field_str, value);
2477                        }
2478                    }
2479                }
2480            }
2481            WalRecord::CreateRule { def_bytes } => {
2482                let def: RuleDef = decode_rule_def(def_bytes).map_err(|e| GraphError::Corrupt {
2483                    detail: format!("CreateRule def_bytes deserialize failed: {e}"),
2484                })?;
2485                // Replay-over-snapshot idempotency: the rule was captured in the snapshot
2486                // so the engine already has it; silently skip to avoid a spurious
2487                // RuleInvalid error in the crash window between snapshot write and WAL
2488                // truncation.
2489                if self.engine.rules().any(|r| r.name == def.name) {
2490                    return Ok(());
2491                }
2492                let cursor = self.engine.pending_delta_count();
2493                let mut eng = std::mem::take(&mut self.engine);
2494                let result = {
2495                    let mut gm = make_graph_mut(
2496                        &self.ids,
2497                        &mut self.syms,
2498                        &self.labels,
2499                        build_props_view(&self.props, &self.base),
2500                        &mut self.topo,
2501                        &mut self.edge_props,
2502                    );
2503                    eng.create_rule(def, &mut gm)
2504                };
2505                self.engine = eng;
2506                result.map_err(|e| GraphError::RuleInvalid { detail: e })?;
2507                // Derived-edge fires from backfill → view updates.
2508                // Fast path: skip O(edge_count) allocation when no views exist.
2509                if !self.view_store.is_empty() {
2510                    #[cfg(test)]
2511                    DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
2512                    let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
2513                    for d in &new_deltas {
2514                        self.view_store.on_edge_changed(
2515                            d.etype_sym,
2516                            d.src_id,
2517                            d.dst_id,
2518                            d.fired,
2519                            &mut self.props,
2520                            &build_topo_view(&self.topo, &self.base),
2521                            &self.ids,
2522                            &self.syms,
2523                            &self.labels,
2524                            self.base.as_ref().map(|b| {
2525                                b.columns()
2526                                    .expect("base columns section bounds validated at open")
2527                            }),
2528                        );
2529                    }
2530                }
2531            }
2532            WalRecord::DeleteRule { name } => {
2533                // Replay-over-snapshot idempotency: the snapshot already captured the
2534                // post-delete state so the rule is absent; silently skip to avoid a
2535                // spurious RuleNotFound error in the crash window between snapshot write
2536                // and WAL truncation.
2537                if !self.engine.rules().any(|r| r.name == *name) {
2538                    return Ok(());
2539                }
2540                let cursor = self.engine.pending_delta_count();
2541                let mut eng = std::mem::take(&mut self.engine);
2542                let result = {
2543                    let mut gm = make_graph_mut(
2544                        &self.ids,
2545                        &mut self.syms,
2546                        &self.labels,
2547                        build_props_view(&self.props, &self.base),
2548                        &mut self.topo,
2549                        &mut self.edge_props,
2550                    );
2551                    eng.delete_rule(name, &mut gm)
2552                };
2553                self.engine = eng;
2554                result.map_err(|_| GraphError::RuleNotFound { name: name.clone() })?;
2555                // Derived-edge retractions → view updates.
2556                if !self.view_store.is_empty() {
2557                    #[cfg(test)]
2558                    DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
2559                    let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
2560                    for d in &new_deltas {
2561                        self.view_store.on_edge_changed(
2562                            d.etype_sym,
2563                            d.src_id,
2564                            d.dst_id,
2565                            d.fired,
2566                            &mut self.props,
2567                            &build_topo_view(&self.topo, &self.base),
2568                            &self.ids,
2569                            &self.syms,
2570                            &self.labels,
2571                            self.base.as_ref().map(|b| {
2572                                b.columns()
2573                                    .expect("base columns section bounds validated at open")
2574                            }),
2575                        );
2576                    }
2577                }
2578            }
2579            WalRecord::RemoveProp { key, field } => {
2580                // Recovery-safe: unknown key or already-absent field is a
2581                // clean no-op. Crash-window replay over a snapshot that
2582                // already applied this record must not Err.
2583                let Some(id) = self.ids.get(key) else {
2584                    return Ok(());
2585                };
2586                // Read old value through the seam for rule retraction.
2587                let old = build_props_view(&self.props, &self.base)
2588                    .get(id, field)
2589                    .map(|vr| vr.into_value());
2590                self.props.remove(id, field);
2591                // If the base still supplies the value after the overlay removal,
2592                // record a tombstone so ColumnsView::get does not resurrect it.
2593                // This covers both the base-only case AND the both-resident case:
2594                //   base-only (in_overlay=false): old prop was only in base, remove
2595                //     is a no-op on overlay, base still visible → tombstone needed.
2596                //   both-resident (in_overlay=true): overlay had v2, base has v1;
2597                //     removing overlay uncovers v1 → tombstone needed.
2598                // Idempotent on double-replay: second pass sees the tombstone →
2599                // get() returns None → condition is false → no duplicate tombstone.
2600                if build_props_view(&self.props, &self.base)
2601                    .get(id, field)
2602                    .is_some()
2603                {
2604                    self.props.record_prop_tombstone(id, field);
2605                }
2606                let cursor = self.engine.pending_delta_count();
2607                let mut eng = std::mem::take(&mut self.engine);
2608                {
2609                    let mut gm = make_graph_mut(
2610                        &self.ids,
2611                        &mut self.syms,
2612                        &self.labels,
2613                        build_props_view(&self.props, &self.base),
2614                        &mut self.topo,
2615                        &mut self.edge_props,
2616                    );
2617                    eng.on_node_changed(id, Some((field, old)), &mut gm);
2618                }
2619                self.engine = eng;
2620                // Derived-edge deltas → view updates.
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                // Neighbor-aggregate views that read `field` must also update.
2644                self.view_store.on_prop_changed(
2645                    id,
2646                    field,
2647                    &mut self.props,
2648                    &build_topo_view(&self.topo, &self.base),
2649                    &self.ids,
2650                    &self.syms,
2651                    &self.labels,
2652                    self.base.as_ref().map(|b| {
2653                        b.columns()
2654                            .expect("base columns section bounds validated at open")
2655                    }),
2656                );
2657                // Full-text index maintenance: remove tokens for this field.
2658                if self.fulltext.field_indexed(field) {
2659                    self.fulltext.remove_node_field(id, field);
2660                }
2661            }
2662            WalRecord::DeleteEdge {
2663                edge_type,
2664                src_key,
2665                dst_key,
2666            } => {
2667                // Recovery-safe: unknown keys, unknown etype, or already-
2668                // absent edge is a clean no-op (remove_edge returns false).
2669                let Some(src) = self.ids.get(src_key) else {
2670                    return Ok(());
2671                };
2672                let Some(dst) = self.ids.get(dst_key) else {
2673                    return Ok(());
2674                };
2675                let Some(etype) = self.syms.get(edge_type) else {
2676                    return Ok(());
2677                };
2678                // I3: phantom-tombstone guard.  When a V8 base is present, a
2679                // DeleteEdge WAL record for an edge that was already absorbed into
2680                // the new base (i.e. neither in overlay nor in base) must be skipped.
2681                // Without this guard, remove_edge records a tombstone for an edge
2682                // that no longer exists, incorrectly understating edge_count.
2683                if self.base.is_some()
2684                    && !self
2685                        .topo_view()
2686                        .neighbors(etype, core_storage::topology::Direction::Out, src)
2687                        .contains(&dst)
2688                {
2689                    return Ok(());
2690                }
2691                self.topo.remove_edge(etype, src, dst);
2692                self.edge_props.remove_edge(etype, src, dst);
2693                // View maintenance for manual edge delete (topo already updated above).
2694                self.view_store.on_edge_changed(
2695                    etype,
2696                    src,
2697                    dst,
2698                    false,
2699                    &mut self.props,
2700                    &build_topo_view(&self.topo, &self.base),
2701                    &self.ids,
2702                    &self.syms,
2703                    &self.labels,
2704                    self.base.as_ref().map(|b| {
2705                        b.columns()
2706                            .expect("base columns section bounds validated at open")
2707                    }),
2708                );
2709                // Rule engine: via-hop rules must retract when user via-edges are deleted.
2710                let cursor = self.engine.pending_delta_count();
2711                let mut eng = std::mem::take(&mut self.engine);
2712                {
2713                    let mut gm = make_graph_mut(
2714                        &self.ids,
2715                        &mut self.syms,
2716                        &self.labels,
2717                        build_props_view(&self.props, &self.base),
2718                        &mut self.topo,
2719                        &mut self.edge_props,
2720                    );
2721                    eng.on_edge_changed(edge_type, src, dst, &mut gm);
2722                }
2723                self.engine = eng;
2724                if !self.view_store.is_empty() {
2725                    let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
2726                    for d in &new_deltas {
2727                        self.view_store.on_edge_changed(
2728                            d.etype_sym,
2729                            d.src_id,
2730                            d.dst_id,
2731                            d.fired,
2732                            &mut self.props,
2733                            &build_topo_view(&self.topo, &self.base),
2734                            &self.ids,
2735                            &self.syms,
2736                            &self.labels,
2737                            self.base.as_ref().map(|b| {
2738                                b.columns()
2739                                    .expect("base columns section bounds validated at open")
2740                            }),
2741                        );
2742                    }
2743                }
2744            }
2745            WalRecord::DeleteNode { key } => {
2746                // Recovery-safe: already-tombstoned / unknown key is a clean
2747                // no-op. Crash-window replay over a snapshot that already
2748                // applied this record cannot recover the retired id from the
2749                // key (`IdMap::get` is None), so every subsequent step is
2750                // skipped. Each step is independently idempotent if invoked
2751                // twice on a still-live id: retraction is a no-op on empty
2752                // provenance, `remove_edge` returns false, `remove_all` is a
2753                // no-op, `ids.delete` returns None, label sentinel is sticky.
2754                let Some(n) = self.ids.get(key) else {
2755                    return Ok(());
2756                };
2757
2758                // (1) Retract derived edges + de-index while props/labels live.
2759                let cursor = self.engine.pending_delta_count();
2760                let mut eng = std::mem::take(&mut self.engine);
2761                {
2762                    let mut gm = make_graph_mut(
2763                        &self.ids,
2764                        &mut self.syms,
2765                        &self.labels,
2766                        build_props_view(&self.props, &self.base),
2767                        &mut self.topo,
2768                        &mut self.edge_props,
2769                    );
2770                    eng.on_node_removed(n, &mut gm);
2771                }
2772                self.engine = eng;
2773                // Derived-edge retractions → view updates for neighbors.
2774                if !self.view_store.is_empty() {
2775                    #[cfg(test)]
2776                    DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
2777                    let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
2778                    for d in &new_deltas {
2779                        self.view_store.on_edge_changed(
2780                            d.etype_sym,
2781                            d.src_id,
2782                            d.dst_id,
2783                            d.fired,
2784                            &mut self.props,
2785                            &build_topo_view(&self.topo, &self.base),
2786                            &self.ids,
2787                            &self.syms,
2788                            &self.labels,
2789                            self.base.as_ref().map(|b| {
2790                                b.columns()
2791                                    .expect("base columns section bounds validated at open")
2792                            }),
2793                        );
2794                    }
2795                }
2796
2797                // (2) Sweep ALL remaining edges incident to n, both directions,
2798                // every etype. This cascade is intentionally mask-independent:
2799                // topology integrity requires removing every edge touching the
2800                // deleted node regardless of the caller's visibility scope.
2801                // (The mask limits which nodes a role's read phase can return;
2802                // the WAL delete always executes with full storage authority.)
2803                // Collect then remove so neighbor slices stay valid during
2804                // iteration. Remove from topo first, then call view maintenance
2805                // so Avg/Min/Max recompute sees the correct (reduced) neighbor set.
2806                let etypes: Vec<u32> = self.topo.etypes().collect();
2807                let mut doomed = Vec::new();
2808                for et in &etypes {
2809                    for &dst in self.topo.neighbors(*et, Direction::Out, n).as_ref() {
2810                        doomed.push((*et, n, dst));
2811                    }
2812                    for &src in self.topo.neighbors(*et, Direction::In, n).as_ref() {
2813                        doomed.push((*et, src, n));
2814                    }
2815                }
2816                for (et, s, d) in doomed {
2817                    self.topo.remove_edge(et, s, d);
2818                    self.edge_props.remove_edge(et, s, d);
2819                    // View maintenance: n's own view values will be cleared by
2820                    // remove_all below; only update surviving neighbors.
2821                    self.view_store.on_edge_changed(
2822                        et,
2823                        s,
2824                        d,
2825                        false,
2826                        &mut self.props,
2827                        &build_topo_view(&self.topo, &self.base),
2828                        &self.ids,
2829                        &self.syms,
2830                        &self.labels,
2831                        self.base.as_ref().map(|b| {
2832                            b.columns()
2833                                .expect("base columns section bounds validated at open")
2834                        }),
2835                    );
2836                }
2837
2838                // (3) Drop every remaining prop (`ColumnStore::remove_all`).
2839                self.props.remove_all(n);
2840                // Full-text index maintenance: remove all tokens for this node.
2841                self.fulltext.remove_node(n);
2842
2843                // (4) Retire the dense id and stamp the label sentinel.
2844                self.ids.delete(key);
2845                if let Some(slot) = self.labels.get_mut(n as usize) {
2846                    *slot = u32::MAX;
2847                }
2848            }
2849            WalRecord::Batch(inner) => {
2850                // Apply each inner record in order through the same apply path.
2851                // Inner records are validated free of nested Batch by encode_record.
2852                for rec in inner {
2853                    self.apply(rec)?;
2854                }
2855            }
2856            WalRecord::RebuildRule { name } => {
2857                // Replay-over-snapshot idempotency: the snapshot may already
2858                // reflect a later delete_rule, so the rule is absent; skip.
2859                if !self.engine.rules().any(|r| r.name == *name) {
2860                    return Ok(());
2861                }
2862                let cursor = self.engine.pending_delta_count();
2863                let mut eng = std::mem::take(&mut self.engine);
2864                let result = {
2865                    let mut gm = make_graph_mut(
2866                        &self.ids,
2867                        &mut self.syms,
2868                        &self.labels,
2869                        build_props_view(&self.props, &self.base),
2870                        &mut self.topo,
2871                        &mut self.edge_props,
2872                    );
2873                    eng.rebuild(name, &mut gm)
2874                };
2875                self.engine = eng;
2876                result.map_err(|_| GraphError::RuleNotFound { name: name.clone() })?;
2877                // Derived-edge delta changes → view updates.
2878                if !self.view_store.is_empty() {
2879                    #[cfg(test)]
2880                    DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
2881                    let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
2882                    for d in &new_deltas {
2883                        self.view_store.on_edge_changed(
2884                            d.etype_sym,
2885                            d.src_id,
2886                            d.dst_id,
2887                            d.fired,
2888                            &mut self.props,
2889                            &build_topo_view(&self.topo, &self.base),
2890                            &self.ids,
2891                            &self.syms,
2892                            &self.labels,
2893                            self.base.as_ref().map(|b| {
2894                                b.columns()
2895                                    .expect("base columns section bounds validated at open")
2896                            }),
2897                        );
2898                    }
2899                }
2900            }
2901            WalRecord::CreateView { def_bytes } => {
2902                let def: ViewDef =
2903                    bincode::deserialize(def_bytes).map_err(|e| GraphError::Corrupt {
2904                        detail: format!("CreateView def_bytes deserialize failed: {e}"),
2905                    })?;
2906                // Replay-over-snapshot idempotency: view already present → skip.
2907                if self.view_store.has_view(&def.name) {
2908                    return Ok(());
2909                }
2910                self.view_store
2911                    .create_view(
2912                        def,
2913                        &mut self.props,
2914                        &build_topo_view(&self.topo, &self.base),
2915                        &self.ids,
2916                        &self.syms,
2917                        &self.labels,
2918                    )
2919                    .map_err(|e| GraphError::RuleInvalid { detail: e })?;
2920            }
2921            WalRecord::DeleteView { name } => {
2922                // Replay-over-snapshot idempotency: view already absent → skip.
2923                if !self.view_store.has_view(name) {
2924                    return Ok(());
2925                }
2926                self.view_store
2927                    .delete_view(name, &mut self.props, &self.ids, &self.labels, &self.syms)
2928                    .map_err(|_| GraphError::RuleNotFound { name: name.clone() })?;
2929            }
2930            WalRecord::EnableFulltext { label, field } => {
2931                // Replay-over-snapshot idempotency: already enabled → skip.
2932                if self.fulltext.is_enabled(label, field) {
2933                    return Ok(());
2934                }
2935                self.fulltext.enable(label, field);
2936                // Backfill: index all live nodes of this label that have the field.
2937                let n = self.ids.len() as u32;
2938                for id in 0..n {
2939                    let Some(&sym) = self.labels.get(id as usize) else {
2940                        continue;
2941                    };
2942                    if sym == u32::MAX {
2943                        continue; // tombstoned
2944                    }
2945                    let Some(lbl) = self.syms.resolve(sym) else {
2946                        continue;
2947                    };
2948                    if lbl != label {
2949                        continue;
2950                    }
2951                    if let Some(value) = build_props_view(&self.props, &self.base)
2952                        .get(id, field)
2953                        .map(|vr| vr.into_value())
2954                    {
2955                        self.fulltext.add_tokens(id, field, &value);
2956                    }
2957                }
2958            }
2959            WalRecord::DisableFulltext { label, field } => {
2960                // Replay-over-snapshot idempotency: already disabled → skip.
2961                if !self.fulltext.is_enabled(label, field) {
2962                    return Ok(());
2963                }
2964                // If another label still indexes this field, the postings column
2965                // is kept — but it must not contain node_ids from the now-disabled
2966                // label.  Remove them before calling disable() so the field_indexed
2967                // guard inside disable() sees the correct post-removal state.
2968                if self.fulltext.field_indexed_by_other(label, field) {
2969                    if let Some(label_sym) = self.syms.get(label) {
2970                        for (node_id, &lsym) in self.labels.iter().enumerate() {
2971                            if lsym == label_sym {
2972                                self.fulltext.remove_node_field(node_id as u32, field);
2973                            }
2974                        }
2975                    }
2976                }
2977                self.fulltext.disable(label, field);
2978            }
2979            // History markers carry no replay state — rules re-derive edges
2980            // deterministically on open/replay. Skip unconditionally.
2981            WalRecord::DerivedEdgeAdded { .. } | WalRecord::DerivedEdgeRetracted { .. } => {}
2982            // ── rename_node ──────────────────────────────────────────────────
2983            WalRecord::RenameNode { old_key, new_key } => {
2984                // Recovery-safe: if old_key is already gone (key was renamed
2985                // by a snapshot or a prior replay frame), skip cleanly.
2986                if self.ids.get(old_key).is_none() {
2987                    return Ok(());
2988                }
2989                // The rename only updates the key-table; the dense id, all
2990                // topo edges, props, labels, and rule state are id-indexed and
2991                // require no change.
2992                self.ids
2993                    .rename(old_key, new_key)
2994                    .map_err(|e| GraphError::Corrupt {
2995                        detail: format!("wal replay RenameNode {old_key}→{new_key}: {e}"),
2996                    })?;
2997            }
2998        }
2999        Ok(())
3000    }
3001
3002    /// Intern `s` in `syms` and emit a WAL `Intern` record so `*Id` records
3003    /// replay on WAL-only `open_at` (no snapshot intern table). Apply is
3004    /// idempotent when the string is already bound. Always emit: after
3005    /// `snapshot()` the WAL is truncated and live intern is not on disk.
3006    fn intern_wal(&mut self, s: &str) -> (u32, WalRecord) {
3007        let id = if let Some(id) = self.syms.get(s) {
3008            id
3009        } else {
3010            self.syms.intern(s)
3011        };
3012        (
3013            id,
3014            WalRecord::Intern {
3015                id,
3016                text: s.to_string(),
3017            },
3018        )
3019    }
3020
3021    /// Rewrite user-facing records into dense-id records. On `Err`, no live
3022    /// state is left mutated: speculative interns made while building the
3023    /// output are rolled back, so a later successful mutation cannot log an
3024    /// `Intern` record whose id replay would never reproduce.
3025    fn rewrite_wal_dense(&mut self, recs: Vec<WalRecord>) -> Result<Vec<WalRecord>> {
3026        let syms_checkpoint = self.syms.len();
3027        let result = self.rewrite_wal_dense_inner(recs);
3028        if result.is_err() {
3029            self.syms.truncate(syms_checkpoint);
3030        }
3031        result
3032    }
3033
3034    fn rewrite_wal_dense_inner(&mut self, recs: Vec<WalRecord>) -> Result<Vec<WalRecord>> {
3035        let mut out = Vec::with_capacity(recs.len());
3036        // Node ids allocated by later apply(InsertNodeId) in this same batch.
3037        let mut pending: std::collections::HashMap<String, u32> = std::collections::HashMap::new();
3038        let mut interned = std::collections::HashSet::<u32>::new();
3039        let mut next = u32::try_from(self.ids.len()).map_err(|_| GraphError::Corrupt {
3040            detail: "id space exhausted".into(),
3041        })?;
3042        let lookup = |ids: &IdMap,
3043                      pending: &std::collections::HashMap<String, u32>,
3044                      key: &str|
3045         -> Option<u32> { ids.get(key).or_else(|| pending.get(key).copied()) };
3046        for rec in recs {
3047            match rec {
3048                WalRecord::InsertNode { label, key, props } => {
3049                    let (label_id, intern) = self.intern_wal(&label);
3050                    if interned.insert(label_id) {
3051                        out.push(intern);
3052                    }
3053                    let mut props_id = Vec::with_capacity(props.len());
3054                    for (field, value) in props {
3055                        let (field_id, intern) = self.intern_wal(&field);
3056                        if interned.insert(field_id) {
3057                            out.push(intern);
3058                        }
3059                        props_id.push((field_id, value));
3060                    }
3061                    if lookup(&self.ids, &pending, &key).is_none() {
3062                        pending.insert(key.clone(), next);
3063                        next = next.checked_add(1).ok_or_else(|| GraphError::Corrupt {
3064                            detail: "id space exhausted".into(),
3065                        })?;
3066                    }
3067                    out.push(WalRecord::InsertNodeId {
3068                        label: label_id,
3069                        key,
3070                        props: props_id,
3071                    });
3072                }
3073                WalRecord::SetProp { key, field, value } => {
3074                    let id =
3075                        lookup(&self.ids, &pending, &key).ok_or_else(|| GraphError::Corrupt {
3076                            detail: format!("dense WAL rewrite missing key {key}"),
3077                        })?;
3078                    let (field_id, intern) = self.intern_wal(&field);
3079                    if interned.insert(field_id) {
3080                        out.push(intern);
3081                    }
3082                    out.push(WalRecord::SetPropId {
3083                        id,
3084                        field: field_id,
3085                        value,
3086                    });
3087                }
3088                WalRecord::InsertEdge {
3089                    edge_type,
3090                    src_key,
3091                    dst_key,
3092                } => {
3093                    let (etype, intern) = self.intern_wal(&edge_type);
3094                    if interned.insert(etype) {
3095                        out.push(intern);
3096                    }
3097                    let src = lookup(&self.ids, &pending, &src_key).ok_or_else(|| {
3098                        GraphError::Corrupt {
3099                            detail: format!("dense WAL rewrite missing src {src_key}"),
3100                        }
3101                    })?;
3102                    let dst = lookup(&self.ids, &pending, &dst_key).ok_or_else(|| {
3103                        GraphError::Corrupt {
3104                            detail: format!("dense WAL rewrite missing dst {dst_key}"),
3105                        }
3106                    })?;
3107                    out.push(WalRecord::InsertEdgeId { etype, src, dst });
3108                }
3109                WalRecord::RenameNode {
3110                    ref old_key,
3111                    ref new_key,
3112                } => {
3113                    // Track the rename in `pending` so subsequent InsertEdge /
3114                    // SetProp records in this batch can resolve the new key.
3115                    let id = lookup(&self.ids, &pending, old_key).ok_or_else(|| {
3116                        GraphError::Corrupt {
3117                            detail: format!(
3118                                "dense WAL rewrite: RenameNode old key {old_key} not found"
3119                            ),
3120                        }
3121                    })?;
3122                    pending.remove(old_key.as_str());
3123                    pending.insert(new_key.clone(), id);
3124                    out.push(rec);
3125                }
3126                other => out.push(other),
3127            }
3128        }
3129        Ok(out)
3130    }
3131
3132    fn log_dense(&mut self, recs: Vec<WalRecord>) -> Result<()> {
3133        let recs = self.rewrite_wal_dense(recs)?;
3134        match recs.len() {
3135            0 => Ok(()),
3136            1 => self.log_then_apply(recs.into_iter().next().unwrap()),
3137            _ => self.log_then_apply(WalRecord::Batch(recs)),
3138        }
3139    }
3140
3141    /// Durable write, then notify the event sink. Replay (`apply` during
3142    /// `open`) never enters this function, so it is the replay-silent seam.
3143    fn log_then_apply(&mut self, rec: WalRecord) -> Result<()> {
3144        self.log_then_apply_with(rec, None, self.fsync)
3145    }
3146
3147    /// Whether this frame must fsync under `policy`.
3148    ///
3149    /// Batched contract: user-visible batches (>1 mutation) fsync; single
3150    /// mutations do not. The dense rewrite wraps a single mutation in a
3151    /// `Batch([Intern.., <one *Id record>])`, so `Intern` records are excluded
3152    /// from the count — removing that filter would make every single-op write
3153    /// fsync under Batched (or, if the threshold were raised instead, skip a
3154    /// needed fsync for real two-op batches).
3155    fn wal_needs_sync(policy: FsyncPolicy, rec: &WalRecord) -> bool {
3156        match policy {
3157            FsyncPolicy::Relaxed => false,
3158            FsyncPolicy::Strict => true,
3159            FsyncPolicy::Batched => match rec {
3160                // Intern + one mutation is the single-op rewrite, not a user batch.
3161                WalRecord::Batch(inner) => {
3162                    inner
3163                        .iter()
3164                        .filter(|r| !matches!(r, WalRecord::Intern { .. }))
3165                        .count()
3166                        > 1
3167                }
3168                _ => false,
3169            },
3170        }
3171    }
3172
3173    /// # Apply-infallibility invariant (load-bearing)
3174    ///
3175    /// The ordering is: WAL append → fsync → apply. If `apply` returned `Err`
3176    /// for a `Batch` frame after a successful WAL write, the WAL would contain
3177    /// the full frame while in-memory state would reflect only the ops before
3178    /// the failure. On reopen, WAL replay would then apply the entire batch —
3179    /// diverging permanently from what the pre-crash process had in memory.
3180    ///
3181    /// For `Batch` frames this situation cannot arise because:
3182    /// - All validation runs via `commit_logged_batch`/`MutPreview` **before**
3183    ///   the WAL write. `MutPreview` uses the same `&mut self` that apply will
3184    ///   use, with no concurrent mutation between validation exit and apply entry.
3185    /// - Every `apply` arm for a validated op is either infallible by construction
3186    ///   (`InsertNode`, `RemoveProp`, `DeleteEdge`, `DeleteNode`), has idempotency
3187    ///   guards that return `Ok(())` (`CreateRule`, `DeleteRule`), or is
3188    ///   guaranteed-present by validation (`InsertEdge`/`SetProp` key lookups).
3189    /// - `on_node_changed` and `on_node_removed` return `()` — never `Err`.
3190    ///
3191    /// A `debug_assert!` below fires in debug builds if `apply` ever returns
3192    /// `Err` for a `Batch` frame, making any future regression immediately visible
3193    /// in tests rather than silently diverging crash-recovery behaviour.
3194    fn log_then_apply_with(
3195        &mut self,
3196        rec: WalRecord,
3197        ingest: Option<(String, usize)>,
3198        policy: FsyncPolicy,
3199    ) -> Result<()> {
3200        // Read-only guard: as-of instances must never write the WAL.
3201        if self.read_only {
3202            return Err(GraphError::ReadOnly);
3203        }
3204        // Degraded guard: fsync failure left WAL truncated; in-memory state
3205        // is ahead of the on-disk WAL, so further mutations would deepen the
3206        // divergence.  Reopen the database to recover.
3207        if self.degraded {
3208            return Err(GraphError::Io(std::io::Error::other(
3209                "database degraded after group-commit fsync failure; reopen required",
3210            )));
3211        }
3212        // Ensure retained provenance bytes are decoded into the live mutable
3213        // fields before any mutation touches self.engine.provenance.  This is a
3214        // no-op if provenance was never stored (fresh store) or has already been
3215        // consumed (subsequent mutations).  WAL replay calls apply() directly
3216        // and is covered by consume_retained_state_eager before replay.
3217        self.ensure_v8_base_sections_loaded();
3218        self.engine.ensure_provenance_loaded_mut();
3219        // Invariant (I-1): no stale deltas may enter from a previous apply.
3220        // If any engine method ever accumulates deltas before erroring, they would
3221        // contaminate the *next* commit's event stream. This assert fires in debug
3222        // builds, making any future regression visible at the earliest point.
3223        debug_assert_eq!(
3224            self.engine.pending_delta_count(),
3225            0,
3226            "stale engine deltas at log_then_apply_with entry — \
3227             a previous apply arm may have accumulated deltas before erroring; \
3228             the caller must drain_deltas() on any error path before returning"
3229        );
3230        self.fs.append(FileId::Wal, &encode_record(&rec))?;
3231        if Self::wal_needs_sync(policy, &rec) {
3232            self.fs.sync(FileId::Wal)?;
3233        }
3234        // Marker writing always needs the engine deltas, but the engine only
3235        // accumulates them when emit_deltas is true (normally gated on subscribers
3236        // or views being present).  Enable emission for this apply if it is
3237        // currently off, then restore the original state unconditionally via an
3238        // RAII guard — this prevents a panic in apply() from leaking the flag.
3239        struct RestoreEmitDeltas(*mut RuleEngine, bool);
3240        impl Drop for RestoreEmitDeltas {
3241            fn drop(&mut self) {
3242                // SAFETY: pointer into self (GraphDb); guard is dropped within
3243                // this frame before log_then_apply_with returns.
3244                unsafe { (*self.0).set_emit_deltas(self.1) };
3245            }
3246        }
3247        let original_emit = self.engine.emit_deltas();
3248        if !original_emit {
3249            self.engine.set_emit_deltas(true);
3250        }
3251        // SAFETY: raw pointer into self; guard dropped within this frame.
3252        let _emit_guard = RestoreEmitDeltas(&mut self.engine as *mut _, original_emit);
3253
3254        let apply_result = self.apply(&rec);
3255        // For Batch frames, post-validation apply must be infallible (see above).
3256        // A debug_assert here catches any future change that makes apply fallible
3257        // before the caller notices via silent WAL/memory divergence.
3258        if matches!(&rec, WalRecord::Batch(_)) {
3259            debug_assert!(
3260                apply_result.is_ok(),
3261                "Batch apply returned Err after successful WAL write — \
3262                 the validate-then-apply invariant has been violated; \
3263                 see log_then_apply_with invariant doc"
3264            );
3265        }
3266        if apply_result.is_err() {
3267            // Discard any partial deltas accumulated by the failed apply.
3268            // They must not ride the next commit's event stream (I-1).
3269            // _emit_guard restores emit_deltas on drop automatically.
3270            let _ = self.engine.drain_deltas();
3271            let _ = self.engine.take_rebuild_needed();
3272            apply_result?;
3273        }
3274        self.commit_seq += 1;
3275        let seq = self.commit_seq;
3276        // Update per-node last-change map for the committed record.
3277        // Must happen after commit_seq is incremented so the seq is correct.
3278        self.update_last_change_from_rec(&rec, seq);
3279        // Drain engine deltas and distribute to subscribers before the existing
3280        // MutationEvent sink fires — both happen post-fsync, post-apply.
3281        // _emit_guard restores emit_deltas after this line when it drops.
3282        let engine_deltas = self.engine.drain_deltas();
3283
3284        // Append history-marker WAL records for any derived-edge changes so
3285        // that `edge_history` and `was_linked` can surface rule-attributed
3286        // events. Markers are STATE NO-OPS during replay; they are written
3287        // without an additional fsync (the triggering commit's sync already
3288        // happened; the next commit's sync covers these lazily).
3289        if !engine_deltas.is_empty() {
3290            let markers: Vec<WalRecord> = engine_deltas
3291                .iter()
3292                .map(|d| {
3293                    if d.fired {
3294                        WalRecord::DerivedEdgeAdded {
3295                            rule: d.rule.clone(),
3296                            edge_type: d.edge_type.clone(),
3297                            src_key: d.src_key.clone(),
3298                            dst_key: d.dst_key.clone(),
3299                        }
3300                    } else {
3301                        WalRecord::DerivedEdgeRetracted {
3302                            rule: d.rule.clone(),
3303                            edge_type: d.edge_type.clone(),
3304                            src_key: d.src_key.clone(),
3305                            dst_key: d.dst_key.clone(),
3306                        }
3307                    }
3308                })
3309                .collect();
3310            let marker_frame = if markers.len() == 1 {
3311                markers.into_iter().next().unwrap()
3312            } else {
3313                WalRecord::Batch(markers)
3314            };
3315            // Ignore append errors: markers are best-effort history
3316            // annotations. Losing them does not affect state correctness.
3317            let _ = self.fs.append(FileId::Wal, &encode_record(&marker_frame));
3318        }
3319
3320        // Record MVCC CommitDelta for the epoch reader.  The WAL record is
3321        // stored as-is (including any nested Batch / Intern records); the
3322        // ReaderSnapshot's apply_one function handles all variants.
3323        {
3324            let derived_inserts = engine_deltas
3325                .iter()
3326                .filter(|d| d.fired)
3327                .map(|d| (d.etype_sym, d.src_id, d.dst_id))
3328                .collect();
3329            let derived_deletes = engine_deltas
3330                .iter()
3331                .filter(|d| !d.fired)
3332                .map(|d| (d.etype_sym, d.src_id, d.dst_id))
3333                .collect();
3334            let delta = Arc::new(crate::reader::CommitDelta {
3335                records: vec![rec.clone()],
3336                derived_inserts,
3337                derived_deletes,
3338            });
3339            self.delta_tail.push(delta);
3340            self.commits_since_fold += 1;
3341            if self.commits_since_fold >= crate::reader::FOLD_EVERY_K {
3342                self.fold_now();
3343            }
3344        }
3345
3346        if self.defer_events {
3347            // Group-commit drain thread: hold events until after the group
3348            // fsync so subscribers only observe durable data (R2).
3349            self.deferred_events.push(DeferredEvent {
3350                rec: rec.clone(),
3351                engine_deltas,
3352                seq,
3353                ingest,
3354            });
3355        } else {
3356            self.distribute_events(&rec, &engine_deltas, seq);
3357            self.emit_committed(&rec, ingest);
3358        }
3359        // Drift is only known after apply, so auto-rebuild cannot join the
3360        // triggering op's WAL frame. Issue RebuildRule as a second commit.
3361        // Skip when `rec` is itself RebuildRule: rebuild resets drift, so a
3362        // retrigger loop is impossible if the fit succeeded, but we still
3363        // drain the flag so a leftover cannot re-enter.
3364        let rebuilds = self.engine.take_rebuild_needed();
3365        if !matches!(&rec, WalRecord::RebuildRule { .. }) {
3366            let mut failed = Vec::new();
3367            for name in rebuilds {
3368                if self.engine.rules().any(|r| r.name == name) {
3369                    // User op is already durable. A failed second commit must
3370                    // not surface as the caller's error.
3371                    if let Err(e) =
3372                        self.log_then_apply(WalRecord::RebuildRule { name: name.clone() })
3373                    {
3374                        eprintln!(
3375                            "auto-rebuild of rule {name:?} failed after durable user commit: {e}"
3376                        );
3377                        failed.push(name);
3378                    }
3379                }
3380            }
3381            for name in failed {
3382                self.engine.queue_rebuild_needed(name);
3383            }
3384        }
3385        Ok(())
3386    }
3387
3388    /// Install a post-commit hook. Replaces any previous sink.
3389    ///
3390    /// The sink runs inside `log_then_apply` after a successful
3391    /// durable commit, while the caller still holds `&mut self`. When this
3392    /// database is behind a [`crate::SharedDb`], that means the **write
3393    /// guard is held**. The sink must never call `read` / `write` (or any
3394    /// other method) on the same `SharedDb` — the `RwLock` is not
3395    /// re-entrant and doing so deadlocks. The sink is `Send + Sync`;
3396    /// `std::sync::mpsc::Sender` is not `Sync` and will not type-check.
3397    /// Intended examples: `std::sync::mpsc::SyncSender`,
3398    /// `tokio::sync::mpsc::Sender`, `tokio::sync::broadcast::Sender`
3399    /// (non-blocking `send`), or `Arc<Mutex<Vec<MutationEvent>>>`.
3400    pub fn set_event_sink(&mut self, sink: Box<dyn Fn(MutationEvent) + Send + Sync>) {
3401        self.event_sink = Some(sink);
3402    }
3403
3404    /// Whether a post-commit event sink is currently installed.
3405    pub fn has_event_sink(&self) -> bool {
3406        self.event_sink.is_some()
3407    }
3408
3409    /// Set WAL fsync cadence. Default [`FsyncPolicy::Strict`].
3410    pub fn set_fsync_policy(&mut self, p: FsyncPolicy) {
3411        self.fsync = p;
3412    }
3413
3414    /// Return the current WAL fsync cadence.
3415    pub fn fsync_policy(&self) -> FsyncPolicy {
3416        self.fsync
3417    }
3418
3419    // ── Group-commit event deferral ───────────────────────────────────────────
3420
3421    /// Enable or disable deferred event mode.
3422    ///
3423    /// When `true`, event notifications (subscription `DbEvent`s and legacy
3424    /// `MutationEvent` sink calls) are buffered rather than fired immediately.
3425    /// Call [`flush_deferred_events`] after the group fsync to deliver them,
3426    /// or [`discard_deferred_events`] if the fsync failed and the group must
3427    /// be treated as lost.
3428    pub fn set_deferred_events_mode(&mut self, defer: bool) {
3429        self.defer_events = defer;
3430    }
3431
3432    /// Fire all buffered events accumulated since [`set_deferred_events_mode`]
3433    /// was set to true.  Clears the buffer.
3434    ///
3435    /// Called by the drain thread AFTER a successful group fsync, so
3436    /// subscribers observe only data that is durably on disk.
3437    pub fn flush_deferred_events(&mut self) {
3438        let events = std::mem::take(&mut self.deferred_events);
3439        for de in events {
3440            self.distribute_events(&de.rec, &de.engine_deltas, de.seq);
3441            self.emit_committed(&de.rec, de.ingest);
3442        }
3443    }
3444
3445    /// Discard all buffered events without firing them.
3446    ///
3447    /// Called by the drain thread when a group fsync fails: the WAL has been
3448    /// truncated back to the pre-group offset, so the committed-but-unsynced
3449    /// ops must not be observable to subscribers.
3450    pub fn discard_deferred_events(&mut self) {
3451        self.deferred_events.clear();
3452    }
3453
3454    // ── Degraded state ────────────────────────────────────────────────────────
3455
3456    /// Mark this database as degraded.
3457    ///
3458    /// Called by the group-commit drain thread after a group fsync failure and
3459    /// WAL truncation: the in-memory state is now ahead of the on-disk WAL, so
3460    /// further mutations would deepen the divergence.  All subsequent calls to
3461    /// [`log_then_apply_with`] return `Err` until the database is reopened.
3462    pub fn set_degraded(&mut self) {
3463        self.degraded = true;
3464    }
3465
3466    fn emit(&self, ev: MutationEvent) {
3467        if let Some(sink) = &self.event_sink {
3468            sink(ev);
3469        }
3470    }
3471
3472    fn emit_committed(&self, rec: &WalRecord, ingest: Option<(String, usize)>) {
3473        match rec {
3474            WalRecord::Batch(inner) => {
3475                for r in inner {
3476                    if let Some(ev) = event_from_record(r, &self.syms, &self.ids) {
3477                        self.emit(ev);
3478                    }
3479                }
3480                match ingest {
3481                    Some((label, inserted)) => {
3482                        self.emit(MutationEvent::Ingested { label, inserted })
3483                    }
3484                    None => {
3485                        let ops = inner
3486                            .iter()
3487                            .filter(|r| !matches!(r, WalRecord::Intern { .. }))
3488                            .count();
3489                        if ops > 1 {
3490                            self.emit(MutationEvent::BatchApplied { ops });
3491                        }
3492                    }
3493                }
3494            }
3495            other => {
3496                if let Some(ev) = event_from_record(other, &self.syms, &self.ids) {
3497                    self.emit(ev);
3498                }
3499            }
3500        }
3501    }
3502
3503    // -----------------------------------------------------------------------
3504    // Subscription API
3505    // -----------------------------------------------------------------------
3506
3507    /// Distribute post-commit events to all live subscribers.
3508    ///
3509    /// Build a row-key → row-data map from a [`ResultSet`].
3510    ///
3511    /// Each row is serialized to JSON to form its key; a debug fallback is used
3512    /// if serialization fails. Used by both the initial-seed path in
3513    /// [`Self::subscribe_query`] and the per-commit diff path in
3514    /// [`Self::distribute_events`] to keep the two in sync.
3515    fn result_to_row_map(
3516        result: &core_query::ResultSet,
3517    ) -> std::collections::HashMap<String, Vec<Option<Value>>> {
3518        (0..result.len())
3519            .map(|i| {
3520                let row = result.row(i).to_vec();
3521                let key = serde_json::to_string(&row).unwrap_or_else(|_| format!("{row:?}"));
3522                (key, row)
3523            })
3524            .collect()
3525    }
3526
3527    /// Distribute post-commit events to all live subscribers.
3528    ///
3529    /// Called from `log_then_apply_with` after apply + fsync, before the
3530    /// legacy MutationEvent sink. Prunes dead `Weak` entries in-place.
3531    ///
3532    /// Query subscriptions (subscribe_query) re-execute their plan on every
3533    /// call and diff the result against the previous run. Zero overhead when
3534    /// no query subscriptions are active.
3535    fn distribute_events(&mut self, rec: &WalRecord, engine_deltas: &[EngineEdgeDelta], seq: u64) {
3536        if self.subscriptions.is_empty() && self.query_subscriptions.is_empty() {
3537            return;
3538        }
3539
3540        if !self.subscriptions.is_empty() {
3541            // Build write events from the WAL record.
3542            let write_events: Vec<DbEvent> =
3543                Self::write_events_from_record(rec, seq, &self.syms, &self.ids);
3544
3545            // Build edge events from engine deltas.  Weight is looked up from
3546            // edge_props at distribution time (after apply), so it's always fresh.
3547            let edge_events: Vec<DbEvent> = engine_deltas
3548                .iter()
3549                .map(|d| {
3550                    if d.fired {
3551                        let weight = self
3552                            .edge_props
3553                            .get(d.etype_sym, d.src_id, d.dst_id, "weight")
3554                            .and_then(|v| {
3555                                if let core_storage::Value::Float(f) = v {
3556                                    Some(*f)
3557                                } else {
3558                                    None
3559                                }
3560                            });
3561                        DbEvent::EdgeFired {
3562                            rule: d.rule.clone(),
3563                            src_key: d.src_key.clone(),
3564                            dst_key: d.dst_key.clone(),
3565                            edge_type: d.edge_type.clone(),
3566                            weight,
3567                            commit_seq: seq,
3568                        }
3569                    } else {
3570                        DbEvent::EdgeRetracted {
3571                            rule: d.rule.clone(),
3572                            src_key: d.src_key.clone(),
3573                            dst_key: d.dst_key.clone(),
3574                            edge_type: d.edge_type.clone(),
3575                            commit_seq: seq,
3576                        }
3577                    }
3578                })
3579                .collect();
3580
3581            // Prune dead entries; push matching events to live ones.
3582            self.subscriptions.retain(|entry| {
3583                let Some(inner) = entry.inner.upgrade() else {
3584                    return false;
3585                };
3586                for ev in &write_events {
3587                    if event_matches(ev, &entry.filter) {
3588                        inner.push(ev.clone());
3589                    }
3590                }
3591                for ev in &edge_events {
3592                    if event_matches(ev, &entry.filter) {
3593                        inner.push(ev.clone());
3594                    }
3595                }
3596                true
3597            });
3598
3599            // Turn off delta accumulation if all subscribers dropped and no views remain.
3600            if self.subscriptions.is_empty() && self.view_store.is_empty() {
3601                self.engine.set_emit_deltas(false);
3602            }
3603        }
3604
3605        // Query subscriptions: full re-run per commit, then diff rows.
3606        // IMPORTANT: full re-execution on every commit — use LIMIT to bound cost.
3607        // Differential evaluation is roadmap / Phase 5.
3608        if !self.query_subscriptions.is_empty() {
3609            // Take the list out so we can call self.view() without borrow conflict.
3610            let mut query_subs = std::mem::take(&mut self.query_subscriptions);
3611            let empty_params = BTreeMap::new();
3612            query_subs.retain_mut(|entry| {
3613                let Some(inner) = entry.inner.upgrade() else {
3614                    return false; // subscriber dropped — prune
3615                };
3616                let result = match execute(&self.view(), &entry.ops, &Params(&empty_params)) {
3617                    Ok(r) => r,
3618                    Err(e) => {
3619                        // Keep the subscription alive; skip the diff for this commit.
3620                        // Re-run errors are transient (e.g., planner change) and
3621                        // self-heal when the next commit succeeds.
3622                        eprintln!("[mushroomdb] subscribe_query re-run failed: {e}");
3623                        return true;
3624                    }
3625                };
3626                // Build new row map: serialized-key → row data.
3627                let new_row_map = Self::result_to_row_map(&result);
3628                // Removed rows: in prev but not in new.
3629                for (key, row) in &entry.prev_row_map {
3630                    if !new_row_map.contains_key(key) {
3631                        inner.push(DbEvent::QueryRowRemoved {
3632                            columns: entry.columns.clone(),
3633                            row: row.clone(),
3634                        });
3635                    }
3636                }
3637                // Added rows: in new but not in prev.
3638                for (key, row) in &new_row_map {
3639                    if !entry.prev_row_map.contains_key(key) {
3640                        inner.push(DbEvent::QueryRowAdded {
3641                            columns: entry.columns.clone(),
3642                            row: row.clone(),
3643                        });
3644                    }
3645                }
3646                entry.prev_row_map = new_row_map;
3647                true
3648            });
3649            self.query_subscriptions = query_subs;
3650        }
3651    }
3652
3653    /// Returns `true` if any live subscriber or view definition requires delta
3654    /// accumulation. Used to set `engine.emit_deltas` on subscribe/view DDL.
3655    fn needs_emit_deltas(&self) -> bool {
3656        !self.view_store.is_empty()
3657            || self
3658                .subscriptions
3659                .iter()
3660                .any(|e| e.inner.upgrade().is_some())
3661    }
3662
3663    /// Convert a WAL record into `DbEvent` write events with the given seq.
3664    fn write_events_from_record(
3665        rec: &WalRecord,
3666        seq: u64,
3667        intern: &Interner,
3668        ids: &IdMap,
3669    ) -> Vec<DbEvent> {
3670        match rec {
3671            WalRecord::InsertNode { label, key, .. } => vec![DbEvent::NodeInserted {
3672                label: label.clone(),
3673                key: key.clone(),
3674                commit_seq: seq,
3675            }],
3676            // *Id arms run after a successful apply, so resolution can only
3677            // fail on a programming error. Skip the event rather than emit a
3678            // fabricated "" that clients can't tell from a real empty value
3679            // (mirrors event_from_record returning None).
3680            WalRecord::InsertNodeId { label, key, .. } => intern
3681                .resolve(*label)
3682                .map(|label| DbEvent::NodeInserted {
3683                    label: label.to_string(),
3684                    key: key.clone(),
3685                    commit_seq: seq,
3686                })
3687                .into_iter()
3688                .collect(),
3689            WalRecord::SetProp { key, field, .. } => vec![DbEvent::PropSet {
3690                key: key.clone(),
3691                field: field.clone(),
3692                commit_seq: seq,
3693            }],
3694            WalRecord::SetPropId { id, field, .. } => ids
3695                .key_of(*id)
3696                .zip(intern.resolve(*field))
3697                .map(|(key, field)| DbEvent::PropSet {
3698                    key: key.to_string(),
3699                    field: field.to_string(),
3700                    commit_seq: seq,
3701                })
3702                .into_iter()
3703                .collect(),
3704            WalRecord::RemoveProp { key, field } => vec![DbEvent::PropRemoved {
3705                key: key.clone(),
3706                field: field.clone(),
3707                commit_seq: seq,
3708            }],
3709            WalRecord::InsertEdge {
3710                edge_type,
3711                src_key,
3712                dst_key,
3713            } => vec![DbEvent::EdgeInserted {
3714                edge_type: edge_type.clone(),
3715                src: src_key.clone(),
3716                dst: dst_key.clone(),
3717                commit_seq: seq,
3718            }],
3719            WalRecord::InsertEdgeId { etype, src, dst } => (|| {
3720                Some(DbEvent::EdgeInserted {
3721                    edge_type: intern.resolve(*etype)?.to_string(),
3722                    src: ids.key_of(*src)?.to_string(),
3723                    dst: ids.key_of(*dst)?.to_string(),
3724                    commit_seq: seq,
3725                })
3726            })()
3727            .into_iter()
3728            .collect(),
3729            WalRecord::DeleteEdge {
3730                edge_type,
3731                src_key,
3732                dst_key,
3733            } => vec![DbEvent::EdgeDeleted {
3734                edge_type: edge_type.clone(),
3735                src: src_key.clone(),
3736                dst: dst_key.clone(),
3737                commit_seq: seq,
3738            }],
3739            WalRecord::DeleteNode { key } => vec![DbEvent::NodeDeleted {
3740                key: key.clone(),
3741                commit_seq: seq,
3742            }],
3743            WalRecord::Batch(inner) => inner
3744                .iter()
3745                .flat_map(|r| Self::write_events_from_record(r, seq, intern, ids))
3746                .collect(),
3747            WalRecord::CreateRule { .. }
3748            | WalRecord::DeleteRule { .. }
3749            | WalRecord::RebuildRule { .. }
3750            | WalRecord::CreateView { .. }
3751            | WalRecord::DeleteView { .. }
3752            | WalRecord::EnableFulltext { .. }
3753            | WalRecord::DisableFulltext { .. }
3754            | WalRecord::Intern { .. }
3755            // History markers produce no DbEvent — the engine delta already
3756            // fired the EdgeFired/EdgeRetracted subscription events.
3757            | WalRecord::DerivedEdgeAdded { .. }
3758            | WalRecord::DerivedEdgeRetracted { .. }
3759            | WalRecord::RenameNode { .. } => vec![],
3760        }
3761    }
3762
3763    /// Subscribe to edge-fire and edge-retract events for one named rule.
3764    ///
3765    /// Returns `Err(GraphError::RuleNotFound)` if `rule_name` is not
3766    /// currently registered. Dropping the returned [`Subscription`] handle
3767    /// unregisters the subscriber — no further events are queued, no
3768    /// resources leak.
3769    pub fn subscribe_rule(&mut self, rule_name: &str) -> core_storage::Result<Subscription> {
3770        if self.read_only {
3771            return Err(core_storage::GraphError::ReadOnly);
3772        }
3773        if !self.engine.rules().any(|r| r.name == rule_name) {
3774            return Err(core_storage::GraphError::RuleNotFound {
3775                name: rule_name.to_string(),
3776            });
3777        }
3778        let inner = SubInner::new(self.sub_capacity());
3779        self.subscriptions.push(SubEntry {
3780            filter: SubFilter::Rule(rule_name.to_string()),
3781            inner: std::sync::Arc::downgrade(&inner),
3782        });
3783        self.engine.set_emit_deltas(true);
3784        Ok(Subscription(inner))
3785    }
3786
3787    /// Subscribe to edge-fire and edge-retract events for **all** rules.
3788    ///
3789    /// Returns `Err(GraphError::ReadOnly)` if called on an as-of instance —
3790    /// as-of instances never commit, so `distribute_events` never runs and the
3791    /// subscription would never deliver events.
3792    pub fn subscribe_all_rules(&mut self) -> core_storage::Result<Subscription> {
3793        if self.read_only {
3794            return Err(core_storage::GraphError::ReadOnly);
3795        }
3796        let inner = SubInner::new(self.sub_capacity());
3797        self.subscriptions.push(SubEntry {
3798            filter: SubFilter::AllRules,
3799            inner: std::sync::Arc::downgrade(&inner),
3800        });
3801        self.engine.set_emit_deltas(true);
3802        Ok(Subscription(inner))
3803    }
3804
3805    /// Subscribe to write events: node insert/delete, prop set/remove.
3806    ///
3807    /// Does not include edge-fire / edge-retract (rule-derived edge events).
3808    ///
3809    /// Returns `Err(GraphError::ReadOnly)` if called on an as-of instance —
3810    /// as-of instances never commit, so `distribute_events` never runs and the
3811    /// subscription would never deliver events.
3812    pub fn subscribe_writes(&mut self) -> core_storage::Result<Subscription> {
3813        if self.read_only {
3814            return Err(core_storage::GraphError::ReadOnly);
3815        }
3816        let inner = SubInner::new(self.sub_capacity());
3817        self.subscriptions.push(SubEntry {
3818            filter: SubFilter::Writes,
3819            inner: std::sync::Arc::downgrade(&inner),
3820        });
3821        self.engine.set_emit_deltas(true);
3822        Ok(Subscription(inner))
3823    }
3824
3825    /// Subscribe to incremental Cypher query results.
3826    ///
3827    /// Parses and plans `cypher`; rejects the query if the plan is not in the
3828    /// allowlisted subset (see [`core_query::cypher::is_subscribable`]):
3829    ///   - `MATCH (n:Label) WHERE … RETURN … [LIMIT n]`
3830    ///   - `MATCH (a)-[r:TYPE]->(b) RETURN … [LIMIT n]`  (exactly one hop)
3831    ///
3832    /// SKIP is not supported — it shifts the result window on every commit,
3833    /// causing spurious Added/Removed churn for rows whose data never changed.
3834    /// Multi-hop Expand chains are not supported; each additional MATCH clause
3835    /// widens scope beyond the documented single-scan / single-hop subset.
3836    ///
3837    /// After each successful commit, the plan is **fully re-executed** and the
3838    /// result is diffed against the previous run. Added rows produce
3839    /// [`DbEvent::QueryRowAdded`]; removed rows produce
3840    /// [`DbEvent::QueryRowRemoved`].
3841    ///
3842    /// **Full re-run per commit; use LIMIT to bound execution cost.**
3843    /// The existing 1 M intermediate-row cap applies. Differential evaluation
3844    /// is roadmap / Phase 5.
3845    ///
3846    /// Returns `Err(GraphError::ReadOnly)` if called on an as-of instance —
3847    /// as-of instances never commit, so `distribute_events` never runs and the
3848    /// subscription would never deliver events.
3849    ///
3850    /// Returns `Err(GraphError::QueryError)` if the query fails to parse, plan,
3851    /// or if the plan shape is not in the allowlist.
3852    pub fn subscribe_query(&mut self, cypher: &str) -> Result<Subscription> {
3853        if self.read_only {
3854            return Err(GraphError::ReadOnly);
3855        }
3856        let tokens = lex(cypher).map_err(|e| GraphError::QueryError {
3857            detail: format!("lex: {e}"),
3858        })?;
3859        let ast = parse(&tokens).map_err(|e| GraphError::QueryError {
3860            detail: format!("parse: {e}"),
3861        })?;
3862        let ops = plan(&ast).map_err(|e| GraphError::QueryError {
3863            detail: format!("plan: {e}"),
3864        })?;
3865        if !is_subscribable(&ops) {
3866            return Err(GraphError::QueryError {
3867                detail: "subscribe_query only supports allowlisted plan shapes: \
3868                         MATCH (n:Label) WHERE … RETURN … [LIMIT n] or \
3869                         MATCH (a)-[r:TYPE]->(b) RETURN … [LIMIT n] (exactly one hop). \
3870                         Not supported: multi-hop Expand chains, SKIP (creates \
3871                         unstable offset windows), ORDER BY, DISTINCT, aggregates, \
3872                         variable-length paths, OPTIONAL MATCH, WITH, UNWIND. \
3873                         Use LIMIT to bound re-execution cost."
3874                    .to_string(),
3875            });
3876        }
3877        // Execute once to capture initial state (initial rows are not emitted as
3878        // events — the subscriber learns the baseline via the first query call).
3879        let empty_params = BTreeMap::new();
3880        let initial = execute(&self.view(), &ops, &Params(&empty_params)).map_err(|e| {
3881            GraphError::QueryError {
3882                detail: format!("execute: {e}"),
3883            }
3884        })?;
3885        let columns = initial.columns().to_vec();
3886        let prev_row_map = Self::result_to_row_map(&initial);
3887        let inner = SubInner::new(self.sub_capacity());
3888        self.query_subscriptions.push(QuerySubEntry {
3889            ops,
3890            columns,
3891            prev_row_map,
3892            inner: std::sync::Arc::downgrade(&inner),
3893        });
3894        Ok(Subscription(inner))
3895    }
3896
3897    /// Queue capacity used for new subscriptions.
3898    fn sub_capacity(&self) -> usize {
3899        self.sub_capacity
3900    }
3901
3902    /// Override per-subscriber queue capacity for subsequently created
3903    /// subscriptions on this db instance.
3904    ///
3905    /// Default is [`DEFAULT_SUB_CAPACITY`] (65,536 events). Use a smaller
3906    /// value in tests to exercise the [`DbEvent::Lagged`] path without
3907    /// generating tens of thousands of events.
3908    ///
3909    /// This is a test-support escape hatch. Calling it in production reduces
3910    /// subscriber reliability (more Lagged events). It is hidden from rustdoc
3911    /// to discourage accidental production use.
3912    #[doc(hidden)]
3913    pub fn set_sub_capacity(&mut self, capacity: usize) {
3914        self.sub_capacity = capacity;
3915    }
3916
3917    // -----------------------------------------------------------------------
3918
3919    /// Start an atomic batch.
3920    ///
3921    /// The returned [`BatchBuilder`] borrows `self` mutably until
3922    /// [`BatchBuilder::commit`]. Builder methods queue ops only — no
3923    /// validation, no WAL I/O. `commit` validates every queued op against
3924    /// live state plus preceding ops in this batch (duplicate key inside
3925    /// the batch is `Err`; an edge between two nodes created earlier in
3926    /// the batch is valid; `delete_node` then insert of the same key is a
3927    /// fresh identity). Validation never mutates the database. Any failure
3928    /// leaves WAL bytes and in-memory state identical to before `commit`.
3929    /// On success, one `WalRecord::Batch` frame is appended (one fsync)
3930    /// and each inner record is applied in order so rules fire per record.
3931    /// An empty batch, or a batch of only no-ops, writes zero WAL bytes.
3932    ///
3933    /// **Rule-window limitation:** batch validation cannot see edges that a
3934    /// rule created earlier in the *same* batch will derive at apply time, so
3935    /// a `delete_edge` / `insert_edge` in that window is silently no-oped
3936    /// where sequential calls would return `Err(RuleOwned)`. State integrity
3937    /// is unaffected (idempotent apply, provenance intact). Create rules in
3938    /// their own batch, or sequentially, when later ops may touch derived
3939    /// edges.
3940    pub fn batch(&mut self) -> BatchBuilder<'_, F> {
3941        BatchBuilder {
3942            db: self,
3943            ops: Vec::new(),
3944        }
3945    }
3946
3947    /// Closure-style atomic write batch.
3948    ///
3949    /// Equivalent to calling [`GraphDb::batch`], invoking `build` to queue ops,
3950    /// then committing. All ops queued inside `build` are validated in order and
3951    /// committed as a single `WalRecord::Batch` frame (one fsync). Rules fire
3952    /// once per inner record, in order, after commit — semantically identical to
3953    /// sequential single-op writes.
3954    ///
3955    /// **Error semantics — validate-then-apply.** `build` queues ops without
3956    /// touching the database. [`BatchBuilder::commit`] validates every op against
3957    /// live state plus earlier ops in this batch before writing anything. If op N
3958    /// fails validation (duplicate key, unknown key, rule-owned edge, …) the
3959    /// entire batch is rejected: no WAL bytes are written and no in-memory state
3960    /// changes. The database is identical to its state before `write_batch` was
3961    /// called.
3962    ///
3963    /// **Atomicity is crash-level, NOT isolation-level.** On replay after a crash,
3964    /// a partial (torn) `Batch` frame applies NONE of its ops — the frame is
3965    /// either fully applied or not at all. However, while applying a committed
3966    /// batch, concurrent readers may observe intermediate states as ops are applied
3967    /// sequentially in memory. There is no interactive transaction isolation in v1.
3968    /// This is documented as "crash-atomic write batches; no interactive
3969    /// transactions or read isolation."
3970    ///
3971    /// **Returns** `(nodes_inserted, edges_inserted)`. An empty or all-noop batch
3972    /// writes zero WAL bytes and returns `(0, 0)`.
3973    ///
3974    /// # Example
3975    ///
3976    /// ```rust,ignore
3977    /// let (nodes, edges) = db.write_batch(|b| {
3978    ///     b.insert_node("Person", "alice", vec![("age".into(), Value::Int(30))]);
3979    ///     b.insert_node("Person", "bob", vec![]);
3980    ///     b.insert_edge("KNOWS", "alice", "bob");
3981    ///     b.set_prop("alice", "role", Value::Str("admin".into()));
3982    ///     b.delete_node("old_key");
3983    /// })?;
3984    /// // One fsync; on crash replay: all five ops land or none do.
3985    /// ```
3986    pub fn write_batch<C>(&mut self, build: C) -> Result<(usize, usize)>
3987    where
3988        C: FnOnce(&mut BatchBuilder<'_, F>),
3989    {
3990        let mut b = self.batch();
3991        build(&mut b);
3992        b.commit()
3993    }
3994
3995    /// Insert `rows` as nodes of `label`. One call is one atomic batch:
3996    /// auto-declared KeyMatch rules (if any) first, then the accepted node
3997    /// inserts, so incremental fire sees the new rules. Per-row key problems
3998    /// are collected in [`IngestReport::row_errors`] and skipped; a commit
3999    /// `Err` means nothing was applied.
4000    ///
4001    /// Auto-FK rule names are `auto_fk_<src_label_lowercase>_<field>` so
4002    /// distinct source labels sharing an FK field each get their own rule.
4003    pub fn ingest(
4004        &mut self,
4005        label: &str,
4006        rows: Vec<BTreeMap<String, Value>>,
4007        opts: &IngestOptions,
4008    ) -> Result<IngestReport> {
4009        self.ingest_with_edges(label, rows, opts, &[])
4010    }
4011
4012    /// [`ingest`] plus user edges in the **same** previewed WAL batch.
4013    /// A failing edge rejects the whole request; nothing is applied.
4014    pub fn ingest_with_edges(
4015        &mut self,
4016        label: &str,
4017        rows: Vec<BTreeMap<String, Value>>,
4018        opts: &IngestOptions,
4019        edges: &[(String, String, String)],
4020    ) -> Result<IngestReport> {
4021        crate::ingest::run(self, label, rows, opts, edges)
4022    }
4023
4024    /// Parse `json` as an array of objects and ingest via [`GraphDb::ingest`].
4025    ///
4026    /// JSON `null` fields are silently omitted (not stored, not a row error).
4027    /// Nested objects and arrays-of-objects are a per-row error (row skipped).
4028    /// Parse failures and a top-level value that is not an array of objects
4029    /// return [`GraphError::IngestError`].
4030    pub fn ingest_json(
4031        &mut self,
4032        label: &str,
4033        json: &str,
4034        opts: &IngestOptions,
4035    ) -> Result<IngestReport> {
4036        crate::ingest::run_json(self, label, json, opts)
4037    }
4038
4039    fn commit_logged_batch(
4040        &mut self,
4041        ops: Vec<BatchOp>,
4042        ingest: Option<(String, usize)>,
4043        // Two-source rule: write_batch_authz threads authz here directly (never
4044        // touches pending_write_authz); query_write_authz sets the field instead
4045        // and passes None.  Only one source is non-None per call.
4046        param_authz: Option<WriteAuthz>,
4047    ) -> Result<(usize, usize)> {
4048        // Read-only guard: catches empty-batch calls before the early-return
4049        // that skips log_then_apply_with, ensuring all mutation entry points fail.
4050        if self.read_only {
4051            return Err(GraphError::ReadOnly);
4052        }
4053        // Ensure provenance is decoded before MutPreview accesses it
4054        // (note_delete_rule / is_rule_owned may call engine.provenance()).
4055        self.engine.ensure_provenance_loaded_mut();
4056
4057        // ── Authz pre-check ──────────────────────────────────────────────────
4058        // Evaluate the decision table per-op BEFORE MutPreview so that a denial
4059        // produces no WAL frame (all-or-nothing at the authz boundary extends
4060        // the existing validate-then-apply contract to role-scope checks).
4061        //
4062        // `batch_created` tracks key→label for nodes created by earlier ops in
4063        // THIS batch, so InsertEdgeUpsert can count same-batch placeholder nodes
4064        // as visible without needing to call `self.ids.get` on not-yet-committed
4065        // keys (they won't be there yet).
4066        //
4067        // Two-source rule: param_authz (write_batch_authz path) takes precedence;
4068        // fall back to self.pending_write_authz (query_write_authz/Cypher path).
4069        // Cloning the field copy avoids a simultaneous borrow of self.ids below.
4070        let authz_opt = param_authz.or_else(|| self.pending_write_authz.clone());
4071        if let Some(ref authz) = authz_opt {
4072            let mut batch_created: BTreeMap<String, String> = BTreeMap::new();
4073            for op in &ops {
4074                self.check_single_op_authz(authz, op, &batch_created)?;
4075                // Update batch_created after a passing authz check so that
4076                // subsequent ops in this batch see the nodes as "about to exist".
4077                match op {
4078                    BatchOp::InsertNode { label, key, .. } => {
4079                        // Only track genuinely new nodes (absent from the
4080                        // snapshot at authz-check time). A pre-existing visible
4081                        // key would be a DuplicateKey — not a real creation —
4082                        // so MutPreview handles it. Letting it into batch_created
4083                        // would allow a later SetProp to bypass update_labels
4084                        // via the "batch-created → always updatable" ruling
4085                        // (delete+recreate exploit, fix for I1 review round 2).
4086                        //
4087                        // Accepted edge: for a delete+recreate-with-different-
4088                        // label batch, node_status resolves the pre-delete
4089                        // (store) label for any subsequent update checks. This
4090                        // grants no net-new capability — a role that can delete+
4091                        // create can already place arbitrary props via
4092                        // InsertNode's own props field.
4093                        if self.ids.get(key.as_str()).is_none() {
4094                            batch_created.insert(key.clone(), label.clone());
4095                        }
4096                    }
4097                    BatchOp::InsertEdgeUpsert {
4098                        placeholder_label,
4099                        src_key,
4100                        dst_key,
4101                        ..
4102                    } => {
4103                        // Both endpoints will be created if not already in store.
4104                        for ep_key in [src_key, dst_key] {
4105                            if self.ids.get(ep_key.as_str()).is_none()
4106                                && !batch_created.contains_key(ep_key.as_str())
4107                            {
4108                                batch_created.insert(ep_key.clone(), placeholder_label.clone());
4109                            }
4110                        }
4111                    }
4112                    _ => {}
4113                }
4114            }
4115        }
4116
4117        let recs = {
4118            let mut preview = MutPreview::new(self);
4119            let mut recs = Vec::with_capacity(ops.len());
4120            for op in ops {
4121                match op {
4122                    BatchOp::InsertNode { label, key, props } => {
4123                        preview.check_insert_node(&key)?;
4124                        preview.note_insert_node(&key, &props);
4125                        recs.push(WalRecord::InsertNode { label, key, props });
4126                    }
4127                    BatchOp::InsertEdge {
4128                        edge_type,
4129                        src_key,
4130                        dst_key,
4131                    } => {
4132                        if preview.prepare_insert_edge(&edge_type, &src_key, &dst_key)? {
4133                            preview.note_insert_edge(&edge_type, &src_key, &dst_key);
4134                            recs.push(WalRecord::InsertEdge {
4135                                edge_type,
4136                                src_key,
4137                                dst_key,
4138                            });
4139                        }
4140                    }
4141                    BatchOp::SetProp { key, field, value } => {
4142                        preview.check_live_key(&key)?;
4143                        preview.note_set_prop(&key, &field, &value);
4144                        recs.push(WalRecord::SetProp { key, field, value });
4145                    }
4146                    BatchOp::RemoveProp { key, field } => {
4147                        if preview.prepare_remove_prop(&key, &field)? {
4148                            preview.note_remove_prop(&key, &field);
4149                            recs.push(WalRecord::RemoveProp { key, field });
4150                        }
4151                    }
4152                    BatchOp::DeleteEdge {
4153                        edge_type,
4154                        src_key,
4155                        dst_key,
4156                    } => {
4157                        if preview.prepare_delete_edge(&edge_type, &src_key, &dst_key)? {
4158                            preview.note_delete_edge(&edge_type, &src_key, &dst_key);
4159                            recs.push(WalRecord::DeleteEdge {
4160                                edge_type,
4161                                src_key,
4162                                dst_key,
4163                            });
4164                        }
4165                    }
4166                    BatchOp::DeleteNode { key } => {
4167                        preview.check_live_key(&key)?;
4168                        preview.note_delete_node(&key);
4169                        recs.push(WalRecord::DeleteNode { key });
4170                    }
4171                    BatchOp::CreateRule(def) => {
4172                        preview.check_create_rule(&def)?;
4173                        let def_bytes =
4174                            bincode::serialize(&def).map_err(|e| GraphError::Corrupt {
4175                                detail: format!("serialize rule: {e}"),
4176                            })?;
4177                        preview.note_create_rule(&def.name);
4178                        recs.push(WalRecord::CreateRule { def_bytes });
4179                    }
4180                    BatchOp::DeleteRule { name } => {
4181                        preview.check_delete_rule(&name)?;
4182                        preview.note_delete_rule(&name);
4183                        recs.push(WalRecord::DeleteRule { name });
4184                    }
4185                    BatchOp::RenameNode { old_key, new_key } => {
4186                        preview.check_rename_node(&old_key, &new_key)?;
4187                        preview.note_rename_node(&old_key, &new_key);
4188                        recs.push(WalRecord::RenameNode { old_key, new_key });
4189                    }
4190                    BatchOp::InsertEdgeUpsert {
4191                        edge_type,
4192                        src_key,
4193                        dst_key,
4194                        placeholder_label,
4195                    } => {
4196                        // Auto-create any missing endpoints as plain InsertNode ops.
4197                        // Rules fire and last-change is updated for each created node.
4198                        for key in [&src_key, &dst_key] {
4199                            if !preview.has_key(key) {
4200                                preview.check_insert_node(key)?;
4201                                preview.note_insert_node(key, &[]);
4202                                recs.push(WalRecord::InsertNode {
4203                                    label: placeholder_label.clone(),
4204                                    key: key.clone(),
4205                                    props: vec![],
4206                                });
4207                            }
4208                        }
4209                        if preview.prepare_insert_edge(&edge_type, &src_key, &dst_key)? {
4210                            preview.note_insert_edge(&edge_type, &src_key, &dst_key);
4211                            recs.push(WalRecord::InsertEdge {
4212                                edge_type,
4213                                src_key,
4214                                dst_key,
4215                            });
4216                        }
4217                    }
4218                }
4219            }
4220            recs
4221        };
4222        if recs.is_empty() {
4223            return Ok((0, 0));
4224        }
4225        // rewrite_wal_dense converts every InsertNode/InsertEdge into its
4226        // *Id form, so only the dense variants can appear in `recs` here.
4227        let recs = self.rewrite_wal_dense(recs)?;
4228        let nodes_inserted = recs
4229            .iter()
4230            .filter(|r| matches!(r, WalRecord::InsertNodeId { .. }))
4231            .count();
4232        let edges_inserted = recs
4233            .iter()
4234            .filter(|r| matches!(r, WalRecord::InsertEdgeId { .. }))
4235            .count();
4236        // Ingest / write_batch / query_write: one Batch frame, one fsync per call
4237        // under Strict.  Pass self.fsync directly so Strict stays Strict —
4238        // wal_needs_sync(Strict, _) always returns true regardless of op count.
4239        // Mapping Strict → Batched (the prior bug) caused wal_needs_sync to
4240        // short-circuit on single-op batches and silently skip the fsync.
4241        // Batched fsyncs only for multi-op batches; Relaxed always skips.
4242        self.log_then_apply_with(WalRecord::Batch(recs), ingest, self.fsync)?;
4243        Ok((nodes_inserted, edges_inserted))
4244    }
4245
4246    fn commit_batch(&mut self, ops: Vec<BatchOp>) -> Result<(usize, usize)> {
4247        self.commit_logged_batch(ops, None, None)
4248    }
4249
4250    /// Commit one submission WITHOUT an fsync — for use inside `commit_group`
4251    /// and the group-commit drain thread, which do a single group fsync later.
4252    fn commit_batch_nosync(&mut self, ops: Vec<BatchOp>) -> Result<(usize, usize)> {
4253        // Restore fsync policy even on panic via a raw-pointer drop guard.
4254        // A panic here would poison the RwLock anyway, but the correct policy
4255        // must be in place if the guard is ever unwrapped.
4256        struct RestoreFsync(*mut FsyncPolicy, FsyncPolicy);
4257        impl Drop for RestoreFsync {
4258            fn drop(&mut self) {
4259                // SAFETY: the pointer is valid for the full duration of
4260                // commit_batch_nosync; the guard is dropped before the frame
4261                // returns, and GraphDb outlives this frame.
4262                unsafe {
4263                    *self.0 = self.1;
4264                }
4265            }
4266        }
4267        let saved = self.fsync;
4268        // SAFETY: raw pointer into self; guard dropped within this frame.
4269        let _g = RestoreFsync(&mut self.fsync as *mut FsyncPolicy, saved);
4270        self.fsync = FsyncPolicy::Relaxed;
4271        self.commit_logged_batch(ops, None, None)
4272    }
4273
4274    /// Commit multiple op-batches as a **group**: each submission gets its own
4275    /// WAL `Batch` frame, but there is exactly **one** `Fs::sync` for the whole
4276    /// group (under `Strict` / `Batched` policy; `Relaxed` skips all syncs).
4277    ///
4278    /// # Durability semantics
4279    ///
4280    /// A crash before the group fsync may lose **all** submissions in the group.
4281    /// A crash after the group fsync preserves all of them.  No submission is
4282    /// ever torn: each WAL frame is either fully applied on replay or dropped
4283    /// in its entirety (CRC-protected frame boundaries).
4284    ///
4285    /// Events and subscription notifications fire per-submission immediately
4286    /// after apply, which may be before the group fsync.  From a subscriber's
4287    /// perspective this is equivalent to the `Relaxed` durability window.
4288    /// Submitters using [`SharedDb::submit_batch`] only unblock after the group
4289    /// fsync, so from their perspective durability is fully guaranteed.
4290    ///
4291    /// # MVCC interplay
4292    ///
4293    /// Each submission records its own `CommitDelta`; the fold-every-K counter
4294    /// increments per submission (not per group), preserving existing reader
4295    /// snapshot semantics.
4296    ///
4297    /// # Returns
4298    ///
4299    /// One `Result<(nodes_inserted, edges_inserted)>` per input group element,
4300    /// in order.  Failures are per-submission (validation errors); the group
4301    /// fsync error (if any) is returned as the second tuple element.
4302    pub fn commit_group(
4303        &mut self,
4304        groups: Vec<Vec<BatchOp>>,
4305    ) -> (Vec<Result<(usize, usize)>>, Option<GraphError>) {
4306        let mut results = Vec::with_capacity(groups.len());
4307        for ops in groups {
4308            results.push(self.commit_batch_nosync(ops));
4309        }
4310        let any_ok = results.iter().any(|r| r.is_ok());
4311        let sync_err = if self.fsync != FsyncPolicy::Relaxed && any_ok {
4312            self.fs
4313                .sync(core_storage::fs::FileId::Wal)
4314                .map_err(GraphError::Io)
4315                .err()
4316        } else {
4317            None
4318        };
4319        (results, sync_err)
4320    }
4321
4322    /// Like [`commit_group`] but skips the group fsync entirely.
4323    ///
4324    /// Used by the drain thread to apply submissions under the write lock and
4325    /// then perform the single fsync OUTSIDE the lock (via
4326    /// `core_storage::sync_wal_at`), reducing the write-lock hold time visible
4327    /// to concurrent readers.
4328    pub fn commit_group_nosync(
4329        &mut self,
4330        groups: Vec<Vec<BatchOp>>,
4331    ) -> Vec<Result<(usize, usize)>> {
4332        let mut results = Vec::with_capacity(groups.len());
4333        for ops in groups {
4334            results.push(self.commit_batch_nosync(ops));
4335        }
4336        results
4337    }
4338
4339    pub fn insert_node(
4340        &mut self,
4341        label: &str,
4342        key: &str,
4343        props: Vec<(String, Value)>,
4344    ) -> Result<()> {
4345        if self.read_only {
4346            return Err(GraphError::ReadOnly);
4347        }
4348        MutPreview::new(self).check_insert_node(key)?;
4349        self.log_dense(vec![WalRecord::InsertNode {
4350            label: label.into(),
4351            key: key.into(),
4352            props,
4353        }])
4354    }
4355
4356    pub fn insert_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) -> Result<bool> {
4357        if self.read_only {
4358            return Err(GraphError::ReadOnly);
4359        }
4360        if !MutPreview::new(self).prepare_insert_edge(edge_type, src_key, dst_key)? {
4361            return Ok(false);
4362        }
4363        self.log_dense(vec![WalRecord::InsertEdge {
4364            edge_type: edge_type.into(),
4365            src_key: src_key.into(),
4366            dst_key: dst_key.into(),
4367        }])?;
4368        Ok(true)
4369    }
4370
4371    pub fn set_prop(&mut self, key: &str, field: &str, value: Value) -> Result<()> {
4372        if self.read_only {
4373            return Err(GraphError::ReadOnly);
4374        }
4375        if let Some(view_name) = self.view_store.view_for_prop(field) {
4376            return Err(GraphError::ViewPropReadOnly {
4377                view_name: view_name.to_string(),
4378            });
4379        }
4380        MutPreview::new(self).check_live_key(key)?;
4381        self.log_dense(vec![WalRecord::SetProp {
4382            key: key.into(),
4383            field: field.into(),
4384            value,
4385        }])
4386    }
4387
4388    /// Remove a property. Returns `Ok(false)` (and does not log) if the field
4389    /// is already absent. Unknown or tombstoned keys are `Err(KeyNotFound)`.
4390    pub fn remove_prop(&mut self, key: &str, field: &str) -> Result<bool> {
4391        if self.read_only {
4392            return Err(GraphError::ReadOnly);
4393        }
4394        if let Some(view_name) = self.view_store.view_for_prop(field) {
4395            return Err(GraphError::ViewPropReadOnly {
4396                view_name: view_name.to_string(),
4397            });
4398        }
4399        if !MutPreview::new(self).prepare_remove_prop(key, field)? {
4400            return Ok(false);
4401        }
4402        self.log_then_apply(WalRecord::RemoveProp {
4403            key: key.into(),
4404            field: field.into(),
4405        })?;
4406        Ok(true)
4407    }
4408
4409    /// Delete a user edge. Returns `Ok(false)` (and does not log) if the edge
4410    /// is absent. Unknown keys are `Err(KeyNotFound)`. Rule-owned edges — in
4411    /// provenance, or a pair a live rule would derive — are `Err(RuleOwned)`
4412    /// (the rule would just put the edge back; delete or change the rule).
4413    pub fn delete_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) -> Result<bool> {
4414        if self.read_only {
4415            return Err(GraphError::ReadOnly);
4416        }
4417        if !MutPreview::new(self).prepare_delete_edge(edge_type, src_key, dst_key)? {
4418            return Ok(false);
4419        }
4420        self.log_then_apply(WalRecord::DeleteEdge {
4421            edge_type: edge_type.into(),
4422            src_key: src_key.into(),
4423            dst_key: dst_key.into(),
4424        })?;
4425        Ok(true)
4426    }
4427
4428    /// Delete a live node. Unknown or already-tombstoned keys are
4429    /// `Err(KeyNotFound)` and are not logged. Validation runs before the WAL
4430    /// write; `apply` of a logged `DeleteNode` for an already-tombstoned key
4431    /// (crash window) is a clean no-op.
4432    ///
4433    /// Returns a [`DeleteReport`] with counts of manual and derived edges
4434    /// removed (computed from live state before the deletion is applied).
4435    pub fn delete_node(&mut self, key: &str) -> Result<DeleteReport> {
4436        if self.read_only {
4437            return Err(GraphError::ReadOnly);
4438        }
4439        // Provenance must be loaded before we query provenance_touching.
4440        self.engine.ensure_provenance_loaded_mut();
4441        let id = self
4442            .ids
4443            .get(key)
4444            .ok_or_else(|| GraphError::KeyNotFound { key: key.into() })?;
4445
4446        // Count edges before the delete is applied so we can report counts.
4447        let derived_set: BTreeSet<(u32, u32, u32)> = self
4448            .engine
4449            .provenance_touching(id)
4450            .map(|(_, etype, src, dst)| (etype, src, dst))
4451            .collect();
4452        let derived_edges = derived_set.len() as u64;
4453
4454        let mut total_topo = 0u64;
4455        let tv = self.topo_view();
4456        for et in tv.etypes() {
4457            total_topo += tv.neighbors(et, Direction::Out, id).len() as u64
4458                + tv.neighbors(et, Direction::In, id).len() as u64;
4459        }
4460        // For symmetric rules (e.g. Overlap), a→b and b→a are two separate directed
4461        // triples in both the topo scan (Out and In from id) and in provenance_touching.
4462        // The subtraction remains correct because both counts include both directions.
4463        let manual_edges = total_topo.saturating_sub(derived_edges);
4464
4465        self.log_then_apply(WalRecord::DeleteNode { key: key.into() })?;
4466        Ok(DeleteReport {
4467            manual_edges,
4468            derived_edges,
4469        })
4470    }
4471
4472    /// Rename a live node's key.  The dense id (and therefore all edges,
4473    /// props, history, and last-change tracking) is unaffected.
4474    ///
4475    /// Returns `Err(KeyNotFound)` if `old` is not a live key.
4476    /// Returns `Err(DuplicateKey)` if `new` is already live.
4477    pub fn rename_node(&mut self, old: &str, new: &str) -> Result<()> {
4478        if self.read_only {
4479            return Err(GraphError::ReadOnly);
4480        }
4481        MutPreview::new(self).check_rename_node(old, new)?;
4482        self.log_then_apply(WalRecord::RenameNode {
4483            old_key: old.into(),
4484            new_key: new.into(),
4485        })
4486    }
4487
4488    /// Return the IVF drift counter for the dst-side candidate index of `rule`.
4489    /// `None` if the rule does not exist or is not approximate.
4490    ///
4491    /// The drift counter increments on IVF insert/remove after the last fit.
4492    /// When dst-side drift exceeds [`core_rules::IVF_DRIFT_REBUILD`], apply
4493    /// WAL-logs `RebuildRule` as a second commit (rebuild resets the counter).
4494    pub fn ivf_dst_drift(&self, rule: &str) -> Option<u64> {
4495        // SideIvfExport = (centroids, node→cluster, drift)
4496        self.engine
4497            .export_ivf_state()
4498            .remove(rule)
4499            .map(|(_src, dst)| dst.2)
4500    }
4501
4502    /// Validate and WAL-log a new rule, then backfill derived edges inside apply.
4503    /// Validation and duplicate-name check run before logging so invalid rules
4504    /// never enter the WAL.
4505    pub fn create_rule(&mut self, def: RuleDef) -> Result<()> {
4506        if self.read_only {
4507            return Err(GraphError::ReadOnly);
4508        }
4509        MutPreview::new(self).check_create_rule(&def)?;
4510        let def_bytes = bincode::serialize(&def).map_err(|e| GraphError::Corrupt {
4511            detail: format!("serialize rule: {e}"),
4512        })?;
4513        self.log_then_apply(WalRecord::CreateRule { def_bytes })
4514    }
4515
4516    /// WAL-log rule deletion. Returns RuleNotFound if the rule does not exist.
4517    pub fn delete_rule(&mut self, name: &str) -> Result<()> {
4518        if self.read_only {
4519            return Err(GraphError::ReadOnly);
4520        }
4521        MutPreview::new(self).check_delete_rule(name)?;
4522        self.log_then_apply(WalRecord::DeleteRule { name: name.into() })
4523    }
4524
4525    /// Return a snapshot of all registered rules.
4526    pub fn rules(&self) -> Vec<RuleDef> {
4527        self.engine.rules().cloned().collect()
4528    }
4529
4530    // -----------------------------------------------------------------------
4531    // Rule suggestion API
4532    // -----------------------------------------------------------------------
4533
4534    /// Profile the database and suggest linking rules with previewed edge counts.
4535    ///
4536    /// Uses the default seed ([`core_rules::SUGGEST_DEFAULT_SEED`]) for deterministic
4537    /// sampling. Suggestions are sorted by estimated edge count (descending).
4538    /// **NO auto-accept** — call [`GraphDb::create_rule`] explicitly to apply.
4539    pub fn suggest_rules(&self) -> Vec<core_rules::RuleSuggestion> {
4540        self.suggest_rules_seeded(core_rules::SUGGEST_DEFAULT_SEED)
4541    }
4542
4543    /// Like [`suggest_rules`] but with a caller-supplied RNG seed for
4544    /// reproducibility. Same seed + same data = identical output.
4545    pub fn suggest_rules_seeded(&self, seed: u64) -> Vec<core_rules::RuleSuggestion> {
4546        self.suggest_rules_with_config(&core_rules::suggest::SuggestConfig::default(), seed)
4547            .suggestions
4548    }
4549
4550    /// [`suggest_rules_seeded`] with a fully custom [`SuggestConfig`].
4551    ///
4552    /// Returns a [`core_rules::SuggestReport`] that includes both the candidate list
4553    /// and a `truncated` flag indicating whether the global budget fired before all
4554    /// candidates were evaluated.
4555    pub fn suggest_rules_with_config(
4556        &self,
4557        config: &core_rules::suggest::SuggestConfig,
4558        seed: u64,
4559    ) -> core_rules::SuggestReport {
4560        use std::collections::BTreeMap;
4561
4562        // Collect (node_id, key) pairs per label, skipping tombstoned nodes.
4563        let mut label_nodes: BTreeMap<String, Vec<(u32, String)>> = BTreeMap::new();
4564        for id in 0..self.ids.len() as u32 {
4565            let Some(key) = self.ids.key_of(id) else {
4566                continue;
4567            };
4568            let Some(&sym) = self.labels.get(id as usize) else {
4569                continue;
4570            };
4571            if sym == u32::MAX {
4572                continue; // tombstoned
4573            }
4574            let Some(label) = self.syms.resolve(sym) else {
4575                continue;
4576            };
4577            label_nodes
4578                .entry(label.to_string())
4579                .or_default()
4580                .push((id, key.to_string()));
4581        }
4582
4583        let existing = self.rules();
4584        let pv = build_props_view(&self.props, &self.base);
4585        let all_fields: Vec<String> = pv.field_names();
4586
4587        core_rules::suggest::suggest_rules(
4588            &label_nodes,
4589            &|id, field| pv.get(id, field).map(|vr| vr.into_value()),
4590            &all_fields,
4591            &existing,
4592            config,
4593            seed,
4594        )
4595    }
4596
4597    /// Recompute a rule's derived edges from scratch. WAL-logged so un-trip
4598    /// plus later mutations replay identically (rebuild is a pure function
4599    /// of state).
4600    ///
4601    /// Only exit from the tripped latch: if the full desired set fits the
4602    /// budget, it is applied completely and `tripped` clears; if it still
4603    /// exceeds the budget, provenance is left untouched and `tripped` stays
4604    /// true. Counts as a fire evaluation (see [`RuleStats::fires`]).
4605    /// Unknown rule → `RuleNotFound`, nothing logged.
4606    pub fn rebuild_rule(&mut self, name: &str) -> Result<()> {
4607        if self.read_only {
4608            return Err(GraphError::ReadOnly);
4609        }
4610        if !self.engine.rules().any(|r| r.name == name) {
4611            return Err(GraphError::RuleNotFound { name: name.into() });
4612        }
4613        self.log_then_apply(WalRecord::RebuildRule { name: name.into() })
4614    }
4615
4616    // -----------------------------------------------------------------------
4617    // Materialized view API
4618    // -----------------------------------------------------------------------
4619
4620    /// Register a new materialized property view, backfill its values for all
4621    /// existing nodes, and WAL-log the definition.
4622    ///
4623    /// # Errors
4624    /// - `ReadOnly`: called on an as-of instance.
4625    /// - `RuleInvalid`: name collision, view_prop collision, or invalid def.
4626    pub fn create_view(&mut self, def: ViewDef) -> Result<()> {
4627        if self.read_only {
4628            return Err(GraphError::ReadOnly);
4629        }
4630        // Pre-validate before WAL write.
4631        def.validate()
4632            .map_err(|e| GraphError::RuleInvalid { detail: e })?;
4633        if self.view_store.has_view(&def.name) {
4634            return Err(GraphError::RuleInvalid {
4635                detail: format!("view {:?} already exists", def.name),
4636            });
4637        }
4638        if let Some(existing) = self.view_store.view_for_prop(&def.view_prop) {
4639            return Err(GraphError::RuleInvalid {
4640                detail: format!(
4641                    "view_prop {:?} is already used by view {:?}",
4642                    def.view_prop, existing
4643                ),
4644            });
4645        }
4646        let def_bytes = bincode::serialize(&def).map_err(|e| GraphError::Corrupt {
4647            detail: format!("serialize view: {e}"),
4648        })?;
4649        // Enable delta accumulation before the view is registered so subsequent
4650        // incremental edge events reach view maintenance from this point onward.
4651        // (The backfill inside create_view reads topo directly; it does not rely
4652        // on pending deltas.)
4653        self.engine.set_emit_deltas(true);
4654        self.log_then_apply(WalRecord::CreateView { def_bytes })
4655    }
4656
4657    /// Remove a named view and delete its values from every node.
4658    ///
4659    /// # Errors
4660    /// - `ReadOnly`: called on an as-of instance.
4661    /// - `RuleNotFound`: view does not exist.
4662    pub fn delete_view(&mut self, name: &str) -> Result<()> {
4663        if self.read_only {
4664            return Err(GraphError::ReadOnly);
4665        }
4666        if !self.view_store.has_view(name) {
4667            return Err(GraphError::RuleNotFound { name: name.into() });
4668        }
4669        let result = self.log_then_apply(WalRecord::DeleteView { name: name.into() });
4670        // After deletion, disable accumulation if no listeners remain.
4671        if !self.needs_emit_deltas() {
4672            self.engine.set_emit_deltas(false);
4673        }
4674        result
4675    }
4676
4677    /// Snapshot of all registered view definitions.
4678    pub fn views(&self) -> Vec<ViewDef> {
4679        self.view_store.views().cloned().collect()
4680    }
4681
4682    // -----------------------------------------------------------------------
4683    // Full-text-lite API
4684    // -----------------------------------------------------------------------
4685
4686    /// Enable full-text indexing for all nodes of `label` on property `field`.
4687    ///
4688    /// After this call, every subsequent write to `(label, field)` is reflected
4689    /// in the index incrementally.  Existing nodes are backfilled immediately.
4690    /// The declaration is persisted as a WAL record; the index itself is rebuilt
4691    /// from scratch on re-open (no snapshot format changes).
4692    ///
4693    /// # Errors
4694    /// - [`GraphError::ReadOnly`]: called on an as-of instance.
4695    /// - [`GraphError::RuleInvalid`]: `(label, field)` is already indexed.
4696    pub fn enable_fulltext(&mut self, label: &str, field: &str) -> Result<()> {
4697        if self.read_only {
4698            return Err(GraphError::ReadOnly);
4699        }
4700        if self.fulltext.is_enabled(label, field) {
4701            return Err(GraphError::RuleInvalid {
4702                detail: format!("full-text index for ({label:?}, {field:?}) already enabled"),
4703            });
4704        }
4705        self.log_then_apply(WalRecord::EnableFulltext {
4706            label: label.into(),
4707            field: field.into(),
4708        })
4709    }
4710
4711    /// Disable full-text indexing for `(label, field)` and drop its postings.
4712    ///
4713    /// # Errors
4714    /// - [`GraphError::ReadOnly`]: called on an as-of instance.
4715    /// - [`GraphError::RuleNotFound`]: `(label, field)` is not currently indexed.
4716    pub fn disable_fulltext(&mut self, label: &str, field: &str) -> Result<()> {
4717        if self.read_only {
4718            return Err(GraphError::ReadOnly);
4719        }
4720        if !self.fulltext.is_enabled(label, field) {
4721            return Err(GraphError::RuleNotFound {
4722                name: format!("fulltext({label},{field})"),
4723            });
4724        }
4725        self.log_then_apply(WalRecord::DisableFulltext {
4726            label: label.into(),
4727            field: field.into(),
4728        })
4729    }
4730
4731    /// Whether `(label, field)` is currently indexed for full-text search.
4732    pub fn is_fulltext_enabled(&self, label: &str, field: &str) -> bool {
4733        self.fulltext.is_enabled(label, field)
4734    }
4735
4736    /// Search a full-text-indexed field.
4737    ///
4738    /// Returns `(node_key, match_count)` pairs sorted by match_count descending,
4739    /// ties broken by key (lexicographic).  Tombstoned nodes are excluded.
4740    ///
4741    /// **Query syntax:**
4742    /// - Space-separated terms are AND'd: `"foo bar"` requires both.
4743    /// - `OR` between terms forms disjunction: `"foo OR bar"` matches either.
4744    /// - Trailing `*` on a term is a prefix match: `"rust*"` matches `rustlang`, `rusty`.
4745    /// - `AND` keyword is accepted explicitly and is the default.
4746    /// - Tokenization is unicode-alphanumeric (same as index time); case-insensitive.
4747    ///
4748    /// **Unindexed field:** returns `Ok(vec![])` if `field` is not indexed.
4749    /// Pin: this is the documented, tested, stable behavior for v1.
4750    ///
4751    /// **Memory / performance:** O(postings) lookup; no scan.  The index is
4752    /// in-memory and proportional to total indexed text across all enabled fields.
4753    ///
4754    /// **v2 grammar:** supports `"phrase"`, `-negation`, `prefix*`, `OR`, `AND`.
4755    /// Results are BM25-scored (k1=1.2, b=0.75) and sorted by score descending,
4756    /// key ascending for deterministic tiebreaking.
4757    pub fn search(&self, field: &str, query: &str) -> Vec<(String, f64)> {
4758        // Resolve node_ids to keys (excluding tombstones) then re-sort by
4759        // (score DESC, key ASC) to give a deterministic, key-lexicographic
4760        // tiebreak.  FulltextIndex::search sorts by (score DESC, node_id ASC)
4761        // which diverges from key order when nodes were not inserted in key-lex order.
4762        let mut results: Vec<(String, f64)> = self
4763            .fulltext
4764            .search(field, query, 0)
4765            .into_iter()
4766            .filter_map(|(id, score)| self.ids.key_of(id).map(|key| (key.to_string(), score)))
4767            .collect();
4768        results.sort_by(|a, b| {
4769            b.1.partial_cmp(&a.1)
4770                .unwrap_or(std::cmp::Ordering::Equal)
4771                .then(a.0.cmp(&b.0))
4772        });
4773        results
4774    }
4775
4776    /// Hybrid search: Reciprocal Rank Fusion (RRF) over fulltext + vector results.
4777    ///
4778    /// Takes up to `4*k` fulltext hits for `(text_field, query_text)` and up to
4779    /// `4*k` vector hits for `(vector_field, query_vec, min=0.0)`, then fuses
4780    /// them with RRF using a fixed constant of 60.
4781    ///
4782    /// ```text
4783    /// score(d) = Σ  1 / (60 + rank_i(d))    (rank 1-based per list)
4784    /// ```
4785    ///
4786    /// Returns the top `k` nodes by fused score, ties broken by node key
4787    /// ascending (deterministic).
4788    ///
4789    /// # Vector leg fallback
4790    ///
4791    /// When `query_vec` is empty the vector leg is skipped entirely and
4792    /// results are ranked by the text list alone through the same RRF path
4793    /// (each text result scores `1/(60 + rank)` from that single list).
4794    ///
4795    /// When `label` is `None`, the vector leg **always** returns empty results.
4796    /// Internally `label` is mapped to `""`, which does not match any rule-created
4797    /// HNSW index (all such indexes are keyed to a specific non-empty label), and
4798    /// the brute-force fallback finds no nodes with an empty label.  The fused
4799    /// ranking is therefore text-only in this case.
4800    pub fn search_hybrid(
4801        &self,
4802        text_field: &str,
4803        query_text: &str,
4804        vector_field: &str,
4805        query_vec: &[f64],
4806        label: Option<&str>,
4807        k: usize,
4808    ) -> Vec<(String, f64)> {
4809        use std::collections::HashMap;
4810
4811        const RRF_K: f64 = 60.0;
4812        let pool = 4 * k;
4813
4814        // Accumulate per-node RRF scores.
4815        let mut scores: HashMap<String, f64> = HashMap::new();
4816
4817        // Text leg.
4818        let text_hits = self.search(text_field, query_text);
4819        for (rank0, (key, _count)) in text_hits.into_iter().take(pool).enumerate() {
4820            let rank = (rank0 + 1) as f64;
4821            *scores.entry(key).or_insert(0.0) += 1.0 / (RRF_K + rank);
4822        }
4823
4824        // Vector leg (skipped when query_vec is empty).
4825        if !query_vec.is_empty() {
4826            let vec_hits = self.find_similar_vector(vector_field, label, query_vec, pool, 0.0);
4827            for (rank0, (key, _sim)) in vec_hits.into_iter().enumerate() {
4828                let rank = (rank0 + 1) as f64;
4829                *scores.entry(key).or_insert(0.0) += 1.0 / (RRF_K + rank);
4830            }
4831        }
4832
4833        // Sort: score DESC, then key ASC for deterministic tie-breaking.
4834        let mut ranked: Vec<(String, f64)> = scores.into_iter().collect();
4835        ranked.sort_by(|a, b| {
4836            b.1.partial_cmp(&a.1)
4837                .unwrap_or(std::cmp::Ordering::Equal)
4838                .then(a.0.cmp(&b.0))
4839        });
4840        ranked.truncate(k);
4841        ranked
4842    }
4843
4844    /// For DST/testing: scratch BM25 search over live nodes without the index.
4845    /// Walks every live node, re-stems field tokens, computes corpus stats, and
4846    /// returns BM25-ranked results.
4847    ///
4848    /// The oracle: the ordered key list of `search(field, q)` must equal that of
4849    /// `scratch_search(field, q)` at every quiescent state.
4850    #[doc(hidden)]
4851    pub fn scratch_search(&self, field: &str, query: &str) -> Vec<(String, f64)> {
4852        use core_storage::fulltext::{parse_query, value_tokens_stemmed_with_positions};
4853        use std::collections::BTreeMap;
4854
4855        let groups = parse_query(query);
4856        if groups.is_empty() {
4857            return vec![];
4858        }
4859
4860        // --- Pass 1: collect all live indexed nodes with stemmed token data ---
4861        struct NodeData {
4862            key: String,
4863            /// stemmed_token → positions (sorted)
4864            tokens: BTreeMap<String, Vec<u32>>,
4865            dl: u32,
4866        }
4867
4868        let mut nodes: Vec<NodeData> = Vec::new();
4869        for id in 0..self.ids.len() as u32 {
4870            let Some(key) = self.ids.key_of(id) else {
4871                continue;
4872            };
4873            let Some(&sym) = self.labels.get(id as usize) else {
4874                continue;
4875            };
4876            if sym == u32::MAX {
4877                continue;
4878            }
4879            let label = match self.syms.resolve(sym) {
4880                Some(l) => l,
4881                None => continue,
4882            };
4883            if !self.fulltext.is_enabled(label, field) {
4884                continue;
4885            }
4886            let Some(value) = self.props_view().get(id, field).map(|vr| vr.into_value()) else {
4887                continue;
4888            };
4889            // Use value_tokens_stemmed_with_positions so list elements are
4890            // separated by POSITION_GAP — identical to the index path, which
4891            // prevents phrase queries from matching across element boundaries.
4892            let stemmed_with_pos = match &value {
4893                Value::Str(_) | Value::List(_) => value_tokens_stemmed_with_positions(&value),
4894                _ => continue,
4895            };
4896            let dl = stemmed_with_pos.len() as u32;
4897            let mut tok_map: BTreeMap<String, Vec<u32>> = BTreeMap::new();
4898            for (tok, pos) in stemmed_with_pos {
4899                tok_map.entry(tok).or_default().push(pos);
4900            }
4901            nodes.push(NodeData {
4902                key: key.to_string(),
4903                tokens: tok_map,
4904                dl,
4905            });
4906        }
4907
4908        if nodes.is_empty() {
4909            return vec![];
4910        }
4911
4912        // --- BM25 corpus stats ---
4913        let n = nodes.len() as f64;
4914        let avg_dl: f64 = nodes.iter().map(|nd| nd.dl as f64).sum::<f64>() / n;
4915        // df per stemmed token across all live indexed nodes.
4916        let mut df_map: BTreeMap<&str, f64> = BTreeMap::new();
4917        for nd in &nodes {
4918            for tok in nd.tokens.keys() {
4919                *df_map.entry(tok.as_str()).or_insert(0.0) += 1.0;
4920            }
4921        }
4922
4923        const K1: f64 = 1.2;
4924        const B: f64 = 0.75;
4925
4926        // --- Pass 2: score each node against each OR-group ---
4927        let mut results: Vec<(String, f64)> = Vec::new();
4928        for nd in &nodes {
4929            let dl = nd.dl as f64;
4930            let mut total_score = 0.0f64;
4931
4932            'group: for group in &groups {
4933                let mut group_score = 0.0f64;
4934
4935                for term in group {
4936                    if term.negated {
4937                        // Negated: if doc has this stemmed token → group fails.
4938                        let present = if term.prefix {
4939                            nd.tokens.keys().any(|t| t.starts_with(term.token.as_str()))
4940                        } else {
4941                            nd.tokens.contains_key(term.token.as_str())
4942                        };
4943                        if present {
4944                            continue 'group;
4945                        }
4946                        continue;
4947                    }
4948                    if term.prefix {
4949                        // Prefix: sum BM25 for all matching stemmed tokens.
4950                        let mut prefix_matched = false;
4951                        for (tok, positions) in &nd.tokens {
4952                            if tok.starts_with(term.token.as_str()) {
4953                                let tf = positions.len() as f64;
4954                                let df = df_map.get(tok.as_str()).copied().unwrap_or(1.0);
4955                                let idf = ((n - df + 0.5) / (df + 0.5) + 1.0).ln();
4956                                let tf_norm =
4957                                    tf * (K1 + 1.0) / (tf + K1 * (1.0 - B + B * dl / avg_dl));
4958                                group_score += idf * tf_norm;
4959                                prefix_matched = true;
4960                            }
4961                        }
4962                        if !prefix_matched {
4963                            continue 'group;
4964                        }
4965                    } else {
4966                        // term.token is already stemmed by parse_query; use directly.
4967                        match nd.tokens.get(term.token.as_str()) {
4968                            None => continue 'group,
4969                            Some(positions) => {
4970                                let tf = positions.len() as f64;
4971                                let df = df_map.get(term.token.as_str()).copied().unwrap_or(1.0);
4972                                let idf = ((n - df + 0.5) / (df + 0.5) + 1.0).ln();
4973                                let tf_norm =
4974                                    tf * (K1 + 1.0) / (tf + K1 * (1.0 - B + B * dl / avg_dl));
4975                                group_score += idf * tf_norm;
4976                            }
4977                        }
4978                    }
4979                }
4980
4981                if group_score > 0.0 {
4982                    total_score += group_score;
4983                }
4984            }
4985
4986            if total_score > 0.0 {
4987                results.push((nd.key.clone(), total_score));
4988            }
4989        }
4990
4991        results.sort_by(|a, b| {
4992            b.1.partial_cmp(&a.1)
4993                .unwrap_or(std::cmp::Ordering::Equal)
4994                .then(a.0.cmp(&b.0))
4995        });
4996        results
4997    }
4998
4999    /// Return the current view-maintained value of `view_prop` for node `key`.
5000    /// Equivalent to `get_prop` but documents that it reads a view-managed column.
5001    pub fn get_view_prop(&self, key: &str, view_prop: &str) -> Option<Value> {
5002        let id = self.ids.get(key)?;
5003        self.props_view()
5004            .get(id, view_prop)
5005            .map(|vr| vr.into_value())
5006    }
5007
5008    /// For testing / DST oracle: scratch recompute of a view value for one node.
5009    ///
5010    /// Returns `None` if the node does not exist, the view does not exist, or
5011    /// the view has no result for the node (e.g. Avg with no qualifying neighbors).
5012    #[doc(hidden)]
5013    pub fn scratch_view_value(&self, key: &str, view_name: &str) -> Option<Value> {
5014        let node = self.ids.get(key)?;
5015        let def = self.view_store.views().find(|v| v.name == view_name)?;
5016        // Use TopologyView so that NeighborAgg sees base + overlay edges
5017        // without materialising a temporary Topology (I1).
5018        let topo_view = self.topo_view();
5019        core_rules::views::compute_view_value(
5020            def,
5021            node,
5022            self.props_view(),
5023            &topo_view,
5024            &self.ids,
5025            &self.syms,
5026            &self.labels,
5027        )
5028    }
5029
5030    // -----------------------------------------------------------------------
5031    // Graph algorithm API
5032    // -----------------------------------------------------------------------
5033
5034    /// Run PageRank over the unified topology (manual + derived edges).
5035    ///
5036    /// Returns a [`PageRankReport`] with scores sorted descending (ties: key
5037    /// ascending).  Set `config.edge_type` to restrict to one edge type.
5038    /// `config.converged` is `true` only when the power iteration converged
5039    /// within `config.max_iters` and within any time budget.
5040    pub fn pagerank(&self, config: &crate::algo::PageRankConfig) -> crate::algo::PageRankReport {
5041        crate::algo::pagerank(&self.topo, &self.ids, &self.syms, &self.labels, config)
5042    }
5043
5044    /// Weakly-connected components over the unified topology (treated as
5045    /// undirected regardless of how edges were inserted).
5046    ///
5047    /// Component IDs are the key of the smallest member in the component
5048    /// (deterministic).  Result sorted by (component_id, key).
5049    pub fn connected_components(&self, config: &crate::algo::WccConfig) -> crate::algo::WccReport {
5050        crate::algo::wcc(&self.topo, &self.ids, &self.syms, &self.labels, config)
5051    }
5052
5053    /// Degree centrality for every live node.
5054    ///
5055    /// `direction`: `AlgoDir::Out` = out-degree, `AlgoDir::In` = in-degree,
5056    /// `AlgoDir::Both` = out + in (total directed degree).
5057    ///
5058    /// For one-shot ranking use this; for a live property updated on every
5059    /// write, create a Degree materialized view instead (see `docs/site/algorithms.md`).
5060    pub fn degree_centrality(
5061        &self,
5062        config: &crate::algo::DegreeConfig,
5063    ) -> crate::algo::DegreeReport {
5064        crate::algo::degree_centrality(&self.topo, &self.ids, &self.syms, &self.labels, config)
5065    }
5066
5067    /// Write a vector of `(node_key, score)` pairs as `prop_name` on each node,
5068    /// atomically via a single write-batch (one WAL frame, one fsync).
5069    ///
5070    /// # Errors
5071    /// - [`GraphError::ReadOnly`]: called on an as-of instance.
5072    /// - [`GraphError::RuleInvalid`]: `prop_name` is managed by an existing view
5073    ///   (collision check mirrors `create_view`).
5074    /// - [`GraphError::KeyNotFound`]: a key in `scores` does not exist as a live node.
5075    pub fn write_scores(&mut self, prop_name: &str, scores: &[(String, f64)]) -> Result<()> {
5076        if self.read_only {
5077            return Err(GraphError::ReadOnly);
5078        }
5079        // Collision check: refuse if prop_name is view-managed.
5080        if let Some(view_name) = self.view_store.view_for_prop(prop_name) {
5081            return Err(GraphError::RuleInvalid {
5082                detail: format!(
5083                    "prop {:?} is managed by view {:?} and cannot be written as scores",
5084                    prop_name, view_name
5085                ),
5086            });
5087        }
5088        // Refuse if prop_name is a view name itself (confusing namespace collision).
5089        if self.view_store.has_view(prop_name) {
5090            return Err(GraphError::RuleInvalid {
5091                detail: format!(
5092                    "prop_name {:?} collides with an existing view name",
5093                    prop_name
5094                ),
5095            });
5096        }
5097        // Write all scores in a single crash-atomic batch.
5098        self.write_batch(|b| {
5099            for (key, score) in scores {
5100                b.set_prop(key, prop_name, Value::Float(*score));
5101            }
5102        })?;
5103        Ok(())
5104    }
5105
5106    /// Return the value of `field` for the node with key `key`, or `None` if
5107    /// the node or field is absent.  Reads through the overlay-over-base
5108    /// `ColumnsView`, materialising base values on demand (zero heap cost for
5109    /// overlay hits; one clone per base hit).
5110    pub fn get_prop(&self, key: &str, field: &str) -> Option<Value> {
5111        let id = self.ids.get(key)?;
5112        self.props_view().get(id, field).map(|vr| vr.into_value())
5113    }
5114
5115    pub fn has_node(&self, key: &str) -> bool {
5116        self.ids.get(key).is_some()
5117    }
5118
5119    /// Borrow the raw id map. Used by `NodeMask::from_keys` to resolve keys.
5120    pub(crate) fn ids(&self) -> &IdMap {
5121        &self.ids
5122    }
5123
5124    // -----------------------------------------------------------------------
5125    // RBAC role resolution
5126    // -----------------------------------------------------------------------
5127
5128    /// Parse `roles.json` bytes from `fs`.
5129    ///
5130    /// Return values:
5131    ///   `Ok(Some(roles))` — file absent (returns `vec![]`) **or** file present
5132    ///                       and valid; in both cases `mask_for_role` uses the
5133    ///                       list normally (an absent file means no roles defined).
5134    ///   `Ok(None)`        — file present but corrupt or unrecognised version
5135    ///                       → poisoned state; `mask_for_role` returns `Err` for
5136    ///                       any role name until the file is fixed and the DB
5137    ///                       re-opened (or `apply_schema` is called to repair it).
5138    ///
5139    /// Note: `None` signals corruption, not absence — the opposite of what an
5140    /// optional "file missing" convention would suggest.  The open path stores
5141    /// this result on `db.roles` directly.
5142    fn load_roles_from_fs(fs: &F) -> Result<Option<Vec<RoleDef>>> {
5143        let bytes = fs.read(FileId::Roles).map_err(GraphError::Io)?;
5144        if bytes.is_empty() {
5145            // Empty bytes means either the file is absent or zero-byte — both
5146            // are treated identically as "no roles defined".  A zero-byte
5147            // roles.json does NOT widen access: an absent file and a zero-byte
5148            // file both resolve to an empty role list (sees nothing by default).
5149            return Ok(Some(vec![]));
5150        }
5151        match serde_json::from_slice::<RolesFile>(&bytes) {
5152            Ok(f) if f.version == 1 || f.version == 2 => Ok(Some(f.roles)),
5153            // Corrupt or unrecognised version (>2): poison the roles state.
5154            _ => Ok(None),
5155        }
5156    }
5157
5158    /// Resolve a role to a node-visibility mask against the current graph state.
5159    ///
5160    /// Returns `Err` when:
5161    /// - `roles.json` was present but corrupt at open (poisoned state), or
5162    /// - `role` does not match any defined role name.
5163    ///
5164    /// The mask union is: explicit `keys` (unknown keys silently ignored) plus
5165    /// all live nodes carrying any label in `labels`.  Label resolution is live
5166    /// — new nodes of an allowed label are visible without re-applying the
5167    /// schema.  An empty union = empty mask = sees nothing.
5168    pub fn mask_for_role(&self, role: &str) -> Result<crate::mask::NodeMask> {
5169        let roles = self.roles.as_ref().ok_or_else(|| GraphError::Corrupt {
5170            detail:
5171                "roles.json was corrupt at open; fix the file and re-open to restore role access"
5172                    .into(),
5173        })?;
5174        let def = roles
5175            .iter()
5176            .find(|r| r.name == role)
5177            .ok_or_else(|| GraphError::KeyNotFound {
5178                key: format!("role:{role}"),
5179            })?;
5180
5181        let mut visible = std::collections::HashSet::new();
5182
5183        // Key leg: resolve explicit keys to dense ids (unknown keys ignored).
5184        for key in &def.keys {
5185            if let Some(id) = self.ids.get(key) {
5186                visible.insert(id);
5187            }
5188        }
5189
5190        // Label leg: live scan — iterate labels vec for matching symbol.
5191        for label_name in &def.labels {
5192            if let Some(sym) = self.syms.get(label_name) {
5193                for (i, &s) in self.labels.iter().enumerate() {
5194                    if s == sym {
5195                        visible.insert(i as u32);
5196                    }
5197                }
5198            }
5199        }
5200
5201        Ok(crate::mask::NodeMask::from_ids(visible))
5202    }
5203
5204    /// Return the current list of role definitions.
5205    ///
5206    /// Returns an empty list when no roles are defined or when `roles.json`
5207    /// was corrupt at open (check [`mask_for_role`](Self::mask_for_role) for
5208    /// the fail-loud error in that case).
5209    pub fn roles(&self) -> Vec<RoleDef> {
5210        self.roles.as_deref().unwrap_or(&[]).to_vec()
5211    }
5212
5213    // ── Role-scoped write authz ───────────────────────────────────────────────
5214
5215    /// Execute `ops` with optional role-scoped write authorization.
5216    ///
5217    /// - `None` → full authority, identical to [`write_batch`](Self::write_batch)
5218    ///   (zero-cost bypass of all authz checks).
5219    /// - `Some(authz)` → the decision table is evaluated per-op BEFORE any WAL
5220    ///   record is built.  A denial returns an error with no WAL frame written
5221    ///   (all-or-nothing at the authz boundary, then at the MutPreview boundary).
5222    ///
5223    /// See the plan's "authz decision table" section for the full semantics.
5224    pub fn write_batch_authz(
5225        &mut self,
5226        authz: Option<&WriteAuthz>,
5227        ops: Vec<BatchOp>,
5228    ) -> Result<(usize, usize)> {
5229        // Thread authz as a direct parameter — never touches pending_write_authz.
5230        self.commit_logged_batch(ops, None, authz.cloned())
5231    }
5232
5233    /// Execute a Cypher write statement with role-scoped write authorization.
5234    ///
5235    /// Resolves scope + mask from `self.roles` inside the call (same write-guard
5236    /// lifetime as execution, satisfying §5 lock discipline).  The resolved
5237    /// `WriteAuthz` is stored as `pending_write_authz` for the duration of the
5238    /// call so that all inner `batch.commit()` calls are authz-checked.
5239    ///
5240    /// MERGE is handled specially: the MERGE scope precondition (§3.3) is
5241    /// checked in `exec_merge` BEFORE `has_node` to close the §6.2
5242    /// timing-oracle item (hidden ≡ absent for unscoped roles).
5243    ///
5244    /// Roles with `write: None` (v1 behavior) → `RoleWriteDenied` with
5245    /// "this endpoint is not permitted".
5246    pub fn query_write_authz(
5247        &mut self,
5248        role: &str,
5249        cypher: &str,
5250        params: &BTreeMap<String, Value>,
5251    ) -> Result<ResultSet> {
5252        // Resolve scope (fails fast if role has no write scope).
5253        // write:None → byte-identical v1 blanket-403 body (plan §v1-sidecar mandate).
5254        let scope =
5255            {
5256                let roles = self.roles.as_deref().ok_or_else(|| GraphError::Corrupt {
5257                    detail: "roles.json was corrupt at open; re-open to restore role access".into(),
5258                })?;
5259                let def = roles.iter().find(|r| r.name == role).ok_or_else(|| {
5260                    GraphError::KeyNotFound {
5261                        key: format!("role:{role}"),
5262                    }
5263                })?;
5264                def.write
5265                    .clone()
5266                    .ok_or_else(|| GraphError::RoleWriteDenied {
5267                        reason: "role-bound token: writes are not permitted".into(),
5268                    })?
5269            };
5270        // Resolve mask inside the call (same guard, §5 coherence).
5271        let mask = self.mask_for_role(role)?;
5272        self.pending_write_authz = Some(WriteAuthz {
5273            role: role.into(),
5274            scope,
5275            mask,
5276        });
5277        // RAII guard: always clears pending_write_authz on scope exit, including
5278        // on panic or early-return, mirroring the RestoreEmitDeltas precedent.
5279        struct ClearPendingAuthzOnDrop(*mut Option<WriteAuthz>);
5280        impl Drop for ClearPendingAuthzOnDrop {
5281            fn drop(&mut self) {
5282                // SAFETY: pointer into the owning GraphDb; guard is dropped
5283                // within this function's frame before it returns.
5284                unsafe { *self.0 = None };
5285            }
5286        }
5287        // SAFETY: raw pointer into self; guard dropped before this fn returns.
5288        let _authz_guard = ClearPendingAuthzOnDrop(&mut self.pending_write_authz as *mut _);
5289        let tokens = lex(cypher).map_err(|e| GraphError::QueryError {
5290            detail: format!("lex: {e}"),
5291        })?;
5292        let stmt = parse_write(&tokens).map_err(|e| GraphError::QueryError {
5293            detail: format!("parse: {e}"),
5294        })?;
5295        self.exec_write_stmt(stmt, params)
5296    }
5297
5298    /// Execute `ops` with optional role-scoped write authorization, suppressing
5299    /// fsync (for use inside the group-commit drain thread, which performs one
5300    /// group fsync after releasing the write lock).
5301    ///
5302    /// Identical to [`write_batch_authz`] except the fsync policy is temporarily
5303    /// forced to `Relaxed` for the duration of the call, matching the drain-thread
5304    /// contract established by [`commit_batch_nosync`].
5305    pub(crate) fn write_batch_authz_nosync(
5306        &mut self,
5307        authz: Option<&WriteAuthz>,
5308        ops: Vec<BatchOp>,
5309    ) -> Result<(usize, usize)> {
5310        let saved = self.fsync;
5311        struct RestoreFsync(*mut FsyncPolicy, FsyncPolicy);
5312        impl Drop for RestoreFsync {
5313            fn drop(&mut self) {
5314                // SAFETY: pointer into the owning GraphDb; guard is dropped
5315                // within the enclosing function's frame before it returns.
5316                unsafe { *self.0 = self.1 };
5317            }
5318        }
5319        // SAFETY: raw pointer into self; guard dropped before this fn returns.
5320        let _g = RestoreFsync(&mut self.fsync as *mut FsyncPolicy, saved);
5321        self.fsync = FsyncPolicy::Relaxed;
5322        self.commit_logged_batch(ops, None, authz.cloned())
5323    }
5324
5325    /// Execute a `/ingest` request with role-scoped write authorization.
5326    ///
5327    /// Resolves the role's `WriteScope` and `NodeMask` inside this call (same
5328    /// write-guard lifetime as the mutation, satisfying §5 lock discipline).
5329    /// Sets `pending_write_authz` for the duration of the call so that the
5330    /// `commit_ingest` → `commit_logged_batch` path picks up the authz context
5331    /// and evaluates the decision table per-op before any WAL write.
5332    ///
5333    /// §7.3: roles with empty `create_labels` will see every `InsertNode` op
5334    /// denied by the decision table with the appropriate §4.3 scope reason;
5335    /// no special HTTP-layer check is needed.
5336    ///
5337    /// Roles with `write: None` return `RoleWriteDenied` with
5338    /// "writes are not permitted" (byte-identical to v1 blanket 403).
5339    pub fn ingest_with_edges_authz(
5340        &mut self,
5341        role: &str,
5342        label: &str,
5343        rows: Vec<std::collections::BTreeMap<String, Value>>,
5344        opts: &crate::ingest::IngestOptions,
5345        edges: &[(String, String, String)],
5346    ) -> Result<crate::ingest::IngestReport> {
5347        // Resolve scope (fails fast if role has no write scope).
5348        // write:None → byte-identical v1 blanket-403 body (plan §v1-sidecar mandate).
5349        let scope =
5350            {
5351                let roles = self.roles.as_deref().ok_or_else(|| GraphError::Corrupt {
5352                    detail: "roles.json was corrupt at open; re-open to restore role access".into(),
5353                })?;
5354                let def = roles.iter().find(|r| r.name == role).ok_or_else(|| {
5355                    GraphError::KeyNotFound {
5356                        key: format!("role:{role}"),
5357                    }
5358                })?;
5359                def.write
5360                    .clone()
5361                    .ok_or_else(|| GraphError::RoleWriteDenied {
5362                        reason: "role-bound token: writes are not permitted".into(),
5363                    })?
5364            };
5365        let mask = self.mask_for_role(role)?;
5366        self.pending_write_authz = Some(WriteAuthz {
5367            role: role.into(),
5368            scope,
5369            mask,
5370        });
5371        // RAII guard: always clears pending_write_authz on scope exit, including
5372        // on panic or early-return, mirroring the RestoreEmitDeltas precedent.
5373        struct ClearPendingAuthzOnDrop(*mut Option<WriteAuthz>);
5374        impl Drop for ClearPendingAuthzOnDrop {
5375            fn drop(&mut self) {
5376                // SAFETY: pointer into the owning GraphDb; guard is dropped
5377                // within this function's frame before it returns.
5378                unsafe { *self.0 = None };
5379            }
5380        }
5381        // SAFETY: raw pointer into self; guard dropped before this fn returns.
5382        let _authz_guard = ClearPendingAuthzOnDrop(&mut self.pending_write_authz as *mut _);
5383        self.ingest_with_edges(label, rows, opts, edges)
5384    }
5385
5386    /// Evaluate the write-authz decision table for one `BatchOp`.
5387    ///
5388    /// Called by `commit_logged_batch` for each op when `pending_write_authz`
5389    /// is `Some`, BEFORE MutPreview.  A denial returns an error immediately;
5390    /// the remaining ops are not evaluated and no WAL frame is written.
5391    ///
5392    /// `batch_created` carries the key→label pairs of nodes that earlier ops in
5393    /// THIS batch will create.  Used by `InsertEdgeUpsert` to count same-batch
5394    /// placeholder nodes as visible (spec: "a placeholder endpoint the SAME
5395    /// batch creates counts as visible if its label passed the create-class gate").
5396    fn check_single_op_authz(
5397        &self,
5398        authz: &WriteAuthz,
5399        op: &BatchOp,
5400        batch_created: &BTreeMap<String, String>,
5401    ) -> Result<()> {
5402        // Helper: 3-way node status under the authz mask.
5403        //
5404        // Batch-created nodes (from earlier InsertNode in THIS batch) are treated
5405        // as Visible with their recorded label — their create gate already passed
5406        // and they are not yet in self.ids (not committed).  This fixes the
5407        // MERGE+ON CREATE SET case where InsertNode + SetProp arrive together:
5408        // the SetProp must not see the node as Absent.
5409        let node_status = |key: &str| -> NodeAuthzStatus {
5410            if let Some(label) = batch_created.get(key) {
5411                return NodeAuthzStatus::Visible(label.clone());
5412            }
5413            match self.ids.get(key) {
5414                None => NodeAuthzStatus::Absent,
5415                Some(id) if !authz.mask.contains_id(id) => NodeAuthzStatus::Hidden,
5416                Some(id) => {
5417                    let label = self
5418                        .labels
5419                        .get(id as usize)
5420                        .and_then(|&sym| {
5421                            if sym == u32::MAX {
5422                                None
5423                            } else {
5424                                self.syms.resolve(sym).map(str::to_string)
5425                            }
5426                        })
5427                        .unwrap_or_default();
5428                    NodeAuthzStatus::Visible(label)
5429                }
5430            }
5431        };
5432
5433        // Helper: is an InsertEdgeUpsert endpoint visible?
5434        // A same-batch placeholder counts as visible if its label passed
5435        // the create-class gate (spec "upsert placeholder-counts-as-visible").
5436        let upsert_ep_visible = |ep_key: &str, placeholder_label: &str| -> bool {
5437            // In store and visible?
5438            if let Some(id) = self.ids.get(ep_key) {
5439                return authz.mask.contains_id(id);
5440            }
5441            // Created by an earlier op in this batch?
5442            if let Some(created_label) = batch_created.get(ep_key) {
5443                return authz.scope.create_labels.contains(created_label);
5444            }
5445            // Will be created by THIS InsertEdgeUpsert: placeholder_label
5446            // must pass the create-class gate.
5447            authz
5448                .scope
5449                .create_labels
5450                .contains(&placeholder_label.to_string())
5451        };
5452
5453        match op {
5454            // RenameNode / CreateRule / DeleteRule: defense-in-depth gate.
5455            // These ops are never routed to role-scoped paths by the HTTP layer,
5456            // but we 403 them here to close any future bypass route.
5457            BatchOp::RenameNode { .. } | BatchOp::CreateRule(_) | BatchOp::DeleteRule { .. } => {
5458                return Err(GraphError::RoleWriteDenied {
5459                    reason: "role-bound token: this endpoint is not permitted".into(),
5460                });
5461            }
5462
5463            // ── CREATE-class: InsertNode ─────────────────────────────────────
5464            //
5465            // Decision table row 1 (scope-before-lookup): check label in
5466            // create_labels BEFORE any key lookup.  This is the structural
5467            // closure of the §6.2 timing-oracle item — the denial fires even
5468            // when the store is EMPTY (see test_create_scope_denied_empty_store).
5469            BatchOp::InsertNode { label, key, .. } => {
5470                if !authz.scope.create_labels.contains(label) {
5471                    return Err(GraphError::RoleWriteDenied {
5472                        reason: format!(
5473                            "role-bound token: label '{}' not in write scope (create_labels)",
5474                            label
5475                        ),
5476                    });
5477                }
5478                // Row 2/3: key lookup.
5479                match self.ids.get(key.as_str()) {
5480                    Some(id) if authz.mask.contains_id(id) => {
5481                        // Visible: DuplicateKey — let MutPreview handle this.
5482                    }
5483                    Some(_) => {
5484                        // Hidden: indistinguishable from absent to the role.
5485                        return Err(GraphError::RoleWriteDenied {
5486                            reason: "role-bound token: target node not visible".into(),
5487                        });
5488                    }
5489                    None => {
5490                        // Absent: proceed (create).
5491                    }
5492                }
5493            }
5494
5495            // ── UPDATE-class: SetProp, RemoveProp ────────────────────────────
5496            BatchOp::SetProp { key, .. } | BatchOp::RemoveProp { key, .. } => {
5497                if batch_created.contains_key(key.as_str()) {
5498                    // Batch-created node: create gate already passed this batch.
5499                    // Updating it in the same batch is always allowed, regardless
5500                    // of update_labels (ruling §3.5: "writer just created it").
5501                } else {
5502                    let label = match node_status(key) {
5503                        NodeAuthzStatus::Visible(lbl) => lbl,
5504                        _ => {
5505                            return Err(GraphError::RoleWriteDenied {
5506                                reason: "role-bound token: target node not visible".into(),
5507                            });
5508                        }
5509                    };
5510                    if !authz.scope.update_labels.contains(&label) {
5511                        return Err(GraphError::RoleWriteDenied {
5512                            reason: format!(
5513                                "role-bound token: label '{}' not in write scope (update_labels)",
5514                                label
5515                            ),
5516                        });
5517                    }
5518                }
5519            }
5520
5521            // ── DELETE-class: DeleteNode ─────────────────────────────────────
5522            BatchOp::DeleteNode { key } => {
5523                let label = match node_status(key) {
5524                    NodeAuthzStatus::Visible(lbl) => lbl,
5525                    _ => {
5526                        return Err(GraphError::RoleWriteDenied {
5527                            reason: "role-bound token: target node not visible".into(),
5528                        });
5529                    }
5530                };
5531                if !authz.scope.delete_labels.contains(&label) {
5532                    return Err(GraphError::RoleWriteDenied {
5533                        reason: format!(
5534                            "role-bound token: label '{}' not in write scope (delete_labels)",
5535                            label
5536                        ),
5537                    });
5538                }
5539            }
5540
5541            // ── DELETE-class: DeleteEdge ─────────────────────────────────────
5542            //
5543            // Derived-edge rejection runs BEFORE the delete_edge_types scope
5544            // check (spec §3.5: "existing derived-edge rejection precedes
5545            // delete_edge_types check").
5546            BatchOp::DeleteEdge {
5547                edge_type,
5548                src_key,
5549                dst_key,
5550            } => {
5551                // Check provenance ownership BEFORE scope (spec §3.5 ordering).
5552                if let (Some(src_id), Some(dst_id), Some(et_sym)) = (
5553                    self.ids.get(src_key.as_str()),
5554                    self.ids.get(dst_key.as_str()),
5555                    self.syms.get(edge_type.as_str()),
5556                ) {
5557                    if self.engine.is_owned(et_sym, src_id, dst_id) {
5558                        return Err(GraphError::RuleOwned {
5559                            detail: format!(
5560                                "edge {edge_type} {src_key}→{dst_key} is rule-owned; \
5561                                 delete or change the owning rule"
5562                            ),
5563                        });
5564                    }
5565                    // Also check would_derive via MutPreview (empty overlay, pre-batch).
5566                    let preview = MutPreview::new(self);
5567                    if preview.would_derive(edge_type, src_key, dst_key) {
5568                        return Err(GraphError::RuleOwned {
5569                            detail: format!(
5570                                "edge {edge_type} {src_key}→{dst_key} is rule-owned; \
5571                                 delete or change the owning rule, or a live rule would \
5572                                 re-derive it"
5573                            ),
5574                        });
5575                    }
5576                }
5577                // Scope check (AFTER derived-edge check, BEFORE endpoint visibility).
5578                if !authz.scope.delete_edge_types.contains(edge_type) {
5579                    return Err(GraphError::RoleWriteDenied {
5580                        reason: format!(
5581                            "role-bound token: edge type '{}' not in write scope (delete_edge_types)",
5582                            edge_type
5583                        ),
5584                    });
5585                }
5586                // Both endpoints must be visible.
5587                for ep_key in [src_key.as_str(), dst_key.as_str()] {
5588                    match self.ids.get(ep_key) {
5589                        None => {
5590                            return Err(GraphError::RoleWriteDenied {
5591                                reason: "role-bound token: edge endpoint not visible".into(),
5592                            });
5593                        }
5594                        Some(id) if !authz.mask.contains_id(id) => {
5595                            return Err(GraphError::RoleWriteDenied {
5596                                reason: "role-bound token: edge endpoint not visible".into(),
5597                            });
5598                        }
5599                        _ => {}
5600                    }
5601                }
5602            }
5603
5604            // ── EDGE-CREATE: InsertEdge ──────────────────────────────────────
5605            //
5606            // Scope check BEFORE endpoint lookup (preserves timing symmetry).
5607            BatchOp::InsertEdge {
5608                edge_type,
5609                src_key,
5610                dst_key,
5611            } => {
5612                if !authz.scope.create_edge_types.contains(edge_type) {
5613                    return Err(GraphError::RoleWriteDenied {
5614                        reason: format!(
5615                            "role-bound token: edge type '{}' not in write scope (create_edge_types)",
5616                            edge_type
5617                        ),
5618                    });
5619                }
5620                // Both endpoints must be visible. A node created by an earlier
5621                // InsertNode in the same batch (tracked in batch_created) counts
5622                // as visible if its label passed the create-class gate.
5623                for ep_key in [src_key.as_str(), dst_key.as_str()] {
5624                    if batch_created.contains_key(ep_key) {
5625                        // Created earlier this batch — already scope-checked.
5626                        continue;
5627                    }
5628                    match self.ids.get(ep_key) {
5629                        None => {
5630                            return Err(GraphError::RoleWriteDenied {
5631                                reason: "role-bound token: edge endpoint not visible".into(),
5632                            });
5633                        }
5634                        Some(id) if !authz.mask.contains_id(id) => {
5635                            return Err(GraphError::RoleWriteDenied {
5636                                reason: "role-bound token: edge endpoint not visible".into(),
5637                            });
5638                        }
5639                        _ => {}
5640                    }
5641                }
5642            }
5643
5644            // ── EDGE-CREATE: InsertEdgeUpsert ────────────────────────────────
5645            //
5646            // Scope check first; then endpoint visibility using same-batch
5647            // placeholder awareness (spec: "a placeholder endpoint the SAME
5648            // batch creates counts as visible if its label passed the
5649            // create-class gate").
5650            BatchOp::InsertEdgeUpsert {
5651                edge_type,
5652                src_key,
5653                dst_key,
5654                placeholder_label,
5655            } => {
5656                if !authz.scope.create_edge_types.contains(edge_type) {
5657                    return Err(GraphError::RoleWriteDenied {
5658                        reason: format!(
5659                            "role-bound token: edge type '{}' not in write scope (create_edge_types)",
5660                            edge_type
5661                        ),
5662                    });
5663                }
5664                // Check placeholder label against create_labels (create-class gate).
5665                // This ensures the auto-created endpoints are scope-allowed.
5666                for ep_key in [src_key.as_str(), dst_key.as_str()] {
5667                    if !upsert_ep_visible(ep_key, placeholder_label) {
5668                        return Err(GraphError::RoleWriteDenied {
5669                            reason: "role-bound token: edge endpoint not visible".into(),
5670                        });
5671                    }
5672                }
5673            }
5674        }
5675        Ok(())
5676    }
5677
5678    /// Write `roles` to `roles.json` atomically and update the in-memory list.
5679    ///
5680    /// Called by `apply_schema` when roles change. Never called on unchanged
5681    /// re-apply — this preserves byte-identical idempotency.
5682    pub(crate) fn commit_roles(&mut self, roles: Vec<RoleDef>) -> Result<()> {
5683        let file = RolesFile::new_versioned(roles.clone());
5684        let bytes = serde_json::to_vec(&file).map_err(|e| GraphError::Corrupt {
5685            detail: format!("roles serialization: {e}"),
5686        })?;
5687        self.fs
5688            .write_atomic(FileId::Roles, &bytes)
5689            .map_err(GraphError::Io)?;
5690        self.roles = Some(roles);
5691        // Refresh the MVCC frozen overlay so that reader() immediately sees the
5692        // updated role definitions without waiting for the next K-commit fold.
5693        self.fold_now();
5694        Ok(())
5695    }
5696
5697    fn view(&self) -> GraphView<'_> {
5698        GraphView {
5699            ids: &self.ids,
5700            syms: &self.syms,
5701            labels: &self.labels,
5702            props: self.props_view(),
5703            topo: self.topo_view(),
5704            edge_props: self.edge_props_view(),
5705            mask: None,
5706        }
5707    }
5708
5709    fn view_masked<'a>(&'a self, mask: &'a crate::mask::NodeMask) -> GraphView<'a> {
5710        GraphView {
5711            ids: &self.ids,
5712            syms: &self.syms,
5713            labels: &self.labels,
5714            props: self.props_view(),
5715            topo: self.topo_view(),
5716            edge_props: self.edge_props_view(),
5717            mask: Some(&mask.visible),
5718        }
5719    }
5720
5721    /// Execute a read-only Cypher query with a node visibility mask.
5722    ///
5723    /// Only nodes whose key is in `mask` are accessible: label scans, key
5724    /// lookups, and neighbor expansions all respect the mask. Edges where
5725    /// either endpoint is hidden are silently dropped.
5726    ///
5727    /// Returns `Err` with a "masked queries are read-only" message when
5728    /// `cypher` is a write statement (CREATE / MERGE / MATCH…SET / DELETE).
5729    pub fn query_masked(
5730        &self,
5731        cypher: &str,
5732        params: &std::collections::BTreeMap<String, Value>,
5733        mask: &crate::mask::NodeMask,
5734    ) -> Result<ResultSet> {
5735        // Reject write statements up front.
5736        let tokens = lex(cypher).map_err(|e| GraphError::QueryError {
5737            detail: format!("lex: {e}"),
5738        })?;
5739        if is_write_tokens(&tokens) {
5740            return Err(GraphError::MaskedReadOnly);
5741        }
5742        let ast = parse(&tokens).map_err(|e| GraphError::QueryError {
5743            detail: format!("parse: {e}"),
5744        })?;
5745        let ops = plan(&ast).map_err(|e| GraphError::QueryError {
5746            detail: format!("plan: {e}"),
5747        })?;
5748        execute(&self.view_masked(mask), &ops, &Params(params)).map_err(|e| {
5749            GraphError::QueryError {
5750                detail: format!("execute: {e}"),
5751            }
5752        })
5753    }
5754
5755    pub fn node_ref(&self, key: &str) -> Option<NodeRef<'_, F>> {
5756        let id = self.ids.get(key)?;
5757        Some(NodeRef { db: self, id })
5758    }
5759
5760    /// BFS neighborhood expansion restricted to visible nodes in `mask`.
5761    ///
5762    /// Hidden nodes are never used as traversal intermediaries in either
5763    /// [`MaskMode::Omit`] or [`MaskMode::Stub`] — a visible node reachable
5764    /// only through a hidden node will not appear in results.
5765    ///
5766    /// In [`MaskMode::Stub`] mode, hidden nodes that are direct neighbours of
5767    /// a visited visible node are appended to the result as stub rows
5768    /// (`label` column is `null`, same key+depth columns as visible rows).
5769    /// They are NOT added to the BFS frontier.
5770    ///
5771    /// Returns `None` when `key` does not exist (caller should 404).
5772    ///
5773    /// **SECURITY**: role-token callers always pass an Omit-mode mask, so
5774    /// stub rows are never produced on the role path.
5775    pub fn neighborhood_masked(
5776        &self,
5777        key: &str,
5778        depth: u32,
5779        edge_types: Option<&[&str]>,
5780        dir: Dir,
5781        mask: &crate::mask::NodeMask,
5782    ) -> Option<ResultSet> {
5783        let start_id = self.ids.get(key)?;
5784        let view = self.view_masked(mask);
5785        let resolved: Option<Vec<u32>> = edge_types.map(|names| {
5786            names
5787                .iter()
5788                .filter_map(|name| view.syms.get(name))
5789                .collect()
5790        });
5791        let nb = neighborhood(&view, start_id, depth, resolved.as_deref(), dir);
5792        let mut rs = ResultSet::new(vec!["key".into(), "label".into(), "depth".into()]);
5793        // Collect visible BFS results (start_id at depth 0, BFS nodes after).
5794        let mut visited: Vec<(u32, u32)> = Vec::with_capacity(nb.nodes.len() + 1);
5795        visited.push((start_id, 0));
5796        for (nid, d) in &nb.nodes {
5797            let k = view.key_of(*nid);
5798            let label = view
5799                .label_of(*nid)
5800                .expect("real nodes always have a label; u32::MAX sentinel cannot occur");
5801            rs.push_row(vec![
5802                Some(Value::Str(k.to_string())),
5803                Some(Value::Str(label.to_string())),
5804                Some(Value::Int(*d as i64)),
5805            ]);
5806            visited.push((*nid, *d));
5807        }
5808        // Stub mode: add hidden direct neighbours of each visited node as stubs.
5809        // Hidden nodes are edge-endpoints only — they are not added to the BFS
5810        // frontier, so the BFS never expands through them.
5811        if mask.mode() == crate::mask::MaskMode::Stub {
5812            let raw_view = self.view();
5813            let mut seen: std::collections::HashSet<u32> =
5814                visited.iter().map(|(id, _)| *id).collect();
5815            for (node_id, node_depth) in &visited {
5816                if *node_depth >= depth {
5817                    continue;
5818                }
5819                for e in expand(&raw_view, *node_id, resolved.as_deref(), dir) {
5820                    let nbr = if e.src == *node_id { e.dst } else { e.src };
5821                    if !mask.contains_id(nbr) && seen.insert(nbr) {
5822                        if let Some(k) = self.ids.key_of(nbr) {
5823                            rs.push_row(vec![
5824                                Some(Value::Str(k.to_string())),
5825                                None,
5826                                Some(Value::Int((*node_depth + 1) as i64)),
5827                            ]);
5828                        }
5829                    }
5830                }
5831            }
5832        }
5833        Some(rs)
5834    }
5835
5836    /// Live node's key, label, and columnar props. Unknown or tombstoned → `None`.
5837    pub fn node_info(&self, key: &str) -> Option<NodeInfo> {
5838        let n = self.node_ref(key)?;
5839        Some(NodeInfo {
5840            key: n.key().to_string(),
5841            label: n.label().to_string(),
5842            props: n.props(),
5843        })
5844    }
5845
5846    /// Look up a node with mask awareness.
5847    ///
5848    /// | Key state         | Omit mode       | Stub mode              |
5849    /// |-------------------|-----------------|------------------------|
5850    /// | does not exist    | `None` (→ 404)  | `None` (→ 404)         |
5851    /// | exists, visible   | `Some(Visible)` | `Some(Visible)`        |
5852    /// | exists, hidden    | `None` (→ 404)  | `Some(Restricted)`     |
5853    ///
5854    /// **SECURITY**: only call from client-mask (full-token) paths.
5855    /// Role-token paths must use [`node_info`] after an explicit visibility check.
5856    pub fn node_info_masked(
5857        &self,
5858        key: &str,
5859        mask: &crate::mask::NodeMask,
5860    ) -> Option<MaskedNodeResult> {
5861        let id = self.ids.get(key)?;
5862        if mask.contains_id(id) {
5863            Some(MaskedNodeResult::Visible(self.node_info(key)?))
5864        } else {
5865            match mask.mode() {
5866                crate::mask::MaskMode::Stub => Some(MaskedNodeResult::Restricted),
5867                crate::mask::MaskMode::Omit => None,
5868            }
5869        }
5870    }
5871
5872    /// Get edges for `key` with mask-aware hidden-endpoint handling.
5873    ///
5874    /// - Omit mode: edges to hidden endpoints are excluded (same as role-path filtering).
5875    /// - Stub mode: edges to hidden endpoints are included; `src_restricted`/`dst_restricted`
5876    ///   is `true` for each hidden endpoint.
5877    ///
5878    /// Unknown key → [`GraphError::KeyNotFound`].
5879    ///
5880    /// **SECURITY**: only call from client-mask (full-token) paths.
5881    pub fn node_edges_masked(
5882        &self,
5883        key: &str,
5884        mask: &crate::mask::NodeMask,
5885    ) -> Result<Vec<MaskedEdge>> {
5886        self.ensure_v8_base_sections_loaded();
5887        let id = self
5888            .ids
5889            .get(key)
5890            .ok_or_else(|| GraphError::KeyNotFound { key: key.into() })?;
5891        let derived: BTreeSet<(u32, u32, u32)> = self
5892            .engine
5893            .provenance_touching(id)
5894            .map(|(_rule, etype, src, dst)| (etype, src, dst))
5895            .collect();
5896        let mut edges = Vec::new();
5897        let tv = self.topo_view();
5898        for etype in tv.etypes() {
5899            // etype comes from the archived CSR (access_unchecked, no eager CRC).
5900            // A bit-flip in the large TOPOLOGY section can produce an etype id
5901            // that is not in the interner.  Return Corrupt rather than panic.
5902            let edge_type = self
5903                .syms
5904                .resolve(etype)
5905                .ok_or_else(|| GraphError::Corrupt {
5906                    detail: format!("v8: topology etype {etype} not in interner"),
5907                })?
5908                .to_string();
5909            for dir in [Direction::Out, Direction::In] {
5910                for &nbr in tv.neighbors(etype, dir, id).as_ref() {
5911                    let nbr_restricted = !mask.contains_id(nbr);
5912                    if nbr_restricted && mask.mode() == crate::mask::MaskMode::Omit {
5913                        continue;
5914                    }
5915                    let nbr_key = self
5916                        .ids
5917                        .key_of(nbr)
5918                        .ok_or_else(|| GraphError::Corrupt {
5919                            detail: format!("topology id {nbr} has no key"),
5920                        })?
5921                        .to_string();
5922                    let (src_id, dst_id, src_key, dst_key, src_restricted, dst_restricted) =
5923                        match dir {
5924                            Direction::Out => {
5925                                (id, nbr, key.to_string(), nbr_key, false, nbr_restricted)
5926                            }
5927                            Direction::In => {
5928                                (nbr, id, nbr_key, key.to_string(), nbr_restricted, false)
5929                            }
5930                        };
5931                    edges.push(MaskedEdge {
5932                        edge_type: edge_type.clone(),
5933                        src_key,
5934                        src_restricted,
5935                        dst_key,
5936                        dst_restricted,
5937                        derived: derived.contains(&(etype, src_id, dst_id)),
5938                    });
5939                }
5940            }
5941        }
5942        edges.sort_by(|a, b| {
5943            a.edge_type
5944                .cmp(&b.edge_type)
5945                .then(a.src_key.cmp(&b.src_key))
5946                .then(a.dst_key.cmp(&b.dst_key))
5947        });
5948        edges.dedup_by(|a, b| {
5949            a.edge_type == b.edge_type && a.src_key == b.src_key && a.dst_key == b.dst_key
5950        });
5951        Ok(edges)
5952    }
5953
5954    /// Every directed edge incident on `key`, both directions, every etype.
5955    ///
5956    /// Walk is `topology.etypes()` × `{Out, In}` × `neighbors()`. `derived` is
5957    /// membership in [`RuleEngine::provenance_touching`] (O(degree) via the
5958    /// Plan-8 `by_node` index). Sorted by `(edge_type, src_key, dst_key)`.
5959    /// Unknown key → [`GraphError::KeyNotFound`].
5960    pub fn node_edges(&self, key: &str) -> Result<Vec<EdgeInfo>> {
5961        self.ensure_v8_base_sections_loaded();
5962        let id = self
5963            .ids
5964            .get(key)
5965            .ok_or_else(|| GraphError::KeyNotFound { key: key.into() })?;
5966        let derived: BTreeSet<(u32, u32, u32)> = self
5967            .engine
5968            .provenance_touching(id)
5969            .map(|(_rule, etype, src, dst)| (etype, src, dst))
5970            .collect();
5971        let mut edges = Vec::new();
5972        let tv = self.topo_view();
5973        for etype in tv.etypes() {
5974            // Same guard as node_edges_masked: etype from unchecked-CRC CSR.
5975            let edge_type = self
5976                .syms
5977                .resolve(etype)
5978                .ok_or_else(|| GraphError::Corrupt {
5979                    detail: format!("v8: topology etype {etype} not in interner"),
5980                })?
5981                .to_string();
5982            for dir in [Direction::Out, Direction::In] {
5983                for &nbr in tv.neighbors(etype, dir, id).as_ref() {
5984                    let (src, dst, src_key, dst_key) = match dir {
5985                        Direction::Out => (
5986                            id,
5987                            nbr,
5988                            key.to_string(),
5989                            self.ids
5990                                .key_of(nbr)
5991                                .ok_or_else(|| GraphError::Corrupt {
5992                                    detail: format!("topology id {nbr} has no key"),
5993                                })?
5994                                .to_string(),
5995                        ),
5996                        Direction::In => (
5997                            nbr,
5998                            id,
5999                            self.ids
6000                                .key_of(nbr)
6001                                .ok_or_else(|| GraphError::Corrupt {
6002                                    detail: format!("topology id {nbr} has no key"),
6003                                })?
6004                                .to_string(),
6005                            key.to_string(),
6006                        ),
6007                    };
6008                    edges.push(EdgeInfo {
6009                        edge_type: edge_type.clone(),
6010                        src_key,
6011                        dst_key,
6012                        derived: derived.contains(&(etype, src, dst)),
6013                    });
6014                }
6015            }
6016        }
6017        edges.sort_by(|a, b| {
6018            a.edge_type
6019                .cmp(&b.edge_type)
6020                .then(a.src_key.cmp(&b.src_key))
6021                .then(a.dst_key.cmp(&b.dst_key))
6022        });
6023        // Self-loops appear in both Out and In; sort makes the pair adjacent
6024        // (sort key matches PartialEq for this case) so one pass drops the dup.
6025        edges.dedup();
6026        Ok(edges)
6027    }
6028
6029    // ── Backup ────────────────────────────────────────────────────────────────
6030
6031    /// Copy this store to `dest` as a consistent, verified snapshot.
6032    ///
6033    /// Copies every durable file in the database directory — `snapshot.bin`,
6034    /// `wal.bin`, all `wal.<N>.archive` files, `wal.floor`, `wal.genesis`, and
6035    /// `roles.json` — into a freshly created `dest` directory using OS-level
6036    /// `copy` calls (no large in-process buffers).
6037    ///
6038    /// # Consistency guarantee
6039    ///
6040    /// The guarantee is **process-local**: the caller holds `&self`, which
6041    /// prevents any concurrent writer in the **same process** from modifying
6042    /// the files during the copy.  Running `mushroomdb backup` against a
6043    /// directory that is **concurrently being written by another process** (e.g.
6044    /// `mushroomdb serve`) is **unsafe** — the copy can be torn.  The post-copy
6045    /// `verified: true` result reduces but does not eliminate the risk of a
6046    /// silent corrupt backup (CRC catches many bit-flips; it cannot catch a
6047    /// consistent mid-write snapshot).
6048    ///
6049    /// **The safe path for a live-served store is `POST /backup` on the HTTP
6050    /// server.** That handler acquires the read lock on the shared database
6051    /// before calling this method, which is the correct cross-process
6052    /// synchronisation point because the server is the single process writing
6053    /// the files.
6054    ///
6055    /// After copying, opens the destination read-only and runs the CRC section
6056    /// verifier (`verify_snapshot`) to confirm byte-for-byte integrity.
6057    /// `BackupReport::verified` reflects whether both checks passed.
6058    ///
6059    /// Returns `Err` when `self` is not backed by a `RealFs` (e.g. `SimFs`).
6060    pub fn backup_to(&self, dest: &std::path::Path) -> Result<BackupReport> {
6061        // Derive source directory from snapshot_path (RealFs only).
6062        let src_dir = match self.fs.snapshot_path() {
6063            Some(p) => p.parent().map(|d| d.to_path_buf()).ok_or_else(|| {
6064                GraphError::Io(std::io::Error::other("snapshot has no parent dir"))
6065            })?,
6066            None => {
6067                return Err(GraphError::Io(std::io::Error::other(
6068                    "backup_to requires a real filesystem (RealFs)",
6069                )))
6070            }
6071        };
6072
6073        std::fs::create_dir_all(dest)?;
6074
6075        let mut files: Vec<String> = Vec::new();
6076        let mut bytes: u64 = 0;
6077
6078        // Helper: copy src_dir/name → dest/name if the file exists.
6079        let mut try_copy = |name: &str| -> std::io::Result<()> {
6080            let src_path = src_dir.join(name);
6081            if src_path.exists() {
6082                let n = std::fs::copy(&src_path, dest.join(name))?;
6083                bytes += n;
6084                files.push(name.to_string());
6085            }
6086            Ok(())
6087        };
6088
6089        try_copy("snapshot.bin")?;
6090        try_copy("snapshot.bin.bak")?;
6091        try_copy("wal.bin")?;
6092        try_copy("wal.floor")?;
6093        try_copy("wal.genesis")?;
6094        try_copy("roles.json")?;
6095
6096        // Copy WAL archives.
6097        let archives = self.fs.list_archives()?;
6098        for n in &archives {
6099            let name = format!("wal.{n}.archive");
6100            let n_bytes = std::fs::copy(src_dir.join(&name), dest.join(&name))?;
6101            bytes += n_bytes;
6102            files.push(name);
6103        }
6104
6105        files.sort();
6106
6107        // Post-copy verification: open dest and run CRC checks.
6108        let snap_in_dest = dest.join("snapshot.bin").exists();
6109        let crc_ok = if snap_in_dest {
6110            crate::verify_snapshot(dest)
6111                .map(|results| results.iter().all(|(_, _, _, r)| r.is_ok()))
6112                .unwrap_or(false)
6113        } else {
6114            true // WAL-only store: nothing to CRC-check in snapshot
6115        };
6116        let opens_ok = GraphDb::<core_storage::fs::RealFs>::open(dest).is_ok();
6117        let verified = crc_ok && opens_ok;
6118
6119        Ok(BackupReport {
6120            files,
6121            bytes,
6122            verified,
6123        })
6124    }
6125
6126    // ── Export helpers ────────────────────────────────────────────────────────
6127
6128    /// All live nodes, sorted by key (deterministic).
6129    ///
6130    /// Reads base + WAL overlay. Tombstoned nodes are excluded.
6131    pub fn all_nodes_for_export(&self) -> Vec<NodeInfo> {
6132        self.ensure_v8_base_sections_loaded();
6133        let pv = self.props_view();
6134        let mut nodes = Vec::new();
6135        for id in 0..self.ids.len() as u32 {
6136            let Some(key) = self.ids.key_of(id) else {
6137                continue;
6138            };
6139            let Some(&sym) = self.labels.get(id as usize) else {
6140                continue;
6141            };
6142            if sym == u32::MAX {
6143                continue; // tombstoned
6144            }
6145            let Some(label) = self.syms.resolve(sym) else {
6146                continue;
6147            };
6148            let mut props = BTreeMap::new();
6149            for field in pv.field_names() {
6150                if let Some(vr) = pv.get(id, &field) {
6151                    props.insert(field, vr.into_value());
6152                }
6153            }
6154            nodes.push(NodeInfo {
6155                key: key.to_string(),
6156                label: label.to_string(),
6157                props,
6158            });
6159        }
6160        nodes.sort_by(|a, b| a.key.cmp(&b.key));
6161        nodes
6162    }
6163
6164    /// All directed edges, sorted by `(edge_type, src, dst)`. Each edge appears once.
6165    ///
6166    /// Derived edges carry `derived: true` and the creating rule's name in `rule`.
6167    /// Manual edges carry `derived: false` and `rule: None`.
6168    /// Deterministic across runs on the same store state.
6169    pub fn all_edges_for_export(&self) -> Vec<ExportEdge> {
6170        self.ensure_v8_base_sections_loaded();
6171
6172        // Build (etype_sym, src_id, dst_id) → rule_name for O(1) derivation lookup.
6173        let mut prov: HashMap<(u32, u32, u32), String> = HashMap::new();
6174        for (rule_name, triples) in self.engine.provenance() {
6175            for &(etype, src, dst) in triples {
6176                prov.insert((etype, src, dst), rule_name.clone());
6177            }
6178        }
6179
6180        let tv = self.topo_view();
6181        let mut edges = Vec::new();
6182
6183        for id in 0..self.ids.len() as u32 {
6184            let Some(key) = self.ids.key_of(id) else {
6185                continue;
6186            };
6187            let Some(&lsym) = self.labels.get(id as usize) else {
6188                continue;
6189            };
6190            if lsym == u32::MAX {
6191                continue; // tombstoned
6192            }
6193
6194            for etype_sym in tv.etypes() {
6195                // etype from archived CSR (access_unchecked, no eager CRC).
6196                // Skip edges whose etype is not in the interner; this can only
6197                // occur with a corrupt large TOPOLOGY section (bit-flip on an
6198                // etype field in the archived data).  The function returns Vec,
6199                // not Result, so we continue rather than propagate.
6200                let Some(edge_type) = self.syms.resolve(etype_sym) else {
6201                    continue;
6202                };
6203                let edge_type = edge_type.to_string();
6204                for &nbr in tv.neighbors(etype_sym, Direction::Out, id).as_ref() {
6205                    let Some(dst_key) = self.ids.key_of(nbr) else {
6206                        continue; // skip corrupt entries
6207                    };
6208                    let prov_key = (etype_sym, id, nbr);
6209                    let rule = prov.get(&prov_key).cloned();
6210                    let derived = rule.is_some();
6211                    edges.push(ExportEdge {
6212                        edge_type: edge_type.clone(),
6213                        src: key.to_string(),
6214                        dst: dst_key.to_string(),
6215                        derived,
6216                        rule,
6217                    });
6218                }
6219            }
6220        }
6221
6222        edges.sort_by(|a, b| {
6223            a.edge_type
6224                .cmp(&b.edge_type)
6225                .then(a.src.cmp(&b.src))
6226                .then(a.dst.cmp(&b.dst))
6227        });
6228        edges
6229    }
6230
6231    pub fn nodes_with_label(&self, label: &str) -> Vec<NodeRef<'_, F>> {
6232        self.view()
6233            .nodes_with_label(label)
6234            .into_iter()
6235            .map(|id| NodeRef { db: self, id })
6236            .collect()
6237    }
6238
6239    pub fn find_nodes(&self, label: &str, filter: &Filter) -> Vec<NodeRef<'_, F>> {
6240        let view = self.view();
6241        view.nodes_with_label(label)
6242            .into_iter()
6243            .filter(|&id| {
6244                eval_filter(filter, &|field| {
6245                    view.prop(id, field).map(|vr| vr.into_value())
6246                })
6247            })
6248            .map(|id| NodeRef { db: self, id })
6249            .collect()
6250    }
6251
6252    /// Returns `true` if any approximate (HNSW) VectorSimilar rule covers
6253    /// `field`.  Use as a capability probe: when `true`, `find_similar_vector`
6254    /// with `label = None` will use the native ANN path rather than the O(n)
6255    /// brute-force scan.
6256    pub fn has_vector_rule(&self, field: &str) -> bool {
6257        self.engine.hnsw_has_rule(field)
6258    }
6259
6260    /// Find nodes whose `field` vector is most similar to `q` (cosine
6261    /// similarity), returning up to `k` results with similarity ≥ `min`,
6262    /// sorted descending.
6263    ///
6264    /// When `label` is `None` the search spans all labels (via
6265    /// `hnsw_search_any_dst` or a full brute-force scan); when `label` is
6266    /// `Some(lbl)` it restricts to nodes with that label.
6267    ///
6268    /// Uses the HNSW index when one is available (fast path); otherwise falls
6269    /// back to an O(n) brute-force scan.
6270    pub fn find_similar_vector(
6271        &self,
6272        field: &str,
6273        label: Option<&str>,
6274        q: &[f64],
6275        k: usize,
6276        min: f64,
6277    ) -> Vec<(String, f64)> {
6278        // Ensure any HNSW blobs retained from the snapshot are deserialized
6279        // before the first ANN query on a clean-open (no-WAL) path.
6280        self.engine.ensure_hnsw_loaded();
6281        // L2-normalise query for cosine via dot product.
6282        let norm: f64 = q.iter().map(|x| x * x).sum::<f64>().sqrt();
6283        if norm == 0.0 {
6284            return vec![];
6285        }
6286        let q_unit: Vec<f64> = q.iter().map(|x| x / norm).collect();
6287
6288        // Try HNSW fast path.
6289        // `None` label searches across all VectorSimilar rules covering `field`
6290        // (merging their results); `Some(lbl)` restricts to rules whose
6291        // dst_label matches.  Returns `None` when no populated HNSW index
6292        // covers the request — the O(n) brute-force fallback handles that case.
6293        let hnsw_hits = match label {
6294            Some(lbl) => self.engine.hnsw_search_dst(field, lbl, &q_unit, k),
6295            None => self.engine.hnsw_search_any_dst(field, &q_unit, k),
6296        };
6297        if let Some(hits) = hnsw_hits {
6298            let mut out: Vec<(String, f64)> = hits
6299                .into_iter()
6300                .filter(|&(_, sim)| sim >= min)
6301                .filter_map(|(id, sim)| self.ids.key_of(id).map(|key| (key.to_string(), sim)))
6302                .collect();
6303            out.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
6304            out.truncate(k);
6305            return out;
6306        }
6307
6308        // Brute-force fallback: O(n) scan (only reached when no HNSW index
6309        // covers the request).
6310        let view = self.view();
6311        let candidate_ids: Vec<u32> = match label {
6312            Some(lbl) => view.nodes_with_label(lbl),
6313            None => view.nodes_all(),
6314        };
6315        let mut scored: Vec<(String, f64)> = candidate_ids
6316            .into_iter()
6317            .filter_map(|id| {
6318                let v = view.prop(id, field)?;
6319                let v_owned = v.into_value();
6320                let xs = value_as_float_list(&v_owned)?;
6321                let v_norm: f64 = xs.iter().map(|x| x * x).sum::<f64>().sqrt();
6322                if v_norm == 0.0 {
6323                    return None;
6324                }
6325                let dot: f64 = q_unit
6326                    .iter()
6327                    .zip(xs.iter())
6328                    .map(|(a, b)| a * (b / v_norm))
6329                    .sum();
6330                if dot < min {
6331                    return None;
6332                }
6333                let key = self.ids.key_of(id)?.to_string();
6334                Some((key, dot))
6335            })
6336            .collect();
6337        scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
6338        scored.truncate(k);
6339        scored
6340    }
6341
6342    /// Like [`find_similar_vector`] but restricts results to nodes visible in
6343    /// `mask`. Hidden nodes never appear in results; the mask is applied
6344    /// **before** k-truncation so a caller still receives up to `k` visible
6345    /// hits.
6346    ///
6347    /// # HNSW path (over-fetch policy)
6348    ///
6349    /// When an HNSW index covers the request, this function fetches `4 * k`
6350    /// candidates from the index and discards hidden nodes in the post-filter
6351    /// step.  If fewer than `k` visible nodes remain after filtering the caller
6352    /// receives whatever is available — we do not re-query the index.  The 4×
6353    /// multiplier is a heuristic suited for sparsely masked graphs; callers
6354    /// operating under a very selective mask should register a VectorSimilar
6355    /// rule with a non-approximate index, or use the brute-force path (no HNSW
6356    /// rule) which exhaustively filters through the masked [`GraphView`].
6357    ///
6358    /// # Brute-force path
6359    ///
6360    /// When no HNSW index covers the request the function builds a masked
6361    /// [`GraphView`] so that `nodes_all` / `nodes_with_label` return only
6362    /// visible nodes, guaranteeing exact `k` results (or all visible nodes if
6363    /// fewer than `k` exist).
6364    pub fn find_similar_vector_masked(
6365        &self,
6366        field: &str,
6367        label: Option<&str>,
6368        q: &[f64],
6369        k: usize,
6370        min: f64,
6371        mask: &crate::mask::NodeMask,
6372    ) -> Vec<(String, f64)> {
6373        self.engine.ensure_hnsw_loaded();
6374        let norm: f64 = q.iter().map(|x| x * x).sum::<f64>().sqrt();
6375        if norm == 0.0 {
6376            return vec![];
6377        }
6378        let q_unit: Vec<f64> = q.iter().map(|x| x / norm).collect();
6379
6380        // HNSW fast path — over-fetch 4×k so post-masking still yields up to k
6381        // visible hits.  See doc comment above for the policy rationale.
6382        let over_k = k.saturating_mul(4).max(k + 1);
6383        let hnsw_hits = match label {
6384            Some(lbl) => self.engine.hnsw_search_dst(field, lbl, &q_unit, over_k),
6385            None => self.engine.hnsw_search_any_dst(field, &q_unit, over_k),
6386        };
6387        if let Some(hits) = hnsw_hits {
6388            let mut out: Vec<(String, f64)> = hits
6389                .into_iter()
6390                .filter(|&(id, sim)| sim >= min && mask.visible.contains(&id))
6391                .filter_map(|(id, sim)| self.ids.key_of(id).map(|key| (key.to_string(), sim)))
6392                .collect();
6393            out.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
6394            out.truncate(k);
6395            return out;
6396        }
6397
6398        // Brute-force fallback — masked view ensures only visible nodes are
6399        // enumerated by nodes_all(); nodes_with_label() does not filter by
6400        // mask so we apply view.visible() explicitly for the labeled case.
6401        let view = self.view_masked(mask);
6402        let candidate_ids: Vec<u32> = match label {
6403            Some(lbl) => view
6404                .nodes_with_label(lbl)
6405                .into_iter()
6406                .filter(|&id| view.visible(id))
6407                .collect(),
6408            None => view.nodes_all(),
6409        };
6410        let mut scored: Vec<(String, f64)> = candidate_ids
6411            .into_iter()
6412            .filter_map(|id| {
6413                let v = view.prop(id, field)?;
6414                let v_owned = v.into_value();
6415                let xs = value_as_float_list(&v_owned)?;
6416                let v_norm: f64 = xs.iter().map(|x| x * x).sum::<f64>().sqrt();
6417                if v_norm == 0.0 {
6418                    return None;
6419                }
6420                let dot: f64 = q_unit
6421                    .iter()
6422                    .zip(xs.iter())
6423                    .map(|(a, b)| a * (b / v_norm))
6424                    .sum();
6425                if dot < min {
6426                    return None;
6427                }
6428                let key = self.ids.key_of(id)?.to_string();
6429                Some((key, dot))
6430            })
6431            .collect();
6432        scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
6433        scored.truncate(k);
6434        scored
6435    }
6436
6437    /// Read a single property from an edge.
6438    ///
6439    /// Returns `None` when the edge does not exist, the field is absent, or any
6440    /// of the string keys cannot be resolved to interned ids.  Only edge props
6441    /// written by rules (weight fields) are accessible without a `set_edge_prop`
6442    /// binding; topology-only edges (no props set) return `None` for every field.
6443    pub fn get_edge_prop(
6444        &self,
6445        edge_type: &str,
6446        src_key: &str,
6447        dst_key: &str,
6448        field: &str,
6449    ) -> Option<Value> {
6450        let etype = self.syms.get(edge_type)?;
6451        let src = self.ids.get(src_key)?;
6452        let dst = self.ids.get(dst_key)?;
6453        self.edge_props_view().get(etype, src, dst, field)
6454    }
6455
6456    /// Lex → parse → plan → execute `cypher` over a read-only view.
6457    /// Every pipeline `Err(String)` becomes `GraphError::QueryError` with a
6458    /// stage prefix (`lex:` / `parse:` / `plan:` / `execute:`).
6459    pub fn query(&self, cypher: &str, params: &BTreeMap<String, Value>) -> Result<ResultSet> {
6460        let tokens = lex(cypher).map_err(|e| GraphError::QueryError {
6461            detail: format!("lex: {e}"),
6462        })?;
6463        let ast = parse(&tokens).map_err(|e| GraphError::QueryError {
6464            detail: format!("parse: {e}"),
6465        })?;
6466        let ops = plan(&ast).map_err(|e| GraphError::QueryError {
6467            detail: format!("plan: {e}"),
6468        })?;
6469        execute(&self.view(), &ops, &Params(params)).map_err(|e| GraphError::QueryError {
6470            detail: format!("execute: {e}"),
6471        })
6472    }
6473
6474    /// Convenience entry-point that accepts a slice of `(name, value)` pairs
6475    /// instead of a pre-built `BTreeMap`.  Equivalent to building the map and
6476    /// calling [`GraphDb::query`].
6477    pub fn query_with_params(&self, cypher: &str, params: &[(&str, Value)]) -> Result<ResultSet> {
6478        let map: BTreeMap<String, Value> = params
6479            .iter()
6480            .map(|(k, v)| (k.to_string(), v.clone()))
6481            .collect();
6482        self.query(cypher, &map)
6483    }
6484
6485    /// Execute a Cypher write statement (CREATE / MATCH…SET / MATCH…DELETE / MERGE).
6486    ///
6487    /// All mutations flow through the same `insert_node` / `set_prop` /
6488    /// `delete_edge` / `insert_edge` path as the Rust API so the rule engine
6489    /// fires and the WAL captures everything with one fsync per statement.
6490    ///
6491    /// Returns a one-row [`ResultSet`] with columns `created`, `properties_set`,
6492    /// and `deleted` matching the write-result contract.
6493    ///
6494    /// **Mutation routing**: mutations are collected into a single
6495    /// [`BatchBuilder`] and committed atomically (one WAL `Batch` frame, one
6496    /// fsync). The MATCH phase for SET/DELETE uses a read-only `execute` call
6497    /// over `self.view()` — the borrow is dropped before the batch is opened.
6498    ///
6499    /// **Limitations (v1)**:
6500    /// - SET RHS must be a literal, `$param`, or arithmetic; bare property copy → named error.
6501    /// - `DETACH DELETE n` → calls `delete_node` for each matched node (removes all edges).
6502    /// - Bare `DELETE n` → error if n has any incident edges; succeeds for isolated nodes.
6503    /// - MERGE supports `ON CREATE SET` / `ON MATCH SET` in the same write batch.
6504    /// - Deleting a derived edge → named error "cannot delete derived edge".
6505    pub fn query_write(
6506        &mut self,
6507        cypher: &str,
6508        params: &BTreeMap<String, Value>,
6509    ) -> Result<ResultSet> {
6510        let tokens = lex(cypher).map_err(|e| GraphError::QueryError {
6511            detail: format!("lex: {e}"),
6512        })?;
6513        let stmt = parse_write(&tokens).map_err(|e| GraphError::QueryError {
6514            detail: format!("parse: {e}"),
6515        })?;
6516        self.exec_write_stmt(stmt, params)
6517    }
6518
6519    fn exec_write_stmt(
6520        &mut self,
6521        stmt: WriteStatement,
6522        params: &BTreeMap<String, Value>,
6523    ) -> Result<ResultSet> {
6524        match stmt {
6525            WriteStatement::Create(s) => self.exec_create(s, params),
6526            WriteStatement::MatchSet(s) => self.exec_match_set(s, params),
6527            WriteStatement::MatchDelete(s) => self.exec_match_delete(s, params),
6528            WriteStatement::MatchDeleteNode(s) => self.exec_match_delete_node(s, params),
6529            WriteStatement::Merge(s) => self.exec_merge(s, params),
6530        }
6531    }
6532
6533    fn exec_create(
6534        &mut self,
6535        stmt: core_query::cypher::CreateStmt,
6536        params: &BTreeMap<String, Value>,
6537    ) -> Result<ResultSet> {
6538        // Extract the node key from props: require a string-valued `id` field.
6539        let mut var_to_key: BTreeMap<String, String> = BTreeMap::new();
6540        for node in &stmt.nodes {
6541            let var = node.var.as_deref().unwrap_or("_cn0");
6542            let key = node
6543                .props
6544                .iter()
6545                .find(|(f, _)| f == "id")
6546                .and_then(|(_, v)| {
6547                    if let Value::Str(s) = v {
6548                        Some(s.clone())
6549                    } else {
6550                        None
6551                    }
6552                })
6553                .ok_or_else(|| GraphError::QueryError {
6554                    detail: format!(
6555                        "CREATE node ({}:{}) requires a string 'id' property",
6556                        var, node.label
6557                    ),
6558                })?;
6559            var_to_key.insert(var.to_string(), key);
6560        }
6561
6562        let mut batch = self.batch();
6563        let mut created: usize = 0;
6564        for node in &stmt.nodes {
6565            let var = node.var.as_deref().unwrap_or("_cn0");
6566            let key = &var_to_key[var];
6567            batch.insert_node(&node.label, key, node.props.clone());
6568            created += 1;
6569        }
6570        for edge in &stmt.edges {
6571            let src_key = var_to_key
6572                .get(&edge.src_var)
6573                .ok_or_else(|| GraphError::QueryError {
6574                    detail: format!("CREATE edge src variable '{}' is not bound", edge.src_var),
6575                })?;
6576            let dst_key = var_to_key
6577                .get(&edge.dst_var)
6578                .ok_or_else(|| GraphError::QueryError {
6579                    detail: format!("CREATE edge dst variable '{}' is not bound", edge.dst_var),
6580                })?;
6581            batch.insert_edge(&edge.etype, src_key, dst_key);
6582        }
6583        batch.commit()?;
6584
6585        // Optional RETURN clause: project created bindings as a read result.
6586        if let Some(returns) = stmt.returns {
6587            // Each created node is looked up by its key via a separate MATCH pattern.
6588            // Multiple single-node patterns cross-join to produce 1 output row with
6589            // all variables bound (each pattern returns exactly 1 row).
6590            let patterns: Vec<Pattern> = stmt
6591                .nodes
6592                .iter()
6593                .map(|node| {
6594                    let var = node.var.as_deref().unwrap_or("_cn0");
6595                    let key = var_to_key[var].clone();
6596                    Pattern {
6597                        start: NodePat {
6598                            var: Some(var.to_string()),
6599                            label: Some(node.label.clone()),
6600                            props: vec![("id".to_string(), Operand::Lit(Value::Str(key)))],
6601                        },
6602                        chain: vec![],
6603                        shortest: false,
6604                    }
6605                })
6606                .collect();
6607            let q = Query {
6608                matches: patterns,
6609                optional_clauses: vec![],
6610                where_expr: None,
6611                unwinds: vec![],
6612                post_unwind_where: None,
6613                stages: vec![],
6614                returns,
6615                distinct: false,
6616                order_by: vec![],
6617                skip: None,
6618                limit: None,
6619            };
6620            let ops = plan(&q).map_err(|e| GraphError::QueryError {
6621                detail: format!("plan: {e}"),
6622            })?;
6623            return execute(&self.view(), &ops, &Params(params)).map_err(|e| {
6624                GraphError::QueryError {
6625                    detail: format!("execute: {e}"),
6626                }
6627            });
6628        }
6629
6630        let mut rs = write_result_set();
6631        rs.push_row(vec![
6632            Some(Value::Int(created as i64)),
6633            Some(Value::Int(0)),
6634            Some(Value::Int(0)),
6635        ]);
6636        Ok(rs)
6637    }
6638
6639    fn exec_match_set(
6640        &mut self,
6641        stmt: core_query::cypher::MatchSetStmt,
6642        params: &BTreeMap<String, Value>,
6643    ) -> Result<ResultSet> {
6644        let project_returns = stmt.returns.clone();
6645        // Collect unique node vars targeted by SET clauses, plus RETURN bindings
6646        // so the post-write projection can look them up by key.
6647        let mut set_vars: Vec<String> = Vec::new();
6648        for s in &stmt.sets {
6649            if !set_vars.contains(&s.var) {
6650                set_vars.push(s.var.clone());
6651            }
6652        }
6653        let rel_vars = pattern_rel_vars(&stmt.matches);
6654        let mut lookup_vars = set_vars.clone();
6655        for v in pattern_node_vars(&stmt.matches) {
6656            add_var(&mut lookup_vars, &v);
6657        }
6658        if let Some(ref returns) = project_returns {
6659            for v in ret_node_vars(returns) {
6660                if !rel_vars.iter().any(|r| r == &v) {
6661                    add_var(&mut lookup_vars, &v);
6662                }
6663            }
6664        }
6665
6666        // Synthesize a read query: MATCH … WHERE … RETURN <lookup_vars>, <set_values…>
6667        // SET values are projected as ScalarExpr items so that arithmetic expressions
6668        // (e.g. `SET n.score = n.score * 1.5`) are evaluated in the matched-row context.
6669        let mut set_returns: Vec<RetItem> = lookup_vars
6670            .iter()
6671            .map(|v| RetItem {
6672                value: RetVal::Var(v.clone()),
6673                alias: None,
6674            })
6675            .collect();
6676        // One computed column per SET clause; alias is `__sv_<i>`.
6677        let set_val_cols: Vec<String> = stmt
6678            .sets
6679            .iter()
6680            .enumerate()
6681            .map(|(i, _)| format!("__sv_{i}"))
6682            .collect();
6683        for (sc, col) in stmt.sets.iter().zip(&set_val_cols) {
6684            set_returns.push(RetItem {
6685                value: RetVal::ScalarExpr(sc.value.clone()),
6686                alias: Some(col.clone()),
6687            });
6688        }
6689        // Capture relationship types while r is bound; SET does not change them.
6690        for r in &rel_vars {
6691            set_returns.push(RetItem {
6692                value: RetVal::FuncCall {
6693                    name: "type".into(),
6694                    args: vec![Operand::Var(r.clone())],
6695                },
6696                alias: Some(rel_type_alias(r)),
6697            });
6698        }
6699
6700        let read_q = Query {
6701            matches: stmt.matches.clone(),
6702            optional_clauses: vec![],
6703            where_expr: stmt.where_expr.clone(),
6704            unwinds: vec![],
6705            post_unwind_where: None,
6706            stages: vec![],
6707            returns: set_returns,
6708            distinct: false,
6709            order_by: vec![],
6710            skip: None,
6711            limit: None,
6712        };
6713        let ops = plan(&read_q).map_err(|e| GraphError::QueryError {
6714            detail: format!("plan: {e}"),
6715        })?;
6716        // MATCH phase is read-only; borrow ends before batch opens.
6717        //
6718        // When a role-scoped write is in flight, run the MATCH read through
6719        // view_masked so hidden nodes are invisible → hidden ≡ absent ≡
6720        // zero-rows (no SetProp ops generated, no existence-oracle 403).
6721        // Full-authority writes (pending_write_authz=None) keep view().
6722        let match_rs = {
6723            let mask_opt = self.pending_write_authz.as_ref().map(|a| a.mask.clone());
6724            if let Some(ref mask) = mask_opt {
6725                execute(&self.view_masked(mask), &ops, &Params(params))
6726            } else {
6727                execute(&self.view(), &ops, &Params(params))
6728            }
6729        }
6730        .map_err(|e| GraphError::QueryError {
6731            detail: format!("execute: {e}"),
6732        })?;
6733
6734        // Collect (key, field, value) for each matched row × each SET clause.
6735        let mut set_ops: Vec<(String, String, Value)> = Vec::new();
6736        for row_i in 0..match_rs.len() {
6737            for (sc, col) in stmt.sets.iter().zip(&set_val_cols) {
6738                let key = match match_rs.get(row_i, &sc.var) {
6739                    Some(Value::Str(k)) => k.clone(),
6740                    _ => {
6741                        return Err(GraphError::QueryError {
6742                            detail: format!(
6743                                "SET variable '{}' did not resolve to a node key",
6744                                sc.var
6745                            ),
6746                        })
6747                    }
6748                };
6749                // The SET value was already evaluated by the executor.
6750                let value = match match_rs.get(row_i, col) {
6751                    Some(v) => v.clone(),
6752                    None => {
6753                        return Err(GraphError::QueryError {
6754                            detail: format!(
6755                                "SET value for {}.{} evaluated to null",
6756                                sc.var, sc.field
6757                            ),
6758                        })
6759                    }
6760                };
6761                set_ops.push((key, sc.field.clone(), value));
6762            }
6763        }
6764
6765        // Apply as one atomic batch.
6766        let props_set = set_ops.len();
6767        let mut batch = self.batch();
6768        for (key, field, value) in set_ops {
6769            batch.set_prop(&key, &field, value);
6770        }
6771        batch.commit()?;
6772
6773        if let Some(returns) = project_returns {
6774            return project_set_return_rows(self, &rel_vars, &match_rs, &returns, params);
6775        }
6776
6777        let mut rs = write_result_set();
6778        rs.push_row(vec![
6779            Some(Value::Int(0)),
6780            Some(Value::Int(props_set as i64)),
6781            Some(Value::Int(0)),
6782        ]);
6783        Ok(rs)
6784    }
6785
6786    fn exec_match_delete(
6787        &mut self,
6788        stmt: core_query::cypher::MatchDeleteStmt,
6789        params: &BTreeMap<String, Value>,
6790    ) -> Result<ResultSet> {
6791        // Collect unique node vars needed to identify edge endpoints.
6792        let mut node_vars: Vec<String> = Vec::new();
6793        for ed in &stmt.deletes {
6794            if !node_vars.contains(&ed.src_var) {
6795                node_vars.push(ed.src_var.clone());
6796            }
6797            if !node_vars.contains(&ed.dst_var) {
6798                node_vars.push(ed.dst_var.clone());
6799            }
6800        }
6801
6802        // Synthesize read query.
6803        let returns: Vec<RetItem> = node_vars
6804            .iter()
6805            .map(|v| RetItem {
6806                value: RetVal::Var(v.clone()),
6807                alias: None,
6808            })
6809            .collect();
6810        let read_q = Query {
6811            matches: stmt.matches,
6812            optional_clauses: vec![],
6813            where_expr: stmt.where_expr,
6814            unwinds: vec![],
6815            post_unwind_where: None,
6816            stages: vec![],
6817            returns,
6818            distinct: false,
6819            order_by: vec![],
6820            skip: None,
6821            limit: None,
6822        };
6823        let ops = plan(&read_q).map_err(|e| GraphError::QueryError {
6824            detail: format!("plan: {e}"),
6825        })?;
6826        // Role-scoped writes: mask the MATCH read phase so hidden nodes are
6827        // invisible → hidden ≡ absent ≡ zero-rows (spec §3.1, hidden ≡ absent).
6828        let match_rs = {
6829            let mask_opt = self.pending_write_authz.as_ref().map(|a| a.mask.clone());
6830            if let Some(ref mask) = mask_opt {
6831                execute(&self.view_masked(mask), &ops, &Params(params))
6832            } else {
6833                execute(&self.view(), &ops, &Params(params))
6834            }
6835        }
6836        .map_err(|e| GraphError::QueryError {
6837            detail: format!("execute: {e}"),
6838        })?;
6839
6840        // Collect (etype, src_key, dst_key) for each row × each delete target.
6841        let mut del_ops: Vec<(String, String, String)> = Vec::new();
6842        for row_i in 0..match_rs.len() {
6843            for ed in &stmt.deletes {
6844                let src_key = match match_rs.get(row_i, &ed.src_var) {
6845                    Some(Value::Str(k)) => k.clone(),
6846                    _ => {
6847                        return Err(GraphError::QueryError {
6848                            detail: format!(
6849                                "DELETE src variable '{}' did not resolve to a node key",
6850                                ed.src_var
6851                            ),
6852                        })
6853                    }
6854                };
6855                let dst_key = match match_rs.get(row_i, &ed.dst_var) {
6856                    Some(Value::Str(k)) => k.clone(),
6857                    _ => {
6858                        return Err(GraphError::QueryError {
6859                            detail: format!(
6860                                "DELETE dst variable '{}' did not resolve to a node key",
6861                                ed.dst_var
6862                            ),
6863                        })
6864                    }
6865                };
6866                del_ops.push((ed.etype.clone(), src_key, dst_key));
6867            }
6868        }
6869
6870        // Apply as one atomic batch.
6871        let deleted = del_ops.len();
6872        let mut batch = self.batch();
6873        for (etype, src_key, dst_key) in del_ops {
6874            batch.delete_edge(&etype, &src_key, &dst_key);
6875        }
6876        batch.commit().map_err(|e| match e {
6877            GraphError::RuleOwned { .. } => GraphError::QueryError {
6878                detail: "cannot delete derived edge; retract via the rule or change the property"
6879                    .to_string(),
6880            },
6881            other => other,
6882        })?;
6883
6884        let mut rs = write_result_set();
6885        rs.push_row(vec![
6886            Some(Value::Int(0)),
6887            Some(Value::Int(0)),
6888            Some(Value::Int(deleted as i64)),
6889        ]);
6890        Ok(rs)
6891    }
6892
6893    /// Execute `MATCH … [DETACH] DELETE <node_var> [, …]`.
6894    ///
6895    /// Collects the matching node keys via an ephemeral read query, then calls
6896    /// `delete_node` on each one.  When `stmt.detach` is `false` (bare DELETE)
6897    /// the executor first checks that the node has no incident edges; if any
6898    /// remain it returns a named error matching openCypher semantics.
6899    fn exec_match_delete_node(
6900        &mut self,
6901        stmt: MatchDeleteNodeStmt,
6902        params: &BTreeMap<String, Value>,
6903    ) -> Result<ResultSet> {
6904        // Build a read query returning only the node keys we need.
6905        let returns: Vec<RetItem> = stmt
6906            .node_vars
6907            .iter()
6908            .map(|v| RetItem {
6909                value: RetVal::Var(v.clone()),
6910                alias: None,
6911            })
6912            .collect();
6913        let read_q = Query {
6914            matches: stmt.matches,
6915            optional_clauses: vec![],
6916            where_expr: stmt.where_expr,
6917            unwinds: vec![],
6918            post_unwind_where: None,
6919            stages: vec![],
6920            returns,
6921            distinct: false,
6922            order_by: vec![],
6923            skip: None,
6924            limit: None,
6925        };
6926        let ops = plan(&read_q).map_err(|e| GraphError::QueryError {
6927            detail: format!("plan: {e}"),
6928        })?;
6929        // Role-scoped writes: mask the MATCH read phase so hidden nodes are
6930        // invisible → hidden ≡ absent ≡ zero-rows (spec §3.1, hidden ≡ absent).
6931        let match_rs = {
6932            let mask_opt = self.pending_write_authz.as_ref().map(|a| a.mask.clone());
6933            if let Some(ref mask) = mask_opt {
6934                execute(&self.view_masked(mask), &ops, &Params(params))
6935            } else {
6936                execute(&self.view(), &ops, &Params(params))
6937            }
6938        }
6939        .map_err(|e| GraphError::QueryError {
6940            detail: format!("execute: {e}"),
6941        })?;
6942
6943        // Collect unique node keys to delete (deduplicate across rows × vars).
6944        let mut keys: Vec<String> = Vec::new();
6945        for row_i in 0..match_rs.len() {
6946            for var in &stmt.node_vars {
6947                if let Some(Value::Str(k)) = match_rs.get(row_i, var) {
6948                    if !keys.contains(k) {
6949                        keys.push(k.clone());
6950                    }
6951                }
6952            }
6953        }
6954
6955        if !stmt.detach {
6956            // openCypher bare DELETE: error if any matched node has incident edges.
6957            for key in &keys {
6958                if let Some(id) = self.ids.get(key) {
6959                    let tv = self.topo_view();
6960                    let has_edges = tv.etypes().any(|et| {
6961                        !tv.neighbors(et, Direction::Out, id).is_empty()
6962                            || !tv.neighbors(et, Direction::In, id).is_empty()
6963                    });
6964                    if has_edges {
6965                        return Err(GraphError::QueryError {
6966                            detail: format!(
6967                                "Cannot delete node `{key}` because it still has incident edges. \
6968                                 Use DETACH DELETE to remove the node and all its edges."
6969                            ),
6970                        });
6971                    }
6972                }
6973            }
6974        }
6975
6976        let mut nodes_deleted = 0i64;
6977        let mut edges_deleted = 0i64;
6978        for key in keys {
6979            match self.delete_node(&key) {
6980                Ok(report) => {
6981                    nodes_deleted += 1;
6982                    edges_deleted += (report.manual_edges + report.derived_edges) as i64;
6983                }
6984                Err(GraphError::KeyNotFound { .. }) => {
6985                    // Node may have been deleted by an earlier iteration (e.g., via
6986                    // multiple MATCH rows for the same node).  Safe to skip.
6987                }
6988                Err(e) => return Err(e),
6989            }
6990        }
6991
6992        let mut rs = write_result_set();
6993        rs.push_row(vec![
6994            Some(Value::Int(0)),
6995            Some(Value::Int(0)),
6996            Some(Value::Int(nodes_deleted + edges_deleted)),
6997        ]);
6998        Ok(rs)
6999    }
7000
7001    fn exec_merge(
7002        &mut self,
7003        stmt: core_query::cypher::MergeStmt,
7004        params: &BTreeMap<String, Value>,
7005    ) -> Result<ResultSet> {
7006        // MERGE: check if a node with the given key already exists.
7007        let key = match &stmt.key_value {
7008            Value::Str(s) => s.clone(),
7009            _ => {
7010                return Err(GraphError::QueryError {
7011                    detail: format!(
7012                        "MERGE key value must be a string (got {:?})",
7013                        stmt.key_value
7014                    ),
7015                })
7016            }
7017        };
7018
7019        if let Some(var) = stmt.var.as_deref() {
7020            for sc in stmt.on_create.iter().chain(&stmt.on_match) {
7021                if sc.var != var {
7022                    return Err(GraphError::QueryError {
7023                        detail: format!(
7024                            "SET variable '{}' does not match MERGE variable '{var}'",
7025                            sc.var
7026                        ),
7027                    });
7028                }
7029            }
7030        }
7031
7032        // ── MERGE authz pre-check (when role-scoped) ─────────────────────────
7033        //
7034        // MERGE scope precondition: check create OR update scope for the
7035        // declared label BEFORE calling `has_node` (timing-oracle closure,
7036        // spec §6.2 "MERGE visibility oracle" item: hidden ≡ absent for
7037        // unscoped roles — the scope denial fires without touching the key store).
7038        //
7039        // Clone to avoid holding a borrow on `self.pending_write_authz` while
7040        // also calling `self.ids.get(key)`.
7041        let merge_existed: bool = if let Some(authz) = self.pending_write_authz.clone() {
7042            let has_create = authz.scope.create_labels.contains(&stmt.label);
7043            let has_update = authz.scope.update_labels.contains(&stmt.label);
7044            if !has_create && !has_update {
7045                // Scope-before-lookup: 403 without has_node call (timing oracle
7046                // closure — see test_merge_unscoped_no_key_lookup).
7047                return Err(GraphError::RoleWriteDenied {
7048                    reason: format!(
7049                        "role-bound token: label '{}' not in write scope (create_labels)",
7050                        stmt.label
7051                    ),
7052                });
7053            }
7054            // Key lookup under mask.
7055            match self.ids.get(key.as_str()) {
7056                Some(id) if authz.mask.contains_id(id) => {
7057                    // Visible: must have update scope to proceed to match arm.
7058                    if !has_update {
7059                        return Err(GraphError::RoleWriteDenied {
7060                            reason: format!(
7061                                "role-bound token: label '{}' not in write scope (update_labels)",
7062                                stmt.label
7063                            ),
7064                        });
7065                    }
7066                    true // existed = true → match arm
7067                }
7068                Some(_) => {
7069                    // Hidden: same error as absent to the role (spec §3.1/§3.3).
7070                    return Err(GraphError::RoleWriteDenied {
7071                        reason: "role-bound token: target node not visible".into(),
7072                    });
7073                }
7074                None => {
7075                    // Absent: must have create scope to proceed to the create arm.
7076                    //
7077                    // Update-only roles (create_labels empty, update_labels set):
7078                    // return the SAME "not visible" error as the hidden-key branch
7079                    // so hidden ≡ absent — no distinguishing oracle (spec §6.1
7080                    // "confirm existence of hidden nodes: No").
7081                    //
7082                    // Create-scoped roles (has_create=true): absent → create arm
7083                    // as before.  The accepted structural key-existence disclosure
7084                    // (§THREAT-MODEL) applies only when the role holds create scope.
7085                    if !has_create {
7086                        return Err(GraphError::RoleWriteDenied {
7087                            reason: "role-bound token: target node not visible".into(),
7088                        });
7089                    }
7090                    false // existed = false → create arm
7091                }
7092            }
7093        } else {
7094            // Full authority: use the existing non-masked has_node check.
7095            self.has_node(&key)
7096        };
7097
7098        let existed = merge_existed;
7099        let mut created = 0i64;
7100        if !existed || !stmt.on_match.is_empty() {
7101            let mut batch = self.batch();
7102            if !existed {
7103                let props = vec![(stmt.key_field.clone(), stmt.key_value.clone())];
7104                batch.insert_node(&stmt.label, &key, props);
7105                for sc in &stmt.on_create {
7106                    let value = resolve_merge_set_value(&sc.value, params)?;
7107                    batch.set_prop(&key, &sc.field, value);
7108                }
7109                created = 1;
7110            } else {
7111                for sc in &stmt.on_match {
7112                    let value = resolve_merge_set_value(&sc.value, params)?;
7113                    batch.set_prop(&key, &sc.field, value);
7114                }
7115            }
7116            batch.commit()?;
7117        }
7118
7119        // Refresh the role mask so the just-created node is visible to this
7120        // statement's RETURN (read-after-write). Safe: create_labels ⊆ read labels
7121        // (apply_schema subset rule), so the new node's label is already in the
7122        // role's read scope — this never widens beyond the role's declared labels.
7123        if !existed {
7124            if let Some(role) = self.pending_write_authz.as_ref().map(|a| a.role.clone()) {
7125                let new_mask = self.mask_for_role(&role)?;
7126                if let Some(a) = self.pending_write_authz.as_mut() {
7127                    a.mask = new_mask;
7128                }
7129            }
7130        }
7131
7132        // Optional RETURN clause: project the node (created or matched) as a read result.
7133        if let Some(returns) = stmt.returns {
7134            let var = stmt.var.as_deref().unwrap_or("_mn0");
7135            let q = Query {
7136                matches: vec![Pattern {
7137                    start: NodePat {
7138                        var: Some(var.to_string()),
7139                        label: Some(stmt.label.clone()),
7140                        props: vec![("id".to_string(), Operand::Lit(stmt.key_value.clone()))],
7141                    },
7142                    chain: vec![],
7143                    shortest: false,
7144                }],
7145                optional_clauses: vec![],
7146                where_expr: None,
7147                unwinds: vec![],
7148                post_unwind_where: None,
7149                stages: vec![],
7150                returns,
7151                distinct: false,
7152                order_by: vec![],
7153                skip: None,
7154                limit: None,
7155            };
7156            let ops = plan(&q).map_err(|e| GraphError::QueryError {
7157                detail: format!("plan: {e}"),
7158            })?;
7159            // Use view_masked when a role-scoped write is in flight so the
7160            // post-merge projection is consistent with the masked read phase.
7161            let mask_opt = self.pending_write_authz.as_ref().map(|a| a.mask.clone());
7162            return (if let Some(ref mask) = mask_opt {
7163                execute(&self.view_masked(mask), &ops, &Params(params))
7164            } else {
7165                execute(&self.view(), &ops, &Params(params))
7166            })
7167            .map_err(|e| GraphError::QueryError {
7168                detail: format!("execute: {e}"),
7169            });
7170        }
7171
7172        let mut rs = write_result_set();
7173        rs.push_row(vec![
7174            Some(Value::Int(created)),
7175            Some(Value::Int(0)),
7176            Some(Value::Int(0)),
7177        ]);
7178        Ok(rs)
7179    }
7180
7181    /// Return all rule-owned edges between `key_a` and `key_b` (either direction),
7182    /// annotated with rule name, edge type, direction, and weight.
7183    /// Results are sorted by (rule, edge_type).
7184    /// Returns `Err(KeyNotFound)` if either key is unknown.
7185    pub fn explain(&self, key_a: &str, key_b: &str) -> Result<Vec<Explanation>> {
7186        self.ensure_v8_base_sections_loaded();
7187        let id_a = self
7188            .ids
7189            .get(key_a)
7190            .ok_or_else(|| GraphError::KeyNotFound { key: key_a.into() })?;
7191        let id_b = self
7192            .ids
7193            .get(key_b)
7194            .ok_or_else(|| GraphError::KeyNotFound { key: key_b.into() })?;
7195
7196        let mut results = Vec::new();
7197
7198        // Walk the smaller incident set so explain is O(min(deg(a), deg(b)))
7199        // rather than O(total provenance).
7200        let scan = if self.engine.provenance_touching_len(id_a)
7201            <= self.engine.provenance_touching_len(id_b)
7202        {
7203            id_a
7204        } else {
7205            id_b
7206        };
7207        for (rule_name, etype, src, dst) in self.engine.provenance_touching(scan) {
7208            if !((src == id_a && dst == id_b) || (src == id_b && dst == id_a)) {
7209                continue;
7210            }
7211            let Some(rule_def) = self.engine.rules().find(|r| r.name == rule_name) else {
7212                continue;
7213            };
7214            let edge_type = match self.syms.resolve(etype) {
7215                Some(s) => s.to_string(),
7216                None => continue,
7217            };
7218            // Provenance (src, dst) ids come from the archived PROVENANCE section
7219            // (large, no eager CRC).  A corrupt section can produce ids that are
7220            // out of range; return Corrupt rather than panic.
7221            let src_key = self
7222                .ids
7223                .key_of(src)
7224                .ok_or_else(|| GraphError::Corrupt {
7225                    detail: format!("v8: provenance src id {src} not in id table"),
7226                })?
7227                .to_string();
7228            let dst_key = self
7229                .ids
7230                .key_of(dst)
7231                .ok_or_else(|| GraphError::Corrupt {
7232                    detail: format!("v8: provenance dst id {dst} not in id table"),
7233                })?
7234                .to_string();
7235            let weight = rule_def.weight_prop.as_deref().and_then(|prop| {
7236                self.edge_props_view()
7237                    .get(etype, src, dst, prop)
7238                    .and_then(|v| {
7239                        if let Value::Float(f) = v {
7240                            Some(f)
7241                        } else {
7242                            None
7243                        }
7244                    })
7245            });
7246            results.push(Explanation {
7247                rule: rule_name.to_string(),
7248                edge_type,
7249                src_key,
7250                dst_key,
7251                weight,
7252                predicate: PredicateSummary {
7253                    approximate: rule_def.approximate,
7254                    ..PredicateSummary::from(&rule_def.predicate)
7255                },
7256            });
7257        }
7258
7259        results.sort_by(|a, b| a.rule.cmp(&b.rule).then(a.edge_type.cmp(&b.edge_type)));
7260        Ok(results)
7261    }
7262
7263    pub fn neighbors(&self, key: &str, edge_type: &str, dir: Direction) -> Result<Vec<String>> {
7264        let id = self
7265            .ids
7266            .get(key)
7267            .ok_or_else(|| GraphError::KeyNotFound { key: key.into() })?;
7268        let Some(sym) = self.syms.get(edge_type) else {
7269            return Ok(Vec::new());
7270        };
7271        self.topo_view()
7272            .neighbors(sym, dir, id)
7273            .iter()
7274            .map(|&n| {
7275                self.ids
7276                    .key_of(n)
7277                    .map(|k| k.to_string())
7278                    .ok_or_else(|| GraphError::Corrupt {
7279                        detail: format!("topology id {n} has no key"),
7280                    })
7281            })
7282            .collect::<Result<Vec<_>>>()
7283    }
7284
7285    /// Return the last-change commit sequence for `key`, or `None` if the node
7286    /// does not exist or has never been mutated since the last V5-V7 snapshot
7287    /// (horizon-bounded for legacy stores).
7288    ///
7289    /// The returned sequence is a monotonically increasing counter that starts
7290    /// at 1 for the first commit after `open` and increments with every
7291    /// successful write.  WAL replay at open also assigns sequences (1..N for N
7292    /// replayed frames), so sequences are consistent across snapshot+WAL cycles.
7293    ///
7294    /// For V5-V7 stores opened without a V8 snapshot, nodes that were present
7295    /// in the snapshot but not touched by any WAL frame will return `None`
7296    /// (horizon-bounded: CAS against such nodes is only safe after the first
7297    /// V8 snapshot or after the node is next mutated).
7298    pub fn last_changed(&self, key: &str) -> Option<u64> {
7299        let id = self.ids.get(key)?;
7300        self.last_change.get(&id).copied()
7301    }
7302
7303    /// The current commit sequence (number of successful commits since open,
7304    /// including WAL replay frames).  Useful for recording a baseline before
7305    /// a read-modify-write cycle.
7306    pub fn commit_seq(&self) -> u64 {
7307        self.commit_seq
7308    }
7309
7310    /// Check that all `preconds` are satisfied against the current db state.
7311    /// Returns `Err(GraphError::CasConflict)` on the first failing precondition.
7312    pub(crate) fn check_preconditions(&self, preconds: &[Precondition]) -> Result<()> {
7313        for precond in preconds {
7314            match precond {
7315                Precondition::NodeUnchangedSince { key, expected } => {
7316                    // Missing entry means the node predates the WAL window or
7317                    // does not exist; treat as 0 (before any commit).
7318                    let actual = self.last_changed(key).unwrap_or_default();
7319                    if actual != *expected {
7320                        return Err(GraphError::CasConflict {
7321                            key: key.clone(),
7322                            expected: *expected,
7323                            actual,
7324                        });
7325                    }
7326                }
7327                Precondition::NodeAbsent { key } => {
7328                    // Node must not exist (not live).
7329                    if self.ids.get(key).is_some() {
7330                        let actual = self.last_changed(key).unwrap_or(0);
7331                        return Err(GraphError::CasConflict {
7332                            key: key.clone(),
7333                            expected: u64::MAX,
7334                            actual,
7335                        });
7336                    }
7337                }
7338            }
7339        }
7340        Ok(())
7341    }
7342
7343    /// Apply a batch of mutations with compare-and-set preconditions.
7344    ///
7345    /// All preconditions are checked atomically before any operation is applied.
7346    /// If any precondition fails, the entire batch is rejected with
7347    /// [`GraphError::CasConflict`] and no WAL frame is written.
7348    ///
7349    /// # Returns
7350    /// `(nodes_inserted, edges_inserted)` on success, same as [`write_batch`].
7351    ///
7352    /// # Errors
7353    /// - [`GraphError::CasConflict`] if any precondition is not satisfied.
7354    /// - Any error that [`write_batch`] would return for the ops themselves.
7355    pub fn write_batch_cas(
7356        &mut self,
7357        preconds: Vec<Precondition>,
7358        ops: Vec<BatchOp>,
7359    ) -> Result<(usize, usize)> {
7360        self.check_preconditions(&preconds)?;
7361        self.commit_logged_batch(ops, None, None)
7362    }
7363
7364    /// Update the per-node last-change map for a WAL record at commit `seq`.
7365    ///
7366    /// Called after a successful apply to record which nodes were touched.
7367    /// For replay, called with the WAL-frame's replayed seq.
7368    ///
7369    /// Touch definition (see [`Precondition`] doc):
7370    /// - InsertNode / InsertNodeId / SetProp / SetPropId / RemoveProp → the node.
7371    /// - InsertEdge / InsertEdgeId / DeleteEdge → both src and dst.
7372    /// - DeleteNode → node tombstoned; last_changed() returns None so no update needed.
7373    /// - DerivedEdge markers, Intern, rule/view records → no-ops.
7374    /// - Batch → recurse into inner records.
7375    fn update_last_change_from_rec(&mut self, rec: &WalRecord, seq: u64) {
7376        match rec {
7377            WalRecord::InsertNode { key, .. }
7378            | WalRecord::SetProp { key, .. }
7379            | WalRecord::RemoveProp { key, .. } => {
7380                if let Some(id) = self.ids.get(key) {
7381                    self.last_change.insert(id, seq);
7382                }
7383            }
7384            WalRecord::InsertNodeId { key, .. } => {
7385                if let Some(id) = self.ids.get(key) {
7386                    self.last_change.insert(id, seq);
7387                }
7388            }
7389            WalRecord::SetPropId { id, .. } => {
7390                self.last_change.insert(*id, seq);
7391            }
7392            WalRecord::InsertEdge {
7393                src_key, dst_key, ..
7394            }
7395            | WalRecord::DeleteEdge {
7396                src_key, dst_key, ..
7397            } => {
7398                if let Some(src_id) = self.ids.get(src_key) {
7399                    self.last_change.insert(src_id, seq);
7400                }
7401                if let Some(dst_id) = self.ids.get(dst_key) {
7402                    self.last_change.insert(dst_id, seq);
7403                }
7404            }
7405            WalRecord::InsertEdgeId { src, dst, .. } => {
7406                self.last_change.insert(*src, seq);
7407                self.last_change.insert(*dst, seq);
7408            }
7409            // DeleteNode: node is tombstoned; last_changed(key) returns None for
7410            // deleted keys (ids.get() returns None post-tombstone), so no update needed.
7411            // History markers: state no-ops; the underlying mutation already
7412            // touched the relevant nodes' last_change entries.
7413            WalRecord::DeleteNode { .. }
7414            | WalRecord::DerivedEdgeAdded { .. }
7415            | WalRecord::DerivedEdgeRetracted { .. }
7416            | WalRecord::Intern { .. }
7417            | WalRecord::CreateRule { .. }
7418            | WalRecord::DeleteRule { .. }
7419            | WalRecord::RebuildRule { .. }
7420            | WalRecord::CreateView { .. }
7421            | WalRecord::DeleteView { .. }
7422            | WalRecord::EnableFulltext { .. }
7423            | WalRecord::DisableFulltext { .. } => {}
7424            // RenameNode: node id is stable; update last_change via the new key.
7425            // Called after apply(), so ids already reflects new_key.
7426            WalRecord::RenameNode { new_key, .. } => {
7427                if let Some(id) = self.ids.get(new_key) {
7428                    self.last_change.insert(id, seq);
7429                }
7430            }
7431            WalRecord::Batch(inner) => {
7432                for inner_rec in inner {
7433                    self.update_last_change_from_rec(inner_rec, seq);
7434                }
7435            }
7436        }
7437    }
7438
7439    pub fn node_count(&self) -> usize {
7440        self.ids.len()
7441    }
7442
7443    /// Configure archive retention: keep the `N` newest WAL archives at each
7444    /// [`snapshot_with`] call when `archive_wal: true`.
7445    ///
7446    /// `Some(N)` where N > 0 → prune oldest archives keeping the newest N.
7447    /// `Some(0)` or `None` → unlimited (no pruning).
7448    ///
7449    /// Pruning only ever happens inside [`snapshot_with`]; this method only
7450    /// stores the policy.  Archives below the retention limit are deleted
7451    /// oldest-first.  The horizon floor is updated so that
7452    /// [`was_linked`] / history APIs return `CommitOutOfRange` for commits
7453    /// in pruned archives rather than silently returning wrong data.
7454    pub fn set_wal_archive_retention(&mut self, keep: Option<u32>) {
7455        self.wal_archive_retention = keep;
7456    }
7457
7458    /// Delete any WAL archives that are fully below the current horizon floor.
7459    ///
7460    /// Orphaned archives arise when the floor is written first during retention
7461    /// pruning and then a crash interrupts the archive-delete sequence.  The
7462    /// opening cleanup ensures no subsequent read path sees stale data.
7463    ///
7464    /// Under the monotonic naming scheme, the archive name N equals the
7465    /// cumulative end-frame index of the archive in global commit space (i.e.
7466    /// the archive covers global frames `[prev_n, N)`).  An archive is
7467    /// fully orphaned when `N <= wal_horizon_floor`: all of its frames fall
7468    /// below the floor and have already been counted in it.
7469    fn cleanup_orphaned_archives(&mut self) -> Result<()> {
7470        if self.wal_horizon_floor == 0 {
7471            // Floor at 0 means no pruning has ever occurred; nothing to clean.
7472            return Ok(());
7473        }
7474        let archive_ns = self.fs.list_archives()?;
7475        for n in archive_ns {
7476            if n <= self.wal_horizon_floor {
7477                // Archive N ends at global frame N; all its frames are below
7478                // the floor (floor already accounts for them) → orphaned.
7479                self.fs.delete_archive(n).map_err(GraphError::Io)?;
7480            } else {
7481                // Archives are sorted ascending; first one above floor stops scan.
7482                break;
7483            }
7484        }
7485        Ok(())
7486    }
7487
7488    /// Collect all WAL frames from surviving archives (oldest-first) then the
7489    /// live WAL into one flat list, and return the total along with the number
7490    /// of archive frames at the front of the list.
7491    ///
7492    /// Commit indices into the returned list are LOCAL (0 = first frame of
7493    /// oldest surviving archive).  To obtain the GLOBAL index add
7494    /// `self.wal_horizon_floor`.
7495    fn all_frames(&self) -> Result<(Vec<WalRecord>, u64)> {
7496        let archive_ns = self.fs.list_archives()?;
7497        let mut all: Vec<WalRecord> = Vec::new();
7498        for n in archive_ns {
7499            let bytes = self.fs.read_archive(n)?;
7500            let (frames, _) = decode_all(&bytes);
7501            all.extend(frames);
7502        }
7503        let archive_count = all.len() as u64;
7504        let live_bytes = self.fs.read(FileId::Wal)?;
7505        let (live_frames, _) = decode_all(&live_bytes);
7506        all.extend(live_frames);
7507        Ok((all, archive_count))
7508    }
7509
7510    /// Return the total number of committed WAL frames visible in the current
7511    /// horizon window, including frames in surviving WAL archives.
7512    ///
7513    /// This is the exclusive upper bound for valid `at_commit` indices in
7514    /// `was_linked`. Valid indices are `wal_horizon_floor()..wal_total_commits()`.
7515    ///
7516    /// Returns the horizon floor when all surviving history is empty.
7517    pub fn wal_total_commits(&self) -> Result<u64> {
7518        let (frames, _) = self.all_frames()?;
7519        Ok(self.wal_horizon_floor + frames.len() as u64)
7520    }
7521
7522    /// The global frame index of the first commit reachable through surviving
7523    /// archives (0 when no archives have been pruned).
7524    pub fn wal_horizon_floor(&self) -> u64 {
7525        self.wal_horizon_floor
7526    }
7527
7528    /// Return the per-node change history for `key` by scanning the on-disk WAL.
7529    ///
7530    /// ## Horizon
7531    ///
7532    /// History reaches back only to the last WAL-truncating snapshot, exactly like `open_at`.
7533    /// Snapshots written with `keep_wal: true` preserve deeper history. This is the honest,
7534    /// zero-cost contract; a durable history log is out of scope.
7535    ///
7536    /// ## Derived edges
7537    ///
7538    /// Rule-created (derived) edges are **not** in the WAL and therefore do not appear in
7539    /// history. Only edges written directly by the application are recorded.
7540    ///
7541    /// ## Deleted nodes
7542    ///
7543    /// For nodes that have been deleted, dense-id records (SetPropId, InsertEdgeId) that
7544    /// predate the deletion may not resolve (the id is tombstoned in the live map). The
7545    /// string-keyed `DeleteNode` record still matches and produces a `NodeDeleted` entry.
7546    /// Prop/edge history of a deleted node may therefore be partially unresolvable.
7547    ///
7548    /// ## Dense-id edge entries and tombstoned partners
7549    ///
7550    /// Edge entries from dense-id WAL records (`InsertEdgeId`) are omitted when the partner
7551    /// endpoint's dense id is tombstoned. As a result, a live node's history can contain an
7552    /// `EdgeRemoved` (string-keyed, always resolves) without a corresponding `EdgeAdded`.
7553    /// Build commit-bounded alias intervals for `queried_key`.
7554    ///
7555    /// Returns a list of `(key, valid_from_inclusive, valid_until_exclusive)` tuples.
7556    /// A record written under `key` at commit `c` matches the queried identity iff
7557    /// `c >= valid_from && (valid_until.is_none() || c < valid_until)`.
7558    ///
7559    /// Each alias entry carries both a lower and an upper bound so that key-reuse
7560    /// after a rename is handled correctly: if "a" is renamed to "b" at commit 5,
7561    /// then a NEW node is created as "a" at commit 7 and renamed to "c" at commit 10,
7562    /// querying "c" must NOT surface identity-1's events (commits 0–4 under "a");
7563    /// only identity-2's events (commits 7–9 under "a") are in scope.
7564    ///
7565    /// Only **forward aliasing**: querying the *new* key surfaces events written
7566    /// under the *old* key.  The reverse direction is not supported.
7567    fn build_key_alias_intervals(
7568        &self,
7569        frames: &[core_storage::wal::WalRecord],
7570        queried_key: &str,
7571    ) -> Vec<(String, u64, Option<u64>)> {
7572        use core_storage::wal::WalRecord;
7573
7574        // Pre-pass: build reverse_rename and key_starts maps.
7575        let mut reverse_rename: HashMap<String, (String, u64)> = HashMap::new();
7576        let mut key_starts: HashMap<String, Vec<u64>> = HashMap::new();
7577
7578        for (local_i, frame) in frames.iter().enumerate() {
7579            let commit = self.wal_horizon_floor + local_i as u64;
7580            let records: &[WalRecord] = match frame {
7581                WalRecord::Batch(inner) => inner.as_slice(),
7582                single => std::slice::from_ref(single),
7583            };
7584            for rec in records {
7585                match rec {
7586                    WalRecord::InsertNode { key, .. } | WalRecord::InsertNodeId { key, .. } => {
7587                        key_starts.entry(key.clone()).or_default().push(commit);
7588                    }
7589                    WalRecord::RenameNode { old_key, new_key } => {
7590                        // new_key came into existence at this commit.
7591                        key_starts.entry(new_key.clone()).or_default().push(commit);
7592                        // Record the reverse rename: new_key was introduced by renaming old_key.
7593                        reverse_rename.insert(new_key.clone(), (old_key.clone(), commit));
7594                    }
7595                    _ => {}
7596                }
7597            }
7598        }
7599
7600        // Build alias intervals by following the reverse rename chain.
7601        let mut result: Vec<(String, u64, Option<u64>)> = Vec::new();
7602        let mut current_key = queried_key.to_string();
7603        let mut current_valid_until: Option<u64> = None;
7604
7605        loop {
7606            // valid_from: the most recent commit where current_key was assigned to this
7607            // identity.  For aliases (valid_until = Some(vu)), find the last start event
7608            // for the key strictly before vu — this is where the alias's occupancy by
7609            // this identity began, correctly excluding prior identities that reused the key.
7610            let valid_from = if let Some(vu) = current_valid_until {
7611                key_starts
7612                    .get(&current_key)
7613                    .and_then(|starts| starts.iter().rev().find(|&&s| s < vu).copied())
7614                    .unwrap_or(self.wal_horizon_floor)
7615            } else {
7616                // Queried key — no upper bound; may have been introduced at any commit.
7617                self.wal_horizon_floor
7618            };
7619
7620            result.push((current_key.clone(), valid_from, current_valid_until));
7621
7622            match reverse_rename.get(&current_key) {
7623                Some((old_key, rename_commit)) => {
7624                    current_valid_until = Some(*rename_commit);
7625                    current_key = old_key.clone();
7626                }
7627                None => break,
7628            }
7629        }
7630
7631        result
7632    }
7633
7634    /// Returns true if `record_key` matches any alias interval that covers `commit`.
7635    fn aliases_match(
7636        intervals: &[(String, u64, Option<u64>)],
7637        record_key: &str,
7638        commit: u64,
7639    ) -> bool {
7640        intervals
7641            .iter()
7642            .any(|(k, vf, vu)| k == record_key && commit >= *vf && vu.is_none_or(|u| commit < u))
7643    }
7644
7645    pub fn node_history(&self, key: &str) -> Result<Vec<crate::history::HistoryEntry>> {
7646        use crate::history::{HistoryChange, HistoryEntry};
7647        use core_storage::wal::WalRecord;
7648
7649        let (frames, _) = self.all_frames()?;
7650
7651        // Resolve commit-bounded alias intervals for `key` (handles renames in the WAL).
7652        let alias_intervals = self.build_key_alias_intervals(&frames, key);
7653
7654        let mut out: Vec<HistoryEntry> = Vec::new();
7655
7656        for (local_i, frame) in frames.iter().enumerate() {
7657            let commit = self.wal_horizon_floor + local_i as u64;
7658            // Collect the inner records to process — Batch is one commit, single records are one commit.
7659            let records: &[WalRecord] = match frame {
7660                WalRecord::Batch(inner) => inner.as_slice(),
7661                single => std::slice::from_ref(single),
7662            };
7663
7664            for rec in records {
7665                let change = match rec {
7666                    WalRecord::InsertNode { label, key: k, .. }
7667                        if Self::aliases_match(&alias_intervals, k, commit) =>
7668                    {
7669                        Some(HistoryChange::NodeInserted {
7670                            label: label.clone(),
7671                        })
7672                    }
7673                    WalRecord::InsertNodeId { label, key: k, .. }
7674                        if Self::aliases_match(&alias_intervals, k, commit) =>
7675                    {
7676                        let label_str = match self.syms.resolve(*label) {
7677                            Some(s) => s.to_string(),
7678                            None => continue,
7679                        };
7680                        Some(HistoryChange::NodeInserted { label: label_str })
7681                    }
7682                    WalRecord::SetProp {
7683                        key: k,
7684                        field,
7685                        value,
7686                    } if Self::aliases_match(&alias_intervals, k, commit) => {
7687                        Some(HistoryChange::PropSet {
7688                            field: field.clone(),
7689                            value: value.clone(),
7690                        })
7691                    }
7692                    WalRecord::SetPropId { id, field, value } => match self.ids.key_of(*id) {
7693                        // key_of returns the current (post-rename) key; compare to queried key.
7694                        Some(resolved) if resolved == key => {
7695                            let field_str = match self.syms.resolve(*field) {
7696                                Some(s) => s.to_string(),
7697                                None => continue,
7698                            };
7699                            Some(HistoryChange::PropSet {
7700                                field: field_str,
7701                                value: value.clone(),
7702                            })
7703                        }
7704                        _ => None,
7705                    },
7706                    WalRecord::RemoveProp { key: k, field }
7707                        if Self::aliases_match(&alias_intervals, k, commit) =>
7708                    {
7709                        Some(HistoryChange::PropRemoved {
7710                            field: field.clone(),
7711                        })
7712                    }
7713                    WalRecord::InsertEdge {
7714                        edge_type,
7715                        src_key,
7716                        dst_key,
7717                    } => {
7718                        if Self::aliases_match(&alias_intervals, src_key, commit) {
7719                            Some(HistoryChange::EdgeAdded {
7720                                edge_type: edge_type.clone(),
7721                                other: dst_key.clone(),
7722                                outgoing: true,
7723                            })
7724                        } else if Self::aliases_match(&alias_intervals, dst_key, commit) {
7725                            Some(HistoryChange::EdgeAdded {
7726                                edge_type: edge_type.clone(),
7727                                other: src_key.clone(),
7728                                outgoing: false,
7729                            })
7730                        } else {
7731                            None
7732                        }
7733                    }
7734                    WalRecord::InsertEdgeId { etype, src, dst } => {
7735                        let etype_str = match self.syms.resolve(*etype) {
7736                            Some(s) => s.to_string(),
7737                            None => continue,
7738                        };
7739                        let src_key = self.ids.key_of(*src);
7740                        let dst_key = self.ids.key_of(*dst);
7741                        if src_key == Some(key) {
7742                            let other = match dst_key {
7743                                Some(s) => s.to_string(),
7744                                None => continue,
7745                            };
7746                            Some(HistoryChange::EdgeAdded {
7747                                edge_type: etype_str,
7748                                other,
7749                                outgoing: true,
7750                            })
7751                        } else if dst_key == Some(key) {
7752                            let other = match src_key {
7753                                Some(s) => s.to_string(),
7754                                None => continue,
7755                            };
7756                            Some(HistoryChange::EdgeAdded {
7757                                edge_type: etype_str,
7758                                other,
7759                                outgoing: false,
7760                            })
7761                        } else {
7762                            None
7763                        }
7764                    }
7765                    WalRecord::DeleteEdge {
7766                        edge_type,
7767                        src_key,
7768                        dst_key,
7769                    } => {
7770                        if Self::aliases_match(&alias_intervals, src_key, commit) {
7771                            Some(HistoryChange::EdgeRemoved {
7772                                edge_type: edge_type.clone(),
7773                                other: dst_key.clone(),
7774                                outgoing: true,
7775                            })
7776                        } else if Self::aliases_match(&alias_intervals, dst_key, commit) {
7777                            Some(HistoryChange::EdgeRemoved {
7778                                edge_type: edge_type.clone(),
7779                                other: src_key.clone(),
7780                                outgoing: false,
7781                            })
7782                        } else {
7783                            None
7784                        }
7785                    }
7786                    WalRecord::DeleteNode { key: k }
7787                        if Self::aliases_match(&alias_intervals, k, commit) =>
7788                    {
7789                        Some(HistoryChange::NodeDeleted)
7790                    }
7791                    // Skip: rule/view/fulltext/intern metadata; Batch wrapper handled above.
7792                    _ => None,
7793                };
7794
7795                if let Some(change) = change {
7796                    out.push(HistoryEntry { commit, change });
7797                }
7798            }
7799        }
7800
7801        Ok(out)
7802    }
7803
7804    /// Return the per-edge change history between nodes `a` and `b` by scanning
7805    /// the on-disk WAL.
7806    ///
7807    /// ## Horizon
7808    ///
7809    /// History reaches back only to the last WAL-truncating snapshot, exactly
7810    /// like `node_history` and `open_at`. The returned [`HistoryResult`] carries
7811    /// `total_commits` (= number of WAL frames), which is the exclusive upper
7812    /// bound for valid commit indices.
7813    ///
7814    /// ## Derived edges
7815    ///
7816    /// Rule-derived edges appear via `DerivedEdgeAdded` / `DerivedEdgeRetracted`
7817    /// WAL markers written by `log_then_apply_with` after each rule-firing
7818    /// mutation. The `rule` field of those events carries the rule name.
7819    ///
7820    /// ## DeleteNode
7821    ///
7822    /// When a node is deleted, its manual incident edges are swept inline without
7823    /// individual `DeleteEdge` WAL records. `edge_history` detects `DeleteNode`
7824    /// events for either endpoint and synthesises `Retracted(rule:None)` events
7825    /// for each manual edge that was active at that point. Derived edges active at
7826    /// the time of deletion are handled by the `DerivedEdgeRetracted` marker that
7827    /// the engine appends immediately after the `DeleteNode` record; those events
7828    /// carry correct rule attribution and are emitted by the marker arm, not the
7829    /// synthetic sweep.
7830    ///
7831    /// ## Masks
7832    ///
7833    /// Like `node_history`, this method has no mask parameter and returns WAL
7834    /// history regardless of any role mask. For masked history semantics, apply
7835    /// the mask at the caller level.
7836    pub fn edge_history(
7837        &self,
7838        a: &str,
7839        b: &str,
7840    ) -> Result<crate::history::HistoryResult<crate::history::EdgeHistoryEvent>> {
7841        use crate::history::{EdgeEvent, EdgeHistoryEvent, HistoryResult};
7842        use core_storage::wal::WalRecord;
7843
7844        let (frames, _) = self.all_frames()?;
7845        let total_commits = self.wal_horizon_floor + frames.len() as u64;
7846
7847        // Resolve all historical names for a and b (handles RenameNode in the WAL).
7848        // Intervals are commit-bounded so recycled keys don't contaminate histories.
7849        let alias_a = self.build_key_alias_intervals(&frames, a);
7850        let alias_b = self.build_key_alias_intervals(&frames, b);
7851
7852        // Active edges between a and b tracked as (edge_type, src_key, dst_key, is_derived).
7853        // The is_derived flag is used by the DeleteNode sweep: manual edges are
7854        // swept with a synthetic Retracted(rule:None); derived edges are skipped
7855        // because the engine writes a DerivedEdgeRetracted marker immediately after
7856        // the DeleteNode record, which carries the correct rule attribution.
7857        let mut active: Vec<(String, String, String, bool)> = Vec::new();
7858        let mut out: Vec<EdgeHistoryEvent> = Vec::new();
7859
7860        for (local_i, frame) in frames.iter().enumerate() {
7861            let commit = self.wal_horizon_floor + local_i as u64;
7862            let records: &[WalRecord] = match frame {
7863                WalRecord::Batch(inner) => inner.as_slice(),
7864                single => std::slice::from_ref(single),
7865            };
7866
7867            for rec in records {
7868                match rec {
7869                    WalRecord::InsertEdge {
7870                        edge_type,
7871                        src_key,
7872                        dst_key,
7873                    } => {
7874                        let is_ab = Self::aliases_match(&alias_a, src_key, commit)
7875                            && Self::aliases_match(&alias_b, dst_key, commit);
7876                        let is_ba = Self::aliases_match(&alias_b, src_key, commit)
7877                            && Self::aliases_match(&alias_a, dst_key, commit);
7878                        if is_ab || is_ba {
7879                            active.push((
7880                                edge_type.clone(),
7881                                src_key.clone(),
7882                                dst_key.clone(),
7883                                false,
7884                            ));
7885                            out.push(EdgeHistoryEvent {
7886                                edge_type: edge_type.clone(),
7887                                commit,
7888                                event: EdgeEvent::Added,
7889                                rule: None,
7890                            });
7891                        }
7892                    }
7893                    WalRecord::InsertEdgeId { etype, src, dst } => {
7894                        let etype_str = match self.syms.resolve(*etype) {
7895                            Some(s) => s.to_string(),
7896                            None => continue,
7897                        };
7898                        // Use key_of_historical so tombstoned nodes (deleted
7899                        // later in the WAL) still resolve during the scan.
7900                        let src_key = self.ids.key_of_historical(*src);
7901                        let dst_key = self.ids.key_of_historical(*dst);
7902                        let is_ab = src_key == Some(a) && dst_key == Some(b);
7903                        let is_ba = src_key == Some(b) && dst_key == Some(a);
7904                        if is_ab || is_ba {
7905                            let src_str = src_key.unwrap().to_string();
7906                            let dst_str = dst_key.unwrap().to_string();
7907                            active.push((etype_str.clone(), src_str, dst_str, false));
7908                            out.push(EdgeHistoryEvent {
7909                                edge_type: etype_str,
7910                                commit,
7911                                event: EdgeEvent::Added,
7912                                rule: None,
7913                            });
7914                        }
7915                    }
7916                    WalRecord::DeleteEdge {
7917                        edge_type,
7918                        src_key,
7919                        dst_key,
7920                    } => {
7921                        let is_ab = Self::aliases_match(&alias_a, src_key, commit)
7922                            && Self::aliases_match(&alias_b, dst_key, commit);
7923                        let is_ba = Self::aliases_match(&alias_b, src_key, commit)
7924                            && Self::aliases_match(&alias_a, dst_key, commit);
7925                        if is_ab || is_ba {
7926                            // Remove the first matching active entry (flag ignored).
7927                            if let Some(pos) = active.iter().position(|(et, s, d, _)| {
7928                                et == edge_type && s == src_key && d == dst_key
7929                            }) {
7930                                active.remove(pos);
7931                            }
7932                            out.push(EdgeHistoryEvent {
7933                                edge_type: edge_type.clone(),
7934                                commit,
7935                                event: EdgeEvent::Retracted,
7936                                rule: None,
7937                            });
7938                        }
7939                    }
7940                    WalRecord::DeleteNode { key: k }
7941                        if Self::aliases_match(&alias_a, k, commit)
7942                            || Self::aliases_match(&alias_b, k, commit) =>
7943                    {
7944                        // Sweep: implicitly retract only MANUAL active edges.
7945                        // Derived active edges are skipped here because the rule
7946                        // engine appends a DerivedEdgeRetracted marker immediately
7947                        // after this DeleteNode record; that marker produces the
7948                        // single correctly-attributed Retracted event.  Derived
7949                        // entries are dropped from `active` (the marker arm's
7950                        // idempotent retain finds nothing to remove).
7951                        for (et, _, _, is_derived) in active.drain(..) {
7952                            if !is_derived {
7953                                out.push(EdgeHistoryEvent {
7954                                    edge_type: et,
7955                                    commit,
7956                                    event: EdgeEvent::Retracted,
7957                                    rule: None,
7958                                });
7959                            }
7960                            // Derived: drop silently; marker carries the Retracted event.
7961                        }
7962                    }
7963                    WalRecord::DerivedEdgeAdded {
7964                        rule,
7965                        edge_type: et,
7966                        src_key,
7967                        dst_key,
7968                    } => {
7969                        let is_ab = Self::aliases_match(&alias_a, src_key, commit)
7970                            && Self::aliases_match(&alias_b, dst_key, commit);
7971                        let is_ba = Self::aliases_match(&alias_b, src_key, commit)
7972                            && Self::aliases_match(&alias_a, dst_key, commit);
7973                        if is_ab || is_ba {
7974                            active.push((et.clone(), src_key.clone(), dst_key.clone(), true));
7975                            out.push(EdgeHistoryEvent {
7976                                edge_type: et.clone(),
7977                                commit,
7978                                event: EdgeEvent::Added,
7979                                rule: Some(rule.clone()),
7980                            });
7981                        }
7982                    }
7983                    WalRecord::DerivedEdgeRetracted {
7984                        rule,
7985                        edge_type: et,
7986                        src_key,
7987                        dst_key,
7988                    } => {
7989                        let is_ab = Self::aliases_match(&alias_a, src_key, commit)
7990                            && Self::aliases_match(&alias_b, dst_key, commit);
7991                        let is_ba = Self::aliases_match(&alias_b, src_key, commit)
7992                            && Self::aliases_match(&alias_a, dst_key, commit);
7993                        if is_ab || is_ba {
7994                            // Push unconditionally: a derived edge whose Added marker
7995                            // predates the history horizon has no `active` entry, but
7996                            // the retraction is still a real in-window event.
7997                            // Remove from active idempotently if present.
7998                            active.retain(|(aet, s, d, _)| {
7999                                !(aet == et && s == src_key && d == dst_key)
8000                            });
8001                            out.push(EdgeHistoryEvent {
8002                                edge_type: et.clone(),
8003                                commit,
8004                                event: EdgeEvent::Retracted,
8005                                rule: Some(rule.clone()),
8006                            });
8007                        }
8008                    }
8009                    // All other records (InsertNode, SetProp, CreateRule, etc.)
8010                    // do not affect edges between a and b.
8011                    _ => {}
8012                }
8013            }
8014        }
8015
8016        Ok(HistoryResult {
8017            items: out,
8018            total_commits,
8019        })
8020    }
8021
8022    /// Return `true` iff an edge of `edge_type` existed between `a` and `b`
8023    /// (in either direction) at the WAL commit `at_commit`.
8024    ///
8025    /// ## Horizon
8026    ///
8027    /// Valid commit indices are `0..total_commits` where `total_commits` is the
8028    /// number of WAL frames. An `at_commit >= total_commits` is outside the
8029    /// visible horizon and returns [`GraphError::CommitOutOfRange`].
8030    ///
8031    /// ## Derived edges
8032    ///
8033    /// Rule-derived edges are tracked via `DerivedEdgeAdded` / `DerivedEdgeRetracted`
8034    /// WAL markers appended at firing time (Task 1). `was_linked` reads these markers
8035    /// and therefore includes derived edges in its point-in-time evaluation,
8036    /// matching `edge_history`'s fidelity.
8037    pub fn was_linked(&self, a: &str, b: &str, edge_type: &str, at_commit: u64) -> Result<bool> {
8038        use core_storage::wal::WalRecord;
8039
8040        let (frames, _) = self.all_frames()?;
8041        let total_commits = self.wal_horizon_floor + frames.len() as u64;
8042
8043        // Horizon floor: commits in pruned archives are unreachable.
8044        if at_commit < self.wal_horizon_floor {
8045            return Err(GraphError::CommitOutOfRange {
8046                commit: at_commit,
8047                total: total_commits,
8048            });
8049        }
8050        if at_commit >= total_commits {
8051            return Err(GraphError::CommitOutOfRange {
8052                commit: at_commit,
8053                total: total_commits,
8054            });
8055        }
8056
8057        // Resolve all historical names for a and b (handles RenameNode in the WAL).
8058        // Intervals are commit-bounded so recycled keys don't contaminate point-in-time reads.
8059        let alias_a = self.build_key_alias_intervals(&frames, a);
8060        let alias_b = self.build_key_alias_intervals(&frames, b);
8061
8062        // Local index into surviving frames (0 = first frame of oldest archive).
8063        let local_commit = at_commit - self.wal_horizon_floor;
8064
8065        // Replay local frames 0..=local_commit, tracking active edges.
8066        let mut active: BTreeSet<(String, String, String)> = BTreeSet::new();
8067
8068        for (local_i, frame) in frames.iter().enumerate().take((local_commit + 1) as usize) {
8069            let commit = self.wal_horizon_floor + local_i as u64;
8070            let records: &[WalRecord] = match frame {
8071                WalRecord::Batch(inner) => inner.as_slice(),
8072                single => std::slice::from_ref(single),
8073            };
8074
8075            for rec in records {
8076                match rec {
8077                    WalRecord::InsertEdge {
8078                        edge_type: et,
8079                        src_key,
8080                        dst_key,
8081                    } => {
8082                        let is_ab = Self::aliases_match(&alias_a, src_key, commit)
8083                            && Self::aliases_match(&alias_b, dst_key, commit);
8084                        let is_ba = Self::aliases_match(&alias_b, src_key, commit)
8085                            && Self::aliases_match(&alias_a, dst_key, commit);
8086                        if is_ab || is_ba {
8087                            active.insert((et.clone(), src_key.clone(), dst_key.clone()));
8088                        }
8089                    }
8090                    WalRecord::InsertEdgeId { etype, src, dst } => {
8091                        let etype_str = match self.syms.resolve(*etype) {
8092                            Some(s) => s.to_string(),
8093                            None => continue,
8094                        };
8095                        // Use key_of_historical so tombstoned nodes resolve.
8096                        let src_key = self.ids.key_of_historical(*src);
8097                        let dst_key = self.ids.key_of_historical(*dst);
8098                        let is_ab = src_key == Some(a) && dst_key == Some(b);
8099                        let is_ba = src_key == Some(b) && dst_key == Some(a);
8100                        if is_ab || is_ba {
8101                            active.insert((
8102                                etype_str,
8103                                src_key.unwrap().to_string(),
8104                                dst_key.unwrap().to_string(),
8105                            ));
8106                        }
8107                    }
8108                    WalRecord::DeleteEdge {
8109                        edge_type: et,
8110                        src_key,
8111                        dst_key,
8112                    } => {
8113                        let is_ab = Self::aliases_match(&alias_a, src_key, commit)
8114                            && Self::aliases_match(&alias_b, dst_key, commit);
8115                        let is_ba = Self::aliases_match(&alias_b, src_key, commit)
8116                            && Self::aliases_match(&alias_a, dst_key, commit);
8117                        if is_ab || is_ba {
8118                            active.remove(&(et.clone(), src_key.clone(), dst_key.clone()));
8119                        }
8120                    }
8121                    WalRecord::DeleteNode { key: k }
8122                        if Self::aliases_match(&alias_a, k, commit)
8123                            || Self::aliases_match(&alias_b, k, commit) =>
8124                    {
8125                        // All edges touching the deleted node are gone.
8126                        active.retain(|(_, s, d)| s != k && d != k);
8127                    }
8128                    WalRecord::DerivedEdgeAdded {
8129                        edge_type: et,
8130                        src_key,
8131                        dst_key,
8132                        ..
8133                    } => {
8134                        let is_ab = Self::aliases_match(&alias_a, src_key, commit)
8135                            && Self::aliases_match(&alias_b, dst_key, commit);
8136                        let is_ba = Self::aliases_match(&alias_b, src_key, commit)
8137                            && Self::aliases_match(&alias_a, dst_key, commit);
8138                        if is_ab || is_ba {
8139                            active.insert((et.clone(), src_key.clone(), dst_key.clone()));
8140                        }
8141                    }
8142                    WalRecord::DerivedEdgeRetracted {
8143                        edge_type: et,
8144                        src_key,
8145                        dst_key,
8146                        ..
8147                    } => {
8148                        let is_ab = Self::aliases_match(&alias_a, src_key, commit)
8149                            && Self::aliases_match(&alias_b, dst_key, commit);
8150                        let is_ba = Self::aliases_match(&alias_b, src_key, commit)
8151                            && Self::aliases_match(&alias_a, dst_key, commit);
8152                        if is_ab || is_ba {
8153                            active.remove(&(et.clone(), src_key.clone(), dst_key.clone()));
8154                        }
8155                    }
8156                    _ => {}
8157                }
8158            }
8159        }
8160
8161        Ok(active.iter().any(|(et, _, _)| et == edge_type))
8162    }
8163
8164    pub fn edge_count(&self) -> u64 {
8165        self.topo_view().edge_count()
8166    }
8167
8168    /// Live/tombstone/edge counts plus per-rule provenance size, trip latch,
8169    /// and fire counter (includes rebuild evaluations). Rules are sorted by name.
8170    pub fn stats(&self) -> Stats {
8171        self.ensure_v8_base_sections_loaded();
8172        let rules: Vec<RuleStats> = self
8173            .engine
8174            .rules()
8175            .map(|r| RuleStats {
8176                name: r.name.clone(),
8177                edges: self
8178                    .engine
8179                    .provenance()
8180                    .get(&r.name)
8181                    .map(|s| s.len() as u64)
8182                    .unwrap_or(0),
8183                tripped: self.engine.is_tripped(&r.name),
8184                fires: self.engine.fire_count(&r.name),
8185                approximate: r.approximate,
8186            })
8187            .collect();
8188        Stats {
8189            nodes_live: self.ids.live_len(),
8190            nodes_tombstoned: self.ids.len() - self.ids.live_len(),
8191            edges: self.topo_view().edge_count(),
8192            rules,
8193        }
8194    }
8195
8196    /// On-disk snapshot format version this binary writes and reads.
8197    pub fn format_version() -> u16 {
8198        core_storage::snapshot::VERSION
8199    }
8200
8201    /// Test-support: total bytes appended (SimFs only usage).
8202    pub fn fs_total_appended(&self) -> usize
8203    where
8204        F: FsIntrospect,
8205    {
8206        self.fs.total_appended()
8207    }
8208
8209    /// Test-support: successful `Fs::sync` calls (SimFs / counting fs).
8210    pub fn fs_sync_count(&self) -> usize
8211    where
8212        F: FsIntrospect,
8213    {
8214        self.fs.sync_count()
8215    }
8216
8217    /// Consume the db, returning its fs (for crash simulation).
8218    pub fn into_fs(self) -> F {
8219        self.fs
8220    }
8221
8222    pub fn snapshot(&mut self) -> Result<()> {
8223        self.snapshot_with(SnapshotOptions::default())
8224    }
8225
8226    /// Snapshot with explicit options.
8227    ///
8228    /// # `keep_wal`
8229    ///
8230    /// When `keep_wal` is `false` (the default, same as [`snapshot`]):
8231    ///   - The WAL is replaced with a minimal baseline containing one
8232    ///     `EnableFulltext` record per active declaration.  All pre-snapshot
8233    ///     history is discarded; `open_at` can only reach post-snapshot commits.
8234    ///
8235    /// When `keep_wal` is `true`:
8236    ///   - The WAL is left intact.  All pre-snapshot commits remain reachable
8237    ///     via `open_at`.  The existing WAL already contains the original
8238    ///     `EnableFulltext` records, so no baseline re-write is needed; the
8239    ///     recovery guards in `apply()` silently skip any duplicate records on
8240    ///     replay.
8241    ///   - Crash window: a crash after the snapshot write but before the next
8242    ///     WAL write leaves the full pre-snapshot WAL intact.  On reopen the
8243    ///     snapshot is loaded and the WAL replayed idempotently over it — safe
8244    ///     because every `apply()` arm is idempotent when replayed over an
8245    ///     already-current snapshot.
8246    pub fn snapshot_with(&mut self, opts: SnapshotOptions) -> Result<()> {
8247        if self.read_only {
8248            return Err(GraphError::ReadOnly);
8249        }
8250        // Capture whether snapshot.bin already existed BEFORE this snapshot write.
8251        // Used by the archive path's conservative genesis-chain check: if a prior
8252        // snapshot exists but wal.truncated does not, we cannot distinguish a
8253        // legacy store (may have been truncated in an older code version) from a
8254        // new store that only used keep_wal=true.  Conservative: refuse genesis in
8255        // both cases.  Must be sampled here, before the snapshot write below.
8256        let had_prior_snapshot = self.fs.snapshot_path().map(|p| p.exists()).unwrap_or(false);
8257        self.ensure_v8_base_sections_loaded();
8258        // Ensure provenance is decoded before to_persist() clones it.
8259        self.engine.ensure_provenance_loaded_mut();
8260        let (rule_defs_typed, provenance, rule_tripped, rule_fires) = self.engine.to_persist();
8261        let rule_defs = rule_defs_typed
8262            .iter()
8263            .map(|r| bincode::serialize(r).expect("RuleDef serialize cannot fail"))
8264            .collect();
8265        // Collect HNSW state and IVF state.  When indexes are not yet
8266        // populated (clean open, no mutation since open), pass the retained
8267        // raw bytes through directly so that migrate/snapshot does not
8268        // silently discard fitted approximate-rule indexes.
8269        let hnsw_state = self.engine.export_hnsw_state_passthrough();
8270        let ivf_bytes = if !self.engine.indexes_populated() {
8271            // Pass retained IVF bytes through unchanged (no re-encode).
8272            self.engine.retained_ivf_bytes_clone().unwrap_or_default()
8273        } else {
8274            // Indexes live: encode from current state.
8275            let raw_ivf = self.engine.export_ivf_state();
8276            let ivf_state_map: BTreeMap<String, core_storage::snapshot::PerRuleIvfState> = raw_ivf
8277                .into_iter()
8278                .map(|(name, ((sc, sa, sd), (dc, da, dd)))| {
8279                    (
8280                        name,
8281                        core_storage::snapshot::PerRuleIvfState {
8282                            src: core_storage::snapshot::SideIvfState {
8283                                centroids: sc,
8284                                clusters: sa,
8285                                drift: sd,
8286                            },
8287                            dst: core_storage::snapshot::SideIvfState {
8288                                centroids: dc,
8289                                clusters: da,
8290                                drift: dd,
8291                            },
8292                        },
8293                    )
8294                })
8295                .collect();
8296            if ivf_state_map.is_empty() {
8297                Vec::new()
8298            } else {
8299                bincode::serialize(&ivf_state_map).expect("IVF state serialize cannot fail")
8300            }
8301        };
8302        let view_defs: Vec<Vec<u8>> = self
8303            .view_store
8304            .views()
8305            .map(|v| bincode::serialize(v).expect("ViewDef serialize cannot fail"))
8306            .collect();
8307        if self.base.is_some() {
8308            // V8 merge-snapshot path: encode base+overlay into a new V8 snapshot,
8309            // write it atomically, remap it as the new base, then clear the overlay.
8310            let meta = V8Meta {
8311                labels: self.labels.clone(),
8312                edge_props: self.edge_props.clone(),
8313                rule_defs,
8314                provenance,
8315                rule_tripped,
8316                rule_fires,
8317                ivf_bytes,
8318                view_defs,
8319                wal_truncated: !opts.keep_wal,
8320                hnsw: hnsw_state,
8321                last_change: self.last_change.clone(),
8322            };
8323            let mut buf: Vec<u8> = Vec::new();
8324            {
8325                // Clone the Arc so the old base stays alive while we encode.
8326                // The borrow of archived_csr (into old_base's mmap) is released
8327                // at the end of this block, before we replace self.base.
8328                let old_base = self.base.clone().expect("is_some checked above");
8329                let archived_csr = old_base.topology().map_err(|e| GraphError::Corrupt {
8330                    detail: format!("v8 snapshot: topology section: {e:?}"),
8331                })?;
8332                let archived_cols = old_base.columns().map_err(|e| GraphError::Corrupt {
8333                    detail: format!("v8 snapshot: columns section: {e:?}"),
8334                })?;
8335                let archived_edge_props =
8336                    old_base
8337                        .edge_props_section()
8338                        .map_err(|e| GraphError::Corrupt {
8339                            detail: format!("v8 snapshot: edge_props section: {e:?}"),
8340                        })?;
8341                let edge_props_raw =
8342                    old_base
8343                        .edge_props_raw_bytes()
8344                        .map_err(|e| GraphError::Corrupt {
8345                            detail: format!("v8 snapshot: edge_props raw bytes: {e:?}"),
8346                        })?;
8347                let prov_raw =
8348                    old_base
8349                        .provenance_raw_bytes()
8350                        .map_err(|e| GraphError::Corrupt {
8351                            detail: format!("v8 snapshot: provenance raw bytes: {e:?}"),
8352                        })?;
8353                encode_v8(
8354                    Some(archived_csr),
8355                    Some(archived_cols),
8356                    Some((archived_edge_props, edge_props_raw)),
8357                    Some(prov_raw),
8358                    &self.topo,
8359                    &self.props,
8360                    &self.ids,
8361                    &self.syms,
8362                    &meta,
8363                    &mut buf,
8364                )?;
8365            }
8366            self.fs.write_atomic(FileId::Snapshot, &buf)?;
8367            // Remap the freshly-written snapshot as the new base.
8368            // C2: use file mmap on RealFs; fall back to from_bytes on SimFs.
8369            let new_base = if let Some(snap_path) = self.fs.snapshot_path() {
8370                core_storage::v8::MappedBase::map(&snap_path)
8371            } else {
8372                core_storage::v8::MappedBase::from_bytes(buf)
8373            }
8374            .map_err(|e| GraphError::Corrupt {
8375                detail: format!("v8 snapshot: remap new base: {e:?}"),
8376            })?;
8377            self.base = Some(Arc::new(new_base));
8378            // Clear the overlay and prop tombstones — all data is now in the new base.
8379            self.topo = Topology::new();
8380            self.props = core_storage::columns::ColumnStore::new();
8381        } else {
8382            // Legacy path (V5–V7 stores without a V8 base).
8383            //
8384            // Memory-diet path: build V8Meta directly from &self — no SnapshotState
8385            // clone and no encode_v8_from_state intermediate clones.  The big
8386            // structures (self.topo, self.props) are borrowed, not cloned.
8387            // self.edge_props is moved (not cloned) because we immediately clear it
8388            // when we remap the new V8 snapshot as self.base (see below).
8389            //
8390            // Eliminates from peak RSS vs. the old SnapshotState path:
8391            //   • self.topo.clone()      (~topology HashMap footprint)
8392            //   • self.props.clone()     (~column-store footprint)
8393            //   • encode_v8_from_state V8Meta secondary clones (labels, edge_props, …)
8394            let meta = V8Meta {
8395                labels: self.labels.clone(),
8396                wal_truncated: !opts.keep_wal,
8397                // Move edge_props out so the large overlay is freed when meta
8398                // drops at end of this block (self.edge_props is now empty; reads
8399                // after base assignment go through the mmap'd base section).
8400                edge_props: std::mem::take(&mut self.edge_props),
8401                rule_defs,
8402                provenance,
8403                rule_tripped,
8404                rule_fires,
8405                ivf_bytes,
8406                view_defs,
8407                hnsw: hnsw_state,
8408                last_change: self.last_change.clone(),
8409            };
8410            let mut buf = Vec::new();
8411            encode_v8(
8412                None,
8413                None,
8414                None,
8415                None,
8416                &self.topo,
8417                &self.props,
8418                &self.ids,
8419                &self.syms,
8420                &meta,
8421                &mut buf,
8422            )?;
8423            // meta (and the moved edge_props inside it) is no longer needed;
8424            // drop it before the write to keep the peak window narrow.
8425            drop(meta);
8426            self.fs.write_atomic(FileId::Snapshot, &buf)?;
8427            // Remap the freshly-written V8 snapshot as self.base.
8428            // On RealFs: drop the encode buffer before mmap to recover ~1.9 GiB.
8429            // On SimFs (tests): pass buf to from_bytes.
8430            let new_base = if let Some(snap_path) = self.fs.snapshot_path() {
8431                drop(buf);
8432                core_storage::v8::MappedBase::map(&snap_path)
8433            } else {
8434                core_storage::v8::MappedBase::from_bytes(buf)
8435            }
8436            .map_err(|e| GraphError::Corrupt {
8437                detail: format!("v8 snapshot: remap new base (legacy path): {e:?}"),
8438            })?;
8439            self.base = Some(Arc::new(new_base));
8440            // Free the large heap-allocated decoded state — all data is now in the
8441            // mmap'd base.  Mirrors the V8 merge-snapshot path (see above).
8442            // self.edge_props was already moved into meta and is effectively empty.
8443            self.topo = Topology::new();
8444            self.props = core_storage::columns::ColumnStore::new();
8445        }
8446
8447        if opts.archive_wal {
8448            // History-preserving snapshot (Task 4):
8449            //   1. Snapshot already written above (write_atomic → fsynced).
8450            //   2. Rename WAL → wal.<commit_seq>.archive  (atomic, same fs).
8451            //      Crash window B: crash here leaves archive present, WAL
8452            //      absent.  Reopen: snapshot loaded (full state), no WAL
8453            //      replay.  Archive is NOT replayed into live state — it is
8454            //      pre-snapshot by construction.  Safe.
8455            //   3. Optionally write genesis marker (first archive only, no
8456            //      prior WAL truncation).
8457            //   4. Prune old archives (retention), update horizon floor.
8458            //      Pruning invalidates the genesis chain; delete marker.
8459            //   5. Write new minimal baseline WAL (write_atomic).
8460            //      Crash window C: crash here leaves new archive plus no live
8461            //      WAL.  Same as window B — handled above.
8462            //
8463            // Sample existing archives BEFORE the rename so we can detect
8464            // whether this is the first archive.
8465            let existing_archives = self.fs.list_archives()?;
8466            let is_first_archive = existing_archives.is_empty();
8467
8468            // Compute a globally-monotonic archive name: the name equals the
8469            // cumulative end-frame index of the archive in global commit space.
8470            //
8471            // Using `commit_seq` directly is UNSOUND across sessions: on reopen
8472            // commit_seq is seeded from max(last_change), which underestimates
8473            // the WAL depth when trailing commits (e.g. insert_edge) do not
8474            // update last_change.  A session-2 archive could then receive a name
8475            // ≤ the session-1 archive, causing incorrect sort order or collision.
8476            //
8477            // Instead: read and decode the live WAL here (before the rename) to
8478            // get its exact frame count, then add it to the last known global
8479            // end-frame index (the name of the most recent existing archive, or
8480            // wal_horizon_floor if no archives exist).  This is O(WAL size) but
8481            // snapshot is already serialising the full graph state, so the cost
8482            // is dominated.
8483            let live_wal_bytes_for_name = self.fs.read(FileId::Wal)?;
8484            let (live_frames_for_name, _) = decode_all(&live_wal_bytes_for_name);
8485            let archive_n = existing_archives
8486                .last()
8487                .copied()
8488                .unwrap_or(self.wal_horizon_floor)
8489                + live_frames_for_name.len() as u64;
8490            self.fs.archive_wal(archive_n)?;
8491
8492            // Genesis marker: written once when the first archive is taken
8493            // from a store that has never undergone a WAL-truncating snapshot.
8494            // When present, `open_at` may replay archive-resident commits from
8495            // empty state (the archive chain covers from global index 0).
8496            //
8497            // Two conditions must ALL hold:
8498            //   1. This is the first archive (existing_archives was empty).
8499            //   2. No snapshot.bin existed before this operation (had_prior_snapshot=false).
8500            //      A WAL-truncating snapshot (keep_wal=false) always writes snapshot.bin
8501            //      before truncating the WAL, so if any prior truncating snapshot was taken
8502            //      — even in a previous session — snapshot.bin is present and this condition
8503            //      is false.  This subsumes the cross-session truncation case without
8504            //      requiring a separate wal.truncated sidecar file.
8505            //      For legacy stores (snapshot.bin written by an older code version that
8506            //      may have truncated the WAL), the same conservative refusal applies:
8507            //      we cannot prove the chain is complete, so we refuse genesis (cost =
8508            //      no as-of-through-archives; never silent wrong data).
8509            //      On SimFs (snapshot_path() == None) had_prior_snapshot is always false,
8510            //      so SimFs always passes this check.
8511            if is_first_archive && !had_prior_snapshot {
8512                self.fs.write_genesis_marker()?;
8513                self.archive_genesis_chain = true;
8514            }
8515
8516            // Retention pruning: keep newest `keep` archives; delete oldest.
8517            // Pruning is the ONLY deletion site for archives.
8518            //
8519            // Crash-safety ordering (C1 fix):
8520            //   1. Count frames in surplus archives (reads only — no mutation).
8521            //   2. Advance and PERSIST the horizon floor FIRST via write-then-
8522            //      rename (atomic).  A crash after this point leaves orphaned
8523            //      archives on disk, but the floor is correct.  The opening
8524            //      cleanup sweep (`cleanup_orphaned_archives`) removes them on
8525            //      the next open, so the store is always safe to reopen.
8526            //   3. Delete the genesis marker (floor > 0 already blocks open_at
8527            //      via the conjunctive gate; marker cleanup is belt-and-suspenders).
8528            //   4. Delete surplus archives.  A crash between any two deletes
8529            //      leaves the floor committed and orphaned archives cleaned at
8530            //      next open — never a stale floor with a missing archive prefix.
8531            if let Some(keep) = self.wal_archive_retention {
8532                if keep > 0 {
8533                    let archives = self.fs.list_archives()?;
8534                    // archives is sorted ascending (oldest first)
8535                    if archives.len() as u32 > keep {
8536                        let surplus = archives.len() - keep as usize;
8537                        // Step 1: count pruned frames (reads, no mutation).
8538                        let mut pruned_frames = 0u64;
8539                        for &n in &archives[..surplus] {
8540                            let bytes = self.fs.read_archive(n)?;
8541                            let (frames, _) = decode_all(&bytes);
8542                            pruned_frames += frames.len() as u64;
8543                        }
8544                        // Step 2: advance and persist floor FIRST.
8545                        self.wal_horizon_floor += pruned_frames;
8546                        self.fs.write_horizon_floor(self.wal_horizon_floor)?;
8547                        // Step 3: delete genesis marker (floor > 0 already
8548                        // blocks open_at; this is belt-and-suspenders cleanup).
8549                        if pruned_frames > 0 && self.archive_genesis_chain {
8550                            self.fs.delete_genesis_marker()?;
8551                            self.archive_genesis_chain = false;
8552                        }
8553                        // Step 4: delete surplus archives.  Crash here →
8554                        // orphaned archives; cleaned at next open.
8555                        for &n in &archives[..surplus] {
8556                            self.fs.delete_archive(n)?;
8557                        }
8558                    }
8559                }
8560            }
8561
8562            // Write new minimal baseline WAL (mirrors the keep_wal=false path).
8563            let mut baseline_wal: Vec<u8> = Vec::new();
8564            for (label, field) in self.fulltext.enabled_pairs() {
8565                let rec = WalRecord::EnableFulltext {
8566                    label: label.clone(),
8567                    field: field.clone(),
8568                };
8569                baseline_wal.extend_from_slice(&encode_record(&rec));
8570            }
8571            self.fs.write_atomic(FileId::Wal, &baseline_wal)?;
8572        } else if opts.keep_wal {
8573            // keep_wal=true: WAL is left untouched.  The existing WAL already
8574            // contains the EnableFulltext records from the original enable calls;
8575            // replay is idempotent (guards in apply() skip already-live entries).
8576            // No baseline re-write is needed or safe here — the full WAL history
8577            // must remain intact for open_at to reach pre-snapshot commits.
8578        } else {
8579            // keep_wal=false (default): truncate by replacing the WAL with a
8580            // minimal baseline of one EnableFulltext record per active pair.
8581            //
8582            // Crash-ordering: write_atomic is atomic.
8583            //   • Crash before snapshot write  → WAL unchanged.  Safe.
8584            //   • Crash after snapshot write but before this WAL write → full
8585            //     pre-snapshot WAL still present; open_with replays idempotently.
8586            //   • Crash after both writes → normal post-snapshot state.
8587            //
8588            // Genesis chain: a WAL-truncating snapshot breaks the archive chain
8589            // for any archives taken AFTER this point (their WAL slices would
8590            // not start at genesis).  Delete any existing genesis marker so that
8591            // open_at refuses archive-resident commits.  Future sessions are
8592            // covered by had_prior_snapshot: snapshot.bin written here persists
8593            // across sessions and prevents a later archiving session from
8594            // incorrectly claiming a complete genesis chain.
8595            if self.archive_genesis_chain {
8596                self.fs.delete_genesis_marker()?;
8597                self.archive_genesis_chain = false;
8598            }
8599            let mut baseline_wal: Vec<u8> = Vec::new();
8600            for (label, field) in self.fulltext.enabled_pairs() {
8601                let rec = WalRecord::EnableFulltext {
8602                    label: label.clone(),
8603                    field: field.clone(),
8604                };
8605                baseline_wal.extend_from_slice(&encode_record(&rec));
8606            }
8607            self.fs.write_atomic(FileId::Wal, &baseline_wal)?;
8608        }
8609        // After snapshot the overlay may have changed (V8 merge path clears
8610        // self.topo and self.props). Refresh the MVCC fold so future readers
8611        // see the post-snapshot state rather than stale overlay data.
8612        self.fold_now();
8613        Ok(())
8614    }
8615}
8616
8617/// Queued mutation for a [`BatchBuilder`] or [`GraphDb::commit_group`].
8618///
8619/// The `submit_batch` / `commit_group` APIs accept `Vec<BatchOp>` so that
8620/// callers can build a set of mutations without holding `&mut GraphDb` and
8621/// hand them off to the group-committing writer for durable, batched I/O.
8622pub enum BatchOp {
8623    InsertNode {
8624        label: String,
8625        key: String,
8626        props: Vec<(String, Value)>,
8627    },
8628    InsertEdge {
8629        edge_type: String,
8630        src_key: String,
8631        dst_key: String,
8632    },
8633    SetProp {
8634        key: String,
8635        field: String,
8636        value: Value,
8637    },
8638    RemoveProp {
8639        key: String,
8640        field: String,
8641    },
8642    DeleteEdge {
8643        edge_type: String,
8644        src_key: String,
8645        dst_key: String,
8646    },
8647    DeleteNode {
8648        key: String,
8649    },
8650    CreateRule(RuleDef),
8651    DeleteRule {
8652        name: String,
8653    },
8654    /// Rename a node's key. Validated: old must exist, new must not.
8655    RenameNode {
8656        old_key: String,
8657        new_key: String,
8658    },
8659    /// Insert an edge, auto-creating any missing endpoint as a plain node with
8660    /// `placeholder_label` and no props. Rules fire and last-change is updated
8661    /// for each created endpoint (normal InsertNode semantics in the batch frame).
8662    InsertEdgeUpsert {
8663        edge_type: String,
8664        src_key: String,
8665        dst_key: String,
8666        placeholder_label: String,
8667    },
8668}
8669
8670/// Three-way node visibility status used by `check_single_op_authz`.
8671enum NodeAuthzStatus {
8672    /// Node exists in the store and is in the role's read mask.
8673    Visible(String), // carries the node's label
8674    /// Node exists in the store but is NOT in the role's read mask.
8675    Hidden,
8676    /// Node does not exist in the store.
8677    Absent,
8678}
8679
8680/// Overlay of ops already accepted earlier in the same batch. Never written
8681/// back to the database — validation only.
8682#[derive(Default)]
8683struct Overlay {
8684    extra_keys: BTreeSet<String>,
8685    deleted_keys: BTreeSet<String>,
8686    extra_props: BTreeMap<(String, String), Value>,
8687    removed_props: BTreeSet<(String, String)>,
8688    extra_edges: BTreeSet<(String, String, String)>,
8689    deleted_edges: BTreeSet<(String, String, String)>,
8690    extra_rules: BTreeSet<String>,
8691    deleted_rules: BTreeSet<String>,
8692}
8693
8694/// Read-only view of live db state plus a batch overlay. Shared by single-op
8695/// public methods (empty overlay) and `commit_batch`.
8696struct MutPreview<'a, F: Fs> {
8697    db: &'a GraphDb<F>,
8698    overlay: Overlay,
8699}
8700
8701impl<'a, F: Fs> MutPreview<'a, F> {
8702    fn new(db: &'a GraphDb<F>) -> Self {
8703        Self {
8704            db,
8705            overlay: Overlay::default(),
8706        }
8707    }
8708
8709    fn has_key(&self, key: &str) -> bool {
8710        if self.overlay.extra_keys.contains(key) {
8711            return true;
8712        }
8713        if self.overlay.deleted_keys.contains(key) {
8714            return false;
8715        }
8716        self.db.ids.get(key).is_some()
8717    }
8718
8719    fn has_prop(&self, key: &str, field: &str) -> bool {
8720        if !self.has_key(key) {
8721            return false;
8722        }
8723        let k = (key.to_string(), field.to_string());
8724        if self.overlay.removed_props.contains(&k) {
8725            return false;
8726        }
8727        if self.overlay.extra_props.contains_key(&k) {
8728            return true;
8729        }
8730        // Fresh identity (first insert in this batch, or delete+reinsert):
8731        // ignore props still sitting on the soon-to-be-tombstoned slot.
8732        if self.overlay.extra_keys.contains(key) {
8733            return false;
8734        }
8735        self.db.get_prop(key, field).is_some()
8736    }
8737
8738    fn has_edge(&self, edge_type: &str, src_key: &str, dst_key: &str) -> bool {
8739        let k = (
8740            edge_type.to_string(),
8741            src_key.to_string(),
8742            dst_key.to_string(),
8743        );
8744        if self.overlay.deleted_edges.contains(&k) {
8745            return false;
8746        }
8747        if self.overlay.extra_edges.contains(&k) {
8748            return true;
8749        }
8750        // A key created in this batch (including reinsert) has no db edges.
8751        if self.overlay.extra_keys.contains(src_key) || self.overlay.extra_keys.contains(dst_key) {
8752            return false;
8753        }
8754        if self.overlay.deleted_keys.contains(src_key)
8755            || self.overlay.deleted_keys.contains(dst_key)
8756        {
8757            return false;
8758        }
8759        let Some(src) = self.db.ids.get(src_key) else {
8760            return false;
8761        };
8762        let Some(dst) = self.db.ids.get(dst_key) else {
8763            return false;
8764        };
8765        let Some(sym) = self.db.syms.get(edge_type) else {
8766            return false;
8767        };
8768        self.db
8769            .topo_view()
8770            .neighbors(sym, Direction::Out, src)
8771            .binary_search(&dst)
8772            .is_ok()
8773    }
8774
8775    fn has_rule(&self, name: &str) -> bool {
8776        if self.overlay.extra_rules.contains(name) {
8777            return true;
8778        }
8779        if self.overlay.deleted_rules.contains(name) {
8780            return false;
8781        }
8782        self.db.engine.rules().any(|r| r.name == name)
8783    }
8784
8785    fn is_rule_owned(&self, edge_type: &str, src_key: &str, dst_key: &str) -> bool {
8786        if self.overlay.extra_keys.contains(src_key) || self.overlay.extra_keys.contains(dst_key) {
8787            return false;
8788        }
8789        if self.overlay.deleted_keys.contains(src_key)
8790            || self.overlay.deleted_keys.contains(dst_key)
8791        {
8792            return false;
8793        }
8794        let Some(src) = self.db.ids.get(src_key) else {
8795            return false;
8796        };
8797        let Some(dst) = self.db.ids.get(dst_key) else {
8798            return false;
8799        };
8800        let Some(et) = self.db.syms.get(edge_type) else {
8801            return false;
8802        };
8803        // extra_rules is deliberately not consulted: a CreateRule earlier in
8804        // this batch has not fired, so it contributes no provenance. That is
8805        // the documented rule-window gap (see GraphDb::batch).
8806        if self.overlay.deleted_rules.is_empty() {
8807            return self.db.engine.is_owned(et, src, dst);
8808        }
8809        for (rule, triples) in self.db.engine.provenance() {
8810            if self.overlay.deleted_rules.contains(rule) {
8811                continue;
8812            }
8813            if triples.contains(&(et, src, dst)) {
8814                return true;
8815            }
8816        }
8817        false
8818    }
8819
8820    fn check_insert_node(&self, key: &str) -> Result<()> {
8821        if self.has_key(key) {
8822            Err(GraphError::DuplicateKey { key: key.into() })
8823        } else {
8824            Ok(())
8825        }
8826    }
8827
8828    fn check_live_key(&self, key: &str) -> Result<()> {
8829        if self.has_key(key) {
8830            Ok(())
8831        } else {
8832            Err(GraphError::KeyNotFound { key: key.into() })
8833        }
8834    }
8835
8836    fn prepare_insert_edge(&self, edge_type: &str, src_key: &str, dst_key: &str) -> Result<bool> {
8837        for k in [src_key, dst_key] {
8838            if !self.has_key(k) {
8839                return Err(GraphError::KeyNotFound { key: k.into() });
8840            }
8841        }
8842        if self.is_rule_owned(edge_type, src_key, dst_key) {
8843            return Err(GraphError::RuleOwned {
8844                detail: format!("edge {edge_type} {src_key}→{dst_key} is rule-owned"),
8845            });
8846        }
8847        Ok(!self.has_edge(edge_type, src_key, dst_key))
8848    }
8849
8850    fn prepare_remove_prop(&self, key: &str, field: &str) -> Result<bool> {
8851        self.check_live_key(key)?;
8852        Ok(self.has_prop(key, field))
8853    }
8854
8855    fn prepare_delete_edge(&self, edge_type: &str, src_key: &str, dst_key: &str) -> Result<bool> {
8856        for k in [src_key, dst_key] {
8857            if !self.has_key(k) {
8858                return Err(GraphError::KeyNotFound { key: k.into() });
8859            }
8860        }
8861        // Provenance-owned OR a live rule would derive this pair. User-first
8862        // edges that a later rule matches are not in `owned`, but deleting
8863        // them would leave a hole `rebuild_rule` immediately fills.
8864        if self.is_rule_owned(edge_type, src_key, dst_key) {
8865            return Err(GraphError::RuleOwned {
8866                detail: format!(
8867                    "edge {edge_type} {src_key}→{dst_key} is rule-owned; \
8868                     delete or change the owning rule"
8869                ),
8870            });
8871        }
8872        if self.would_derive(edge_type, src_key, dst_key) {
8873            return Err(GraphError::RuleOwned {
8874                detail: format!(
8875                    "edge {edge_type} {src_key}→{dst_key} is rule-owned; \
8876                     delete or change the owning rule, or a live rule would re-derive it"
8877                ),
8878            });
8879        }
8880        Ok(self.has_edge(edge_type, src_key, dst_key))
8881    }
8882
8883    /// True if any live rule (minus overlay-deleted names) would derive
8884    /// `(edge_type, src, dst)` from current overlay-visible props/labels.
8885    /// CreateRule names in `extra_rules` are ignored — same documented
8886    /// same-batch rule-window as [`Self::is_rule_owned`].
8887    fn would_derive(&self, edge_type: &str, src_key: &str, dst_key: &str) -> bool {
8888        if src_key == dst_key {
8889            return false;
8890        }
8891        let Some(src_label) = self.label_of(src_key) else {
8892            return false;
8893        };
8894        let Some(dst_label) = self.label_of(dst_key) else {
8895            return false;
8896        };
8897        for rule in self.db.engine.rules() {
8898            if self.overlay.deleted_rules.contains(&rule.name) {
8899                continue;
8900            }
8901            if rule.edge_type != edge_type {
8902                continue;
8903            }
8904            if rule.src_label != src_label || rule.dst_label != dst_label {
8905                continue;
8906            }
8907            let src_props = |f: &str| self.prop_value(src_key, f);
8908            let dst_props = |f: &str| self.prop_value(dst_key, f);
8909            let src_view = NodeView {
8910                key: src_key,
8911                props: &src_props,
8912            };
8913            let dst_view = NodeView {
8914                key: dst_key,
8915                props: &dst_props,
8916            };
8917            if evaluate(&rule.predicate, &src_view, &dst_view).is_some() {
8918                return true;
8919            }
8920        }
8921        false
8922    }
8923
8924    fn label_of(&self, key: &str) -> Option<String> {
8925        if self.overlay.deleted_keys.contains(key) {
8926            return None;
8927        }
8928        // Fresh identities created in this batch have no stored label in the
8929        // overlay; they cannot be provenance-owned yet either.
8930        let id = self.db.ids.get(key)?;
8931        let sym = self.db.labels.get(id as usize).copied()?;
8932        if sym == u32::MAX {
8933            return None;
8934        }
8935        self.db.syms.resolve(sym).map(str::to_string)
8936    }
8937
8938    fn prop_value(&self, key: &str, field: &str) -> Option<Value> {
8939        if !self.has_key(key) {
8940            return None;
8941        }
8942        let k = (key.to_string(), field.to_string());
8943        if self.overlay.removed_props.contains(&k) {
8944            return None;
8945        }
8946        if let Some(v) = self.overlay.extra_props.get(&k) {
8947            return Some(v.clone());
8948        }
8949        if self.overlay.extra_keys.contains(key) {
8950            return None;
8951        }
8952        self.db.get_prop(key, field)
8953    }
8954
8955    fn check_create_rule(&self, def: &RuleDef) -> Result<()> {
8956        def.validate()
8957            .map_err(|e| GraphError::RuleInvalid { detail: e })?;
8958        if self.has_rule(&def.name) {
8959            return Err(GraphError::RuleInvalid {
8960                detail: format!("rule {:?} already exists", def.name),
8961            });
8962        }
8963        Ok(())
8964    }
8965
8966    fn check_delete_rule(&self, name: &str) -> Result<()> {
8967        if self.has_rule(name) {
8968            Ok(())
8969        } else {
8970            Err(GraphError::RuleNotFound { name: name.into() })
8971        }
8972    }
8973
8974    fn note_insert_node(&mut self, key: &str, props: &[(String, Value)]) {
8975        self.overlay.deleted_keys.remove(key);
8976        self.overlay.extra_keys.insert(key.to_string());
8977        self.overlay.extra_props.retain(|(k, _), _| k != key);
8978        self.overlay.removed_props.retain(|(k, _)| k != key);
8979        for (field, value) in props {
8980            self.overlay
8981                .extra_props
8982                .insert((key.to_string(), field.clone()), value.clone());
8983        }
8984    }
8985
8986    fn note_insert_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) {
8987        let k = (
8988            edge_type.to_string(),
8989            src_key.to_string(),
8990            dst_key.to_string(),
8991        );
8992        self.overlay.deleted_edges.remove(&k);
8993        self.overlay.extra_edges.insert(k);
8994    }
8995
8996    fn note_set_prop(&mut self, key: &str, field: &str, value: &Value) {
8997        let k = (key.to_string(), field.to_string());
8998        self.overlay.removed_props.remove(&k);
8999        self.overlay.extra_props.insert(k, value.clone());
9000    }
9001
9002    fn note_remove_prop(&mut self, key: &str, field: &str) {
9003        let k = (key.to_string(), field.to_string());
9004        self.overlay.extra_props.remove(&k);
9005        self.overlay.removed_props.insert(k);
9006    }
9007
9008    fn note_delete_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) {
9009        let k = (
9010            edge_type.to_string(),
9011            src_key.to_string(),
9012            dst_key.to_string(),
9013        );
9014        self.overlay.extra_edges.remove(&k);
9015        self.overlay.deleted_edges.insert(k);
9016    }
9017
9018    fn note_delete_node(&mut self, key: &str) {
9019        self.overlay.extra_keys.remove(key);
9020        self.overlay.deleted_keys.insert(key.to_string());
9021        self.overlay.extra_props.retain(|(k, _), _| k != key);
9022        self.overlay.removed_props.retain(|(k, _)| k != key);
9023        self.overlay
9024            .extra_edges
9025            .retain(|(_, s, d)| s != key && d != key);
9026        self.overlay
9027            .deleted_edges
9028            .retain(|(_, s, d)| s != key && d != key);
9029    }
9030
9031    fn note_create_rule(&mut self, name: &str) {
9032        self.overlay.deleted_rules.remove(name);
9033        self.overlay.extra_rules.insert(name.to_string());
9034    }
9035
9036    fn check_rename_node(&self, old: &str, new: &str) -> Result<()> {
9037        if !self.has_key(old) {
9038            return Err(GraphError::KeyNotFound { key: old.into() });
9039        }
9040        if self.has_key(new) {
9041            return Err(GraphError::DuplicateKey { key: new.into() });
9042        }
9043        Ok(())
9044    }
9045
9046    fn note_rename_node(&mut self, old: &str, new: &str) {
9047        // Mark old as deleted so subsequent batch ops cannot reference it.
9048        self.overlay.extra_keys.remove(old);
9049        self.overlay.deleted_keys.insert(old.to_string());
9050        // Mark new as extra so subsequent batch ops can reference it.
9051        self.overlay.deleted_keys.remove(new);
9052        self.overlay.extra_keys.insert(new.to_string());
9053        // Migrate any overlay props from old key to new key.
9054        let new_str = new.to_string();
9055        let transferred: Vec<((String, String), Value)> = self
9056            .overlay
9057            .extra_props
9058            .iter()
9059            .filter(|((k, _), _)| k.as_str() == old)
9060            .map(|((_, f), v)| ((new_str.clone(), f.clone()), v.clone()))
9061            .collect();
9062        self.overlay
9063            .extra_props
9064            .retain(|(k, _), _| k.as_str() != old);
9065        for (k, v) in transferred {
9066            self.overlay.extra_props.insert(k, v);
9067        }
9068        // Migrate removed_props.
9069        let transferred_removed: Vec<(String, String)> = self
9070            .overlay
9071            .removed_props
9072            .iter()
9073            .filter(|(k, _)| k.as_str() == old)
9074            .map(|(_, f)| (new_str.clone(), f.clone()))
9075            .collect();
9076        self.overlay
9077            .removed_props
9078            .retain(|(k, _)| k.as_str() != old);
9079        for k in transferred_removed {
9080            self.overlay.removed_props.insert(k);
9081        }
9082    }
9083
9084    fn note_delete_rule(&mut self, name: &str) {
9085        self.overlay.extra_rules.remove(name);
9086        self.overlay.deleted_rules.insert(name.to_string());
9087        // Treat the deleted rule's current provenance as gone so a later
9088        // delete_edge of those triples is a no-op (matches sequential).
9089        if let Some(triples) = self.db.engine.provenance().get(name) {
9090            for &(et, s, d) in triples {
9091                let Some(etype) = self.db.syms.resolve(et) else {
9092                    continue;
9093                };
9094                let Some(src) = self.db.ids.key_of(s) else {
9095                    continue;
9096                };
9097                let Some(dst) = self.db.ids.key_of(d) else {
9098                    continue;
9099                };
9100                let k = (etype.to_string(), src.to_string(), dst.to_string());
9101                self.overlay.extra_edges.remove(&k);
9102                self.overlay.deleted_edges.insert(k);
9103            }
9104        }
9105    }
9106}
9107
9108/// Collects mutations and commits them as one WAL `Batch` frame.
9109///
9110/// Holds `&mut GraphDb` for its lifetime. Queue with the same method names
9111/// as [`GraphDb`]; call [`commit`](Self::commit) to validate, log, and apply.
9112/// See [`GraphDb::batch`] for validation and atomicity rules.
9113pub struct BatchBuilder<'a, F: Fs> {
9114    db: &'a mut GraphDb<F>,
9115    ops: Vec<BatchOp>,
9116}
9117
9118impl<'a, F: Fs> BatchBuilder<'a, F> {
9119    pub fn insert_node(
9120        &mut self,
9121        label: &str,
9122        key: &str,
9123        props: Vec<(String, Value)>,
9124    ) -> &mut Self {
9125        self.ops.push(BatchOp::InsertNode {
9126            label: label.into(),
9127            key: key.into(),
9128            props,
9129        });
9130        self
9131    }
9132
9133    pub fn insert_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) -> &mut Self {
9134        self.ops.push(BatchOp::InsertEdge {
9135            edge_type: edge_type.into(),
9136            src_key: src_key.into(),
9137            dst_key: dst_key.into(),
9138        });
9139        self
9140    }
9141
9142    pub fn set_prop(&mut self, key: &str, field: &str, value: Value) -> &mut Self {
9143        self.ops.push(BatchOp::SetProp {
9144            key: key.into(),
9145            field: field.into(),
9146            value,
9147        });
9148        self
9149    }
9150
9151    pub fn remove_prop(&mut self, key: &str, field: &str) -> &mut Self {
9152        self.ops.push(BatchOp::RemoveProp {
9153            key: key.into(),
9154            field: field.into(),
9155        });
9156        self
9157    }
9158
9159    pub fn delete_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) -> &mut Self {
9160        self.ops.push(BatchOp::DeleteEdge {
9161            edge_type: edge_type.into(),
9162            src_key: src_key.into(),
9163            dst_key: dst_key.into(),
9164        });
9165        self
9166    }
9167
9168    pub fn delete_node(&mut self, key: &str) -> &mut Self {
9169        self.ops.push(BatchOp::DeleteNode { key: key.into() });
9170        self
9171    }
9172
9173    pub fn create_rule(&mut self, def: RuleDef) -> &mut Self {
9174        self.ops.push(BatchOp::CreateRule(def));
9175        self
9176    }
9177
9178    pub fn delete_rule(&mut self, name: &str) -> &mut Self {
9179        self.ops.push(BatchOp::DeleteRule { name: name.into() });
9180        self
9181    }
9182
9183    /// Queue a node-rename in this batch.
9184    ///
9185    /// Validation (old exists, new not taken) runs at commit time.
9186    pub fn rename_node(&mut self, old_key: &str, new_key: &str) -> &mut Self {
9187        self.ops.push(BatchOp::RenameNode {
9188            old_key: old_key.into(),
9189            new_key: new_key.into(),
9190        });
9191        self
9192    }
9193
9194    /// Queue an edge insert with endpoint auto-creation.
9195    ///
9196    /// Any missing endpoint is created as a plain node `{key, label:
9197    /// placeholder_label, no props}` inside this batch frame. Rules fire and
9198    /// last-change is updated for each auto-created node.
9199    pub fn insert_edge_upsert(
9200        &mut self,
9201        edge_type: &str,
9202        src_key: &str,
9203        dst_key: &str,
9204        placeholder_label: &str,
9205    ) -> &mut Self {
9206        self.ops.push(BatchOp::InsertEdgeUpsert {
9207            edge_type: edge_type.into(),
9208            src_key: src_key.into(),
9209            dst_key: dst_key.into(),
9210            placeholder_label: placeholder_label.into(),
9211        });
9212        self
9213    }
9214
9215    /// Validate every queued op, then log one `Batch` frame and apply.
9216    /// Empty / all-noop batches return `Ok(())` without writing the WAL.
9217    /// A second `commit()` after a successful one is an empty-batch no-op
9218    /// (queued ops were taken).
9219    /// Takes `&mut self` so it chains after the queue methods (`b.insert_node(..).commit()`)
9220    /// and also works as `let mut b = db.batch(); b.insert_node(..); b.commit()`.
9221    ///
9222    /// **Rule-window limitation:** batch validation cannot see edges that a
9223    /// rule created earlier in the *same* batch will derive at apply time, so
9224    /// a `delete_edge` / `insert_edge` in that window is silently no-oped
9225    /// where sequential calls would return `Err(RuleOwned)`. State integrity
9226    /// is unaffected (idempotent apply, provenance intact). Create rules in
9227    /// their own batch, or sequentially, when later ops may touch derived
9228    /// edges.
9229    /// Validate every queued op and commit atomically.
9230    ///
9231    /// Returns `(nodes_inserted, edges_inserted)` — the counts of node and edge
9232    /// WAL records actually written (duplicate edges are silent no-ops and are
9233    /// NOT counted). Both are 0 when the batch is empty or all-noop.
9234    pub fn commit(&mut self) -> Result<(usize, usize)> {
9235        let ops = std::mem::take(&mut self.ops);
9236        self.db.commit_batch(ops)
9237    }
9238
9239    /// Same as [`commit`](Self::commit) but tail the inner events with
9240    /// [`MutationEvent::Ingested`] instead of [`MutationEvent::BatchApplied`].
9241    pub(crate) fn commit_ingest(&mut self, label: &str, inserted: usize) -> Result<(usize, usize)> {
9242        let ops = std::mem::take(&mut self.ops);
9243        self.db
9244            .commit_logged_batch(ops, Some((label.to_string(), inserted)), None)
9245    }
9246}
9247
9248pub struct NodeRef<'a, F: Fs> {
9249    db: &'a GraphDb<F>,
9250    id: u32,
9251}
9252
9253impl<'a, F: Fs> NodeRef<'a, F> {
9254    pub fn key(&self) -> &str {
9255        self.db.ids.key_of(self.id).expect("dense ids")
9256    }
9257
9258    pub fn label(&self) -> &str {
9259        let sym = self
9260            .db
9261            .labels
9262            .get(self.id as usize)
9263            .copied()
9264            .filter(|&s| s != u32::MAX)
9265            .expect("real nodes always have a label; u32::MAX sentinel cannot occur");
9266        self.db.syms.resolve(sym).expect("interned label symbol")
9267    }
9268
9269    pub fn prop(&self, field: &str) -> Option<Value> {
9270        self.db
9271            .props_view()
9272            .get(self.id, field)
9273            .map(|vr| vr.into_value())
9274    }
9275
9276    /// All stored fields for this node, sorted by field name.
9277    ///
9278    /// Reads from the full base+overlay view so that props stored only in the
9279    /// V8 snapshot base (i.e. before any post-snapshot WAL writes) are visible.
9280    pub fn props(&self) -> BTreeMap<String, Value> {
9281        let mut out = BTreeMap::new();
9282        let pv = self.db.props_view();
9283        for field in pv.field_names() {
9284            if let Some(vr) = pv.get(self.id, &field) {
9285                out.insert(field, vr.into_value());
9286            }
9287        }
9288        out
9289    }
9290
9291    /// depth-N BFS as a ResultSet: columns ["key","label","depth"], BFS order.
9292    pub fn neighborhood(&self, depth: u32, edge_types: Option<&[&str]>, dir: Dir) -> ResultSet {
9293        let view = self.db.view();
9294        let resolved: Option<Vec<u32>> = edge_types.map(|names| {
9295            names
9296                .iter()
9297                .filter_map(|name| view.syms.get(name))
9298                .collect()
9299        });
9300        let nb = neighborhood(&view, self.id, depth, resolved.as_deref(), dir);
9301        let mut rs = ResultSet::new(vec!["key".into(), "label".into(), "depth".into()]);
9302        for (nid, d) in nb.nodes {
9303            let key = view.key_of(nid);
9304            let label = view
9305                .label_of(nid)
9306                .expect("real nodes always have a label; u32::MAX sentinel cannot occur");
9307            rs.push_row(vec![
9308                Some(Value::Str(key.to_string())),
9309                Some(Value::Str(label.to_string())),
9310                Some(Value::Int(d as i64)),
9311            ]);
9312        }
9313        rs
9314    }
9315
9316    /// 1-hop, Both directions: edge-type name → sorted unique neighbor keys.
9317    pub fn grouped_by_edge_type(&self) -> BTreeMap<String, Vec<String>> {
9318        let view = self.db.view();
9319        let mut groups: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
9320        for e in expand(&view, self.id, None, Dir::Both) {
9321            // Skip edges with unknown etypes (only possible from corrupt large
9322            // TOPOLOGY section; function returns BTreeMap not Result).
9323            let Some(etype) = view.syms.resolve(e.etype) else {
9324                continue;
9325            };
9326            let etype = etype.to_string();
9327            let nbr = if e.src == self.id { e.dst } else { e.src };
9328            groups
9329                .entry(etype)
9330                .or_default()
9331                .insert(view.key_of(nbr).to_string());
9332        }
9333        groups
9334            .into_iter()
9335            .map(|(k, v)| (k, v.into_iter().collect()))
9336            .collect()
9337    }
9338}
9339
9340#[cfg(test)]
9341mod tests {
9342    use super::*;
9343    use core_rules::Predicate;
9344
9345    fn tmp_dir(name: &str) -> std::path::PathBuf {
9346        let d =
9347            std::env::temp_dir().join(format!("graphdb-db-unit-{}-{}", name, std::process::id()));
9348        let _ = std::fs::remove_dir_all(&d);
9349        d
9350    }
9351
9352    fn fk_rule() -> RuleDef {
9353        RuleDef {
9354            name: "works_at".into(),
9355            src_label: "Person".into(),
9356            dst_label: "Org".into(),
9357            predicate: Predicate::KeyMatch {
9358                field: "org_id".into(),
9359            },
9360            edge_type: "WORKS_AT".into(),
9361            weight_prop: None,
9362            max_edges: None,
9363            approximate: false,
9364            via_label: None,
9365            via_edge: None,
9366            via_dir: None,
9367        }
9368    }
9369
9370    /// Regression guard for the no-views delta-copy fast path.
9371    ///
9372    /// When no views are defined, `pending_deltas_since().to_vec()` must never
9373    /// be called — even during a large CreateRule backfill. The DELTA_COPY_COUNT
9374    /// thread-local is incremented inside every `if !view_store.is_empty()` block;
9375    /// a count of 0 after the entire sequence proves the guard fires correctly.
9376    #[test]
9377    fn no_delta_copy_when_no_views() {
9378        DELTA_COPY_COUNT.with(|c| c.set(0));
9379        let dir = tmp_dir("no-delta-copy");
9380        {
9381            let mut db = GraphDb::open(&dir).unwrap();
9382            // Insert 50 Org + 50 Person nodes with FK links.
9383            for i in 0..50u32 {
9384                db.insert_node("Org", &format!("o{i}"), vec![]).unwrap();
9385            }
9386            for i in 0..50u32 {
9387                db.insert_node(
9388                    "Person",
9389                    &format!("p{i}"),
9390                    vec![("org_id".into(), Value::Str(format!("o{i}")))],
9391                )
9392                .unwrap();
9393            }
9394            // CreateRule backfill should NOT invoke to_vec() when no views are defined.
9395            db.create_rule(fk_rule()).unwrap();
9396
9397            // Counter must stay 0 — no views, no copies.
9398            let copies = DELTA_COPY_COUNT.with(|c| c.get());
9399            assert_eq!(
9400                copies, 0,
9401                "pending_deltas_since().to_vec() called despite no views"
9402            );
9403
9404            // Derived edges must still be correct (the guard skips only the
9405            // empty delta propagation loop, not the rule application itself).
9406            let nbrs = db.neighbors("p0", "WORKS_AT", Direction::Out).unwrap();
9407            assert_eq!(
9408                nbrs,
9409                vec!["o0"],
9410                "rule must derive edges even with no views"
9411            );
9412        }
9413        let _ = std::fs::remove_dir_all(&dir);
9414    }
9415
9416    /// Gating regression: subscribe AFTER a backfill must see no stale events.
9417    /// subscribe BEFORE a backfill must see every edge-fire event.
9418    #[test]
9419    fn subscribe_after_backfill_no_stale_events() {
9420        let dir = tmp_dir("sub-after-backfill");
9421        {
9422            let mut db = GraphDb::open(&dir).unwrap();
9423            for i in 0..10u32 {
9424                db.insert_node("Org", &format!("o{i}"), vec![]).unwrap();
9425                db.insert_node(
9426                    "Person",
9427                    &format!("p{i}"),
9428                    vec![("org_id".into(), Value::Str(format!("o{i}")))],
9429                )
9430                .unwrap();
9431            }
9432            // Create rule BEFORE subscribing — emit_deltas is false during backfill.
9433            db.create_rule(fk_rule()).unwrap();
9434
9435            // Subscribe AFTER the backfill — queue must be empty (no stale events).
9436            let sub = db.subscribe_all_rules().unwrap();
9437            // No events should have queued for the prior backfill.
9438            assert!(
9439                sub.try_recv().is_none(),
9440                "subscribe after backfill must see no stale events"
9441            );
9442
9443            // Inserting a new node now should fire an event (emit_deltas is now true).
9444            db.insert_node("Org", "o_new", vec![]).unwrap();
9445            db.insert_node(
9446                "Person",
9447                "p_new",
9448                vec![("org_id".into(), Value::Str("o_new".into()))],
9449            )
9450            .unwrap();
9451            let ev = sub.recv_timeout(std::time::Duration::from_millis(200));
9452            assert!(
9453                ev.is_some(),
9454                "edge-fire event must arrive after subscribe (emit_deltas=true)"
9455            );
9456        }
9457        let _ = std::fs::remove_dir_all(&dir);
9458    }
9459
9460    /// Gating regression: subscribe BEFORE a backfill → events flow.
9461    #[test]
9462    fn subscribe_before_backfill_events_flow() {
9463        let dir = tmp_dir("sub-before-backfill");
9464        {
9465            let mut db = GraphDb::open(&dir).unwrap();
9466            // Subscribe FIRST — emit_deltas becomes true.
9467            let sub = db.subscribe_all_rules().unwrap();
9468
9469            for i in 0..5u32 {
9470                db.insert_node("Org", &format!("o{i}"), vec![]).unwrap();
9471                db.insert_node(
9472                    "Person",
9473                    &format!("p{i}"),
9474                    vec![("org_id".into(), Value::Str(format!("o{i}")))],
9475                )
9476                .unwrap();
9477            }
9478            // Backfill fires with emit_deltas=true → events queued.
9479            db.create_rule(fk_rule()).unwrap();
9480
9481            // Should receive at least one edge-fired event from the backfill.
9482            let mut received = 0usize;
9483            while sub.try_recv().is_some() {
9484                received += 1;
9485            }
9486            assert!(
9487                received > 0,
9488                "subscribe before backfill must receive edge-fire events (got 0)"
9489            );
9490        }
9491        let _ = std::fs::remove_dir_all(&dir);
9492    }
9493
9494    /// Companion: when a view IS defined, the delta path fires and view values update.
9495    #[test]
9496    fn delta_copy_fires_when_view_exists() {
9497        use core_rules::ViewSource;
9498        DELTA_COPY_COUNT.with(|c| c.set(0));
9499        let dir = tmp_dir("delta-copy-with-view");
9500        {
9501            let mut db = GraphDb::open(&dir).unwrap();
9502            db.insert_node("Org", "o1", vec![]).unwrap();
9503            db.insert_node(
9504                "Person",
9505                "p1",
9506                vec![("org_id".into(), Value::Str("o1".into()))],
9507            )
9508            .unwrap();
9509            // Declare a Degree view so is_empty() returns false.
9510            db.create_view(ViewDef {
9511                name: "degree_out".into(),
9512                label: "Person".into(),
9513                view_prop: "degree_out".into(),
9514                source: ViewSource::Degree {
9515                    edge_type: "WORKS_AT".into(),
9516                    direction: Direction::Out,
9517                },
9518            })
9519            .unwrap();
9520            db.create_rule(fk_rule()).unwrap();
9521
9522            // At least one delta copy should have happened (CreateRule backfill).
9523            let copies = DELTA_COPY_COUNT.with(|c| c.get());
9524            assert!(
9525                copies > 0,
9526                "expected delta copy to fire when a view is defined"
9527            );
9528
9529            // View value should be computed: p1 has one WORKS_AT out-edge.
9530            let info = db.node_info("p1").unwrap();
9531            let degree = info.props.get("degree_out");
9532            assert!(
9533                degree.is_some(),
9534                "view prop should be written to node props"
9535            );
9536        }
9537        let _ = std::fs::remove_dir_all(&dir);
9538    }
9539
9540    /// Regression: `open_at_with` must call `rebuild_all` after WAL replay so
9541    /// derived-edge-driven view values reflect the as-of state rather than just
9542    /// the initial backfill written at `CreateView` time.
9543    ///
9544    /// Base WAL frames (indices 0..=5 before history markers):
9545    ///   0: insert Org "o1"
9546    ///   1: create_view "employee_count" (Degree / WORKS_AT / In) on Org
9547    ///   2: create_rule fk_rule (WORKS_AT, Person→Org via org_id)
9548    ///   3: insert Person "p1" → rule fires WORKS_AT p1→o1 (degree = 1)  ← mid
9549    ///   4: insert Person "p2" → rule fires WORKS_AT p2→o1 (degree = 2)
9550    ///   5: insert Person "p3" → rule fires WORKS_AT p3→o1 (degree = 3)  ← latest
9551    ///
9552    /// Each rule-fire also appends a DerivedEdgeAdded history-marker frame (state
9553    /// no-op), so the total commit count is higher than the base frame count.
9554    /// The "latest" open_at commit is computed dynamically via `wal_commit_count_at`.
9555    ///
9556    /// Without `rebuild_all`, the as-of instance's "emp" view stays at the
9557    /// initial backfill value (0) instead of reflecting the replayed derived edges.
9558    #[test]
9559    fn open_at_derived_edge_view_values_correct() {
9560        use core_rules::ViewSource;
9561        let dir = tmp_dir("open-at-view-rebuild");
9562        {
9563            let mut db = GraphDb::open(&dir).unwrap();
9564            // frame 0
9565            db.insert_node("Org", "o1", vec![]).unwrap();
9566            // frame 1: create view — initial backfill sees 0 derived edges (none fired yet)
9567            db.create_view(ViewDef {
9568                name: "employee_count".into(),
9569                label: "Org".into(),
9570                view_prop: "emp".into(),
9571                source: ViewSource::Degree {
9572                    edge_type: "WORKS_AT".into(),
9573                    direction: Direction::In,
9574                },
9575            })
9576            .unwrap();
9577            // frame 2: create rule — no Persons yet; backfill is a no-op
9578            db.create_rule(fk_rule()).unwrap();
9579            // frame 3: p1 — rule fires WORKS_AT p1→o1; degree = 1
9580            db.insert_node(
9581                "Person",
9582                "p1",
9583                vec![("org_id".into(), Value::Str("o1".into()))],
9584            )
9585            .unwrap();
9586            // frame 4: p2 — degree = 2
9587            db.insert_node(
9588                "Person",
9589                "p2",
9590                vec![("org_id".into(), Value::Str("o1".into()))],
9591            )
9592            .unwrap();
9593            // frame 5: p3 — degree = 3
9594            db.insert_node(
9595                "Person",
9596                "p3",
9597                vec![("org_id".into(), Value::Str("o1".into()))],
9598            )
9599            .unwrap();
9600            // Sanity: normal open sees degree = 3.
9601            assert_eq!(
9602                db.get_view_prop("o1", "emp"),
9603                Some(Value::Int(3)),
9604                "normal db must show degree 3 after 3 derived edges"
9605            );
9606        } // WAL flushed
9607
9608        // Re-open normally to get the authoritative reference value.
9609        let normal_db = GraphDb::open(&dir).unwrap();
9610        let normal_emp = normal_db.get_view_prop("o1", "emp");
9611        assert_eq!(
9612            normal_emp,
9613            Some(Value::Int(3)),
9614            "re-opened normal db must show degree 3"
9615        );
9616
9617        // Latest as-of (last WAL commit): must match the normal open.
9618        // History-marker frames are appended after each rule-fire, so the total
9619        // commit count is computed dynamically rather than hardcoded.
9620        let total = crate::wal_commit_count_at(&dir).unwrap();
9621        let aof_latest = GraphDb::open_at(&dir, total - 1).unwrap();
9622        assert_eq!(
9623            aof_latest.get_view_prop("o1", "emp"),
9624            normal_emp,
9625            "open_at latest: derived-edge view must equal normal open (rebuild_all required)"
9626        );
9627
9628        // Mid-history as-of (commit 3 = p1 insert Batch frame): only p1; degree = 1.
9629        // The DerivedEdgeAdded marker for p1 is at frame 4 (state no-op on replay),
9630        // so replaying 0..=3 correctly re-derives only the p1→o1 edge.
9631        let aof_mid = GraphDb::open_at(&dir, 3).unwrap();
9632        assert_eq!(
9633            aof_mid.get_view_prop("o1", "emp"),
9634            Some(Value::Int(1)),
9635            "open_at mid-history: only p1 exists at frame 3, degree must be 1"
9636        );
9637
9638        let _ = std::fs::remove_dir_all(&dir);
9639    }
9640
9641    /// Pin: subscribe_* on an as-of instance must return Err(ReadOnly) —
9642    /// as-of instances never commit, so distribute_events never runs and any
9643    /// subscription would wait forever.
9644    #[test]
9645    fn subscribe_on_as_of_returns_read_only_error() {
9646        let dir = tmp_dir("sub-as-of-read-only");
9647        {
9648            let mut db = GraphDb::open(&dir).unwrap();
9649            db.insert_node("Org", "o1", vec![]).unwrap();
9650            db.create_rule(fk_rule()).unwrap();
9651        }
9652        let mut aof = GraphDb::open_at(&dir, 0).unwrap();
9653
9654        assert!(
9655            matches!(
9656                aof.subscribe_all_rules(),
9657                Err(core_storage::GraphError::ReadOnly)
9658            ),
9659            "subscribe_all_rules on as-of must return ReadOnly"
9660        );
9661        assert!(
9662            matches!(
9663                aof.subscribe_writes(),
9664                Err(core_storage::GraphError::ReadOnly)
9665            ),
9666            "subscribe_writes on as-of must return ReadOnly"
9667        );
9668        assert!(
9669            matches!(
9670                aof.subscribe_rule("works_at"),
9671                Err(core_storage::GraphError::ReadOnly)
9672            ),
9673            "subscribe_rule on as-of must return ReadOnly"
9674        );
9675        let _ = std::fs::remove_dir_all(&dir);
9676    }
9677
9678    /// Regression: a failed dense WAL rewrite must not leave speculative
9679    /// interns in `syms`. If it does, the next successful mutation logs an
9680    /// `Intern` record with an inflated id; replay (which never saw the
9681    /// orphans) assigns a smaller id and the WAL becomes unreplayable.
9682    #[test]
9683    fn dense_rewrite_error_rolls_back_speculative_interns() {
9684        let dir = tmp_dir("dense-rewrite-rollback");
9685        {
9686            let mut db = GraphDb::open(&dir).unwrap();
9687            db.insert_node("Person", "a", vec![]).unwrap();
9688
9689            // Bypass MutPreview validation to hit the rewrite's own error path
9690            // (same shape as an id-exhaustion failure mid-rewrite). The
9691            // InsertEdge arm interns the edge type before it resolves keys.
9692            let err = db.rewrite_wal_dense(vec![WalRecord::InsertEdge {
9693                edge_type: "ORPHAN_TYPE".into(),
9694                src_key: "missing".into(),
9695                dst_key: "a".into(),
9696            }]);
9697            assert!(err.is_err(), "rewrite of a missing src key must fail");
9698            assert_eq!(
9699                db.syms.get("ORPHAN_TYPE"),
9700                None,
9701                "failed rewrite must roll back speculative interns"
9702            );
9703
9704            // A later successful mutation must produce a replayable WAL.
9705            db.set_prop("a", "later_field", Value::Int(2)).unwrap();
9706        }
9707        let db = GraphDb::open(&dir).expect("WAL must replay after failed rewrite");
9708        assert_eq!(db.get_prop("a", "later_field"), Some(Value::Int(2)));
9709        let _ = std::fs::remove_dir_all(&dir);
9710    }
9711}