Skip to main content

core_api/
db.rs

1use crate::ingest::{IngestOptions, IngestReport};
2use crate::roles::{RoleDef, RolesFile, WriteScope};
3use crate::subscription::{
4    event_matches, DbEvent, SubEntry, SubFilter, SubInner, Subscription, DEFAULT_SUB_CAPACITY,
5};
6use core_query::cypher::ast::ArithOp;
7use core_query::cypher::{
8    execute, execute_union, is_subscribable, is_write_tokens, lex, parse, parse_read, parse_write,
9    plan, MatchDeleteNodeStmt, NodePat, Operand, Params, Pattern, PlanOp, Query, RetItem, RetVal,
10    WriteStatement,
11};
12use core_query::{eval_filter, expand, neighborhood, Dir, Filter, GraphView, ResultSet};
13use core_rules::{
14    decode_rule_def, evaluate, EngineEdgeDelta, GraphMut, NodeView, Predicate, RuleDef, RuleEngine,
15    ViewDef, ViewStore,
16};
17use core_storage::fs::{FileId, Fs, FsIntrospect, RealFs};
18use core_storage::fulltext::FulltextIndex;
19use core_storage::property_index::PropertyIndex;
20use core_storage::v8::encode::{
21    archived_hnsw_to_owned, archived_rules_meta_to_owned, archived_to_idmap, archived_to_interner,
22    archived_views_to_owned, decode_last_change_bytes, decode_meta, encode_v8, V8Meta,
23};
24use core_storage::v8::seam::TopologyView;
25use core_storage::wal::{decode_all, encode_record, WalRecord};
26use core_storage::EdgePropsView;
27use core_storage::{
28    ColumnStore, Direction, EdgeProps, GraphError, IdMap, Interner, Result, Topology, Value,
29};
30use serde::{Deserialize, Serialize};
31use std::collections::{BTreeMap, BTreeSet, HashMap};
32use std::sync::Arc;
33
34/// Print a timing checkpoint when MUSHROOMDB_TRACE_OPEN is set.
35/// Zero-cost when the env var is absent (the var check is O(1) after first call).
36macro_rules! trace_open {
37    ($phase:literal, $t:expr) => {
38        if std::env::var("MUSHROOMDB_TRACE_OPEN").is_ok() {
39            eprintln!(
40                "[MUSHROOMDB_TRACE_OPEN] {:40} {:>9.3?}",
41                $phase,
42                $t.elapsed()
43            );
44        }
45    };
46}
47
48/// Print a migration phase checkpoint when MUSHROOMDB_TRACE_MIGRATE is set.
49/// Zero-cost when the env var is absent (the var check is O(1) after first call).
50macro_rules! trace_migrate {
51    ($phase:literal, $t:expr) => {
52        if std::env::var("MUSHROOMDB_TRACE_MIGRATE").is_ok() {
53            eprintln!(
54                "[MUSHROOMDB_TRACE_MIGRATE] {:40} {:>9.3?}",
55                $phase,
56                $t.elapsed()
57            );
58        }
59    };
60}
61
62// Test-only: counts how many times `pending_deltas_since().to_vec()` actually
63// executes (i.e., at least one view is defined). Used to verify the fast-path
64// guard skips the allocation when `view_store.is_empty()`.
65#[cfg(test)]
66thread_local! {
67    static DELTA_COPY_COUNT: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
68}
69
70// Per-thread count of query-subscription `execute` calls in `distribute_events`.
71//
72// Incremented each time a query subscription actually runs its plan (i.e.,
73// the label-skip fast-path did not fire). Because `distribute_events` is
74// called synchronously on the writer thread, this thread-local correctly
75// isolates each test thread's count even when integration tests run in
76// parallel. Read via [`query_sub_exec_count`].
77thread_local! {
78    static QUERY_SUB_EXECS_TL: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
79}
80
81/// Return the number of query-subscription re-executions logged on this
82/// thread since the process started (or since last reset via
83/// [`reset_query_sub_exec_count`]).
84///
85/// Primarily for integration tests that verify the label-skip fast-path.
86#[doc(hidden)]
87pub fn query_sub_exec_count() -> usize {
88    QUERY_SUB_EXECS_TL.with(|c| c.get())
89}
90
91/// Reset the per-thread query-subscription execution counter to zero.
92#[doc(hidden)]
93pub fn reset_query_sub_exec_count() {
94    QUERY_SUB_EXECS_TL.with(|c| c.set(0));
95}
96
97/// Internal state for a single `subscribe_query` subscription.
98///
99/// On every commit, `distribute_events` re-executes `ops` against the current
100/// graph state, diffs the result against `prev_rows`, and pushes
101/// `DbEvent::QueryRowAdded` / `QueryRowRemoved` events to `inner`.
102///
103/// **Full re-run per commit; use LIMIT to bound execution cost.**
104/// (Differential evaluation is roadmap / Phase 5.)
105pub(crate) struct QuerySubEntry {
106    /// Compiled plan for the subscribed Cypher query.
107    ops: Vec<PlanOp>,
108    /// Column names from the first execution (fixed for the subscription lifetime).
109    columns: Vec<String>,
110    /// Serialized (JSON) row key → row data, representing the result set at
111    /// the end of the last commit. Used to diff against the new result.
112    prev_row_map: std::collections::HashMap<String, Vec<Option<Value>>>,
113    /// Weak pointer to the subscriber queue; dead Weak → subscription dropped.
114    inner: std::sync::Weak<SubInner>,
115    /// Interned label sym captured at subscribe time from the plan's leading scan
116    /// (`ScanLabel`, `IndexScan`, or `IndexIntersect` with a concrete label).
117    ///
118    /// `None` means the plan has an `Expand` op (or no recognizable leading scan
119    /// with a concrete label), and this subscription must re-execute on every
120    /// commit without skipping. This is the conservative v0.4.3 boundary: Expand
121    /// queries are never skipped because edges can alter join results regardless
122    /// of which node labels were written.
123    scan_label: Option<u32>,
124}
125
126/// A post-commit mutation notification.
127///
128/// Emitted from `log_then_apply` after the WAL append, fsync, and
129/// in-memory `apply` all succeed. Never emitted for rejected operations
130/// (validation errors, [`GraphError::RuleOwned`], duplicate keys, no-op
131/// deletes/removes). Event payloads carry user keys and rule names, never
132/// internal ids.
133///
134/// **Replay:** [`GraphDb::open`] / [`GraphDb::open_with`] replay the WAL via
135/// `apply` only. Emission lives exclusively in `log_then_apply`, so
136/// recovery is silent even if a sink were installed (it cannot be: the
137/// sink is in-memory and set after open).
138///
139/// **Ordering:** a `Batch` WAL frame emits one event per inner record, then
140/// [`MutationEvent::BatchApplied`]. An ingest commit emits those same inner
141/// events, then [`MutationEvent::Ingested`] (not `BatchApplied`). An empty
142/// or all-noop batch writes no WAL and emits nothing (including no summary).
143///
144/// **Derived edges:** rule-created or retracted edges are not individually
145/// evented — they are recoverable from the triggering mutation plus the live
146/// rule set. Only the triggering record is emitted.
147///
148/// **Wire form:** externally tagged snake_case JSON
149/// (`{"node_inserted":{"label":"A","key":"k"}}`).
150#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
151#[serde(rename_all = "snake_case")]
152pub enum MutationEvent {
153    NodeInserted {
154        label: String,
155        key: String,
156    },
157    PropSet {
158        key: String,
159        field: String,
160    },
161    PropRemoved {
162        key: String,
163        field: String,
164    },
165    EdgeInserted {
166        edge_type: String,
167        src: String,
168        dst: String,
169    },
170    EdgeDeleted {
171        edge_type: String,
172        src: String,
173        dst: String,
174    },
175    NodeDeleted {
176        key: String,
177    },
178    RuleCreated {
179        name: String,
180    },
181    RuleDeleted {
182        name: String,
183    },
184    RuleRebuilt {
185        name: String,
186    },
187    BatchApplied {
188        ops: usize,
189    },
190    Ingested {
191        label: String,
192        inserted: usize,
193    },
194}
195
196fn event_from_record(rec: &WalRecord, intern: &Interner, ids: &IdMap) -> Option<MutationEvent> {
197    match rec {
198        WalRecord::InsertNode { label, key, .. } => Some(MutationEvent::NodeInserted {
199            label: label.clone(),
200            key: key.clone(),
201        }),
202        WalRecord::InsertNodeId { label, key, .. } => Some(MutationEvent::NodeInserted {
203            label: intern.resolve(*label)?.to_string(),
204            key: key.clone(),
205        }),
206        WalRecord::SetProp { key, field, .. } => Some(MutationEvent::PropSet {
207            key: key.clone(),
208            field: field.clone(),
209        }),
210        WalRecord::SetPropId { id, field, .. } => Some(MutationEvent::PropSet {
211            key: ids.key_of(*id)?.to_string(),
212            field: intern.resolve(*field)?.to_string(),
213        }),
214        WalRecord::RemoveProp { key, field } => Some(MutationEvent::PropRemoved {
215            key: key.clone(),
216            field: field.clone(),
217        }),
218        WalRecord::InsertEdge {
219            edge_type,
220            src_key,
221            dst_key,
222        } => Some(MutationEvent::EdgeInserted {
223            edge_type: edge_type.clone(),
224            src: src_key.clone(),
225            dst: dst_key.clone(),
226        }),
227        WalRecord::InsertEdgeId { etype, src, dst } => Some(MutationEvent::EdgeInserted {
228            edge_type: intern.resolve(*etype)?.to_string(),
229            src: ids.key_of(*src)?.to_string(),
230            dst: ids.key_of(*dst)?.to_string(),
231        }),
232        WalRecord::DeleteEdge {
233            edge_type,
234            src_key,
235            dst_key,
236        } => Some(MutationEvent::EdgeDeleted {
237            edge_type: edge_type.clone(),
238            src: src_key.clone(),
239            dst: dst_key.clone(),
240        }),
241        WalRecord::DeleteNode { key } => Some(MutationEvent::NodeDeleted { key: key.clone() }),
242        WalRecord::CreateRule { def_bytes } => {
243            let def: RuleDef = decode_rule_def(def_bytes).ok()?;
244            Some(MutationEvent::RuleCreated { name: def.name })
245        }
246        WalRecord::DeleteRule { name } => Some(MutationEvent::RuleDeleted { name: name.clone() }),
247        WalRecord::RebuildRule { name } => Some(MutationEvent::RuleRebuilt { name: name.clone() }),
248        WalRecord::Batch(_)
249        | WalRecord::CreateView { .. }
250        | WalRecord::DeleteView { .. }
251        | WalRecord::EnableFulltext { .. }
252        | WalRecord::DisableFulltext { .. }
253        | WalRecord::EnableIndex { .. }
254        | WalRecord::DisableIndex { .. }
255        | WalRecord::Intern { .. }
256        // History markers are no-ops for mutation events — they carry no new
257        // state and rules re-derive deterministically on replay.
258        | WalRecord::DerivedEdgeAdded { .. }
259        | WalRecord::DerivedEdgeRetracted { .. }
260        // RenameNode carries no node/edge count change; no special event.
261        | WalRecord::RenameNode { .. } => None,
262    }
263}
264
265/// Database-wide counters plus per-rule budget/fire stats.
266#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
267pub struct Stats {
268    pub nodes_live: usize,
269    pub nodes_tombstoned: usize,
270    pub edges: u64,
271    pub rules: Vec<RuleStats>,
272}
273
274/// One rule's provenance size, trip latch, and fire counter.
275///
276/// `tripped` is a one-way latch: once set, the engine adds no new edges for
277/// that rule until [`GraphDb::rebuild_rule`] (and only if the full desired
278/// set then fits). `fires` counts `on_node_changed` evaluations plus
279/// backfill/rebuild participant ticks (rebuild counts even when it is a
280/// provenance no-op).
281#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
282pub struct RuleStats {
283    pub name: String,
284    pub edges: u64,
285    pub tripped: bool,
286    pub fires: u64,
287    /// Whether this rule uses the approximate IVF-Flat candidate path.
288    pub approximate: bool,
289}
290
291/// One entry in the slow-query ring buffer.
292#[derive(Debug, Clone, Serialize)]
293pub struct SlowQueryEntry {
294    /// Execution time in whole milliseconds.
295    pub ms: u64,
296    /// The Cypher query string that was slow.
297    pub query: String,
298    /// The commit sequence number at the time the query ran.
299    pub at_commit: u64,
300}
301
302/// Snapshot of the slow-query log returned by [`GraphDb::slow_query_snapshot`].
303#[derive(Debug, Clone, Serialize)]
304pub struct SlowQuerySnapshot {
305    /// Current threshold in milliseconds (0 = disabled).
306    pub threshold_ms: u64,
307    /// Total number of slow queries ever recorded (not capped by ring size).
308    pub count: u64,
309    /// Most-recent slow queries (up to 16), oldest first.
310    pub last: Vec<SlowQueryEntry>,
311}
312
313/// Internal ring-buffer state protected by a `Mutex` so `query(&self)` can
314/// write to it without a mutable borrow.
315struct SlowQueryLog {
316    entries: std::collections::VecDeque<SlowQueryEntry>,
317    total: u64,
318}
319
320/// Maximum number of entries kept in the slow-query ring buffer.
321const SLOW_QUERY_RING_CAP: usize = 16;
322
323/// Wire summary of a [`Predicate`]. JSON only — `Explanation` is never
324/// bincode-persisted (WAL/snapshots store `RuleDef` bytes, not this type).
325#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
326pub struct PredicateSummary {
327    pub kind: String,
328    pub fields: Vec<String>,
329    pub min: Option<f64>,
330    pub tolerance: Option<f64>,
331    pub km: Option<f64>,
332    pub parts: Option<Vec<PredicateSummary>>,
333    /// True when the owning rule has `approximate=true` (IVF-Flat candidate path).
334    /// Always false for predicates reported without rule context (sub-predicates in `parts`).
335    #[serde(default)]
336    pub approximate: bool,
337}
338
339impl From<&Predicate> for PredicateSummary {
340    fn from(p: &Predicate) -> Self {
341        match p {
342            Predicate::KeyMatch { field } => PredicateSummary {
343                kind: "key_match".into(),
344                fields: vec![field.clone()],
345                min: None,
346                tolerance: None,
347                km: None,
348                parts: None,
349                approximate: false,
350            },
351            Predicate::FieldEqual { field } => PredicateSummary {
352                kind: "field_equal".into(),
353                fields: vec![field.clone()],
354                min: None,
355                tolerance: None,
356                km: None,
357                parts: None,
358                approximate: false,
359            },
360            Predicate::Overlap { field, min } => PredicateSummary {
361                kind: "overlap".into(),
362                fields: vec![field.clone()],
363                min: Some(*min),
364                tolerance: None,
365                km: None,
366                parts: None,
367                approximate: false,
368            },
369            Predicate::NumericWithin { field, tolerance } => PredicateSummary {
370                kind: "numeric_within".into(),
371                fields: vec![field.clone()],
372                min: None,
373                tolerance: Some(*tolerance),
374                km: None,
375                parts: None,
376                approximate: false,
377            },
378            Predicate::GeoRadius { field, km } => PredicateSummary {
379                kind: "geo_radius".into(),
380                fields: vec![field.clone()],
381                min: None,
382                tolerance: None,
383                km: Some(*km),
384                parts: None,
385                approximate: false,
386            },
387            Predicate::VectorSimilar { field, min } => PredicateSummary {
388                kind: "vector_similar".into(),
389                fields: vec![field.clone()],
390                min: Some(*min),
391                tolerance: None,
392                km: None,
393                parts: None,
394                approximate: false,
395            },
396            Predicate::All(inner) => {
397                let parts: Vec<PredicateSummary> = inner.iter().map(Self::from).collect();
398                let mut fields = Vec::new();
399                for part in &parts {
400                    for f in &part.fields {
401                        if !fields.contains(f) {
402                            fields.push(f.clone());
403                        }
404                    }
405                }
406                PredicateSummary {
407                    kind: "all".into(),
408                    fields,
409                    min: None,
410                    tolerance: None,
411                    km: None,
412                    parts: Some(parts),
413                    approximate: false,
414                }
415            }
416            Predicate::Any(inner) => {
417                let parts: Vec<PredicateSummary> = inner.iter().map(Self::from).collect();
418                let mut fields = Vec::new();
419                for part in &parts {
420                    for f in &part.fields {
421                        if !fields.contains(f) {
422                            fields.push(f.clone());
423                        }
424                    }
425                }
426                PredicateSummary {
427                    kind: "any".into(),
428                    fields,
429                    min: None,
430                    tolerance: None,
431                    km: None,
432                    parts: Some(parts),
433                    approximate: false,
434                }
435            }
436        }
437    }
438}
439
440/// Snapshot of a live node's key, label, and columnar properties.
441///
442/// `props` is a [`BTreeMap`] so field order is deterministic (sorted by name)
443/// regardless of insert order or the columnar store's `HashMap` iteration.
444///
445/// Deliberately does not derive `Serialize`: `Value`'s serde form is
446/// internally tagged. Wire JSON is built by `value_to_json` in the server.
447#[derive(Debug, Clone, PartialEq)]
448pub struct NodeInfo {
449    pub key: String,
450    pub label: String,
451    pub props: BTreeMap<String, Value>,
452}
453
454/// Counts returned by [`GraphDb::delete_node`].
455#[derive(Debug, Clone, PartialEq, Eq, Default)]
456pub struct DeleteReport {
457    /// Number of manual (user-inserted) edges removed.
458    pub manual_edges: u64,
459    /// Number of derived (rule-owned) edges retracted.
460    pub derived_edges: u64,
461}
462
463/// One directed edge incident on a node, with provenance membership.
464///
465/// `derived` is true iff `(edge_type, src, dst)` is in the rule engine's
466/// Plan-8 `by_node` provenance index.
467#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
468pub struct EdgeInfo {
469    pub edge_type: String,
470    pub src_key: String,
471    pub dst_key: String,
472    pub derived: bool,
473}
474
475/// An edge with mask-aware endpoint visibility.
476///
477/// Returned by [`GraphDb::node_edges_masked`] in [`crate::mask::MaskMode::Stub`]
478/// mode — hidden endpoints carry `*_restricted: true`.
479#[derive(Debug, Clone, PartialEq, Eq)]
480pub struct MaskedEdge {
481    pub edge_type: String,
482    pub src_key: String,
483    /// `true` when `src_key` is in the DB but hidden from the mask.
484    pub src_restricted: bool,
485    pub dst_key: String,
486    /// `true` when `dst_key` is in the DB but hidden from the mask.
487    pub dst_restricted: bool,
488    pub derived: bool,
489}
490
491/// Result of a mask-aware node lookup via [`GraphDb::node_info_masked`].
492///
493/// `None` from that method means the key does not exist (→ 404).
494/// `Some(Restricted)` is only produced when `mask.mode() == MaskMode::Stub`.
495#[derive(Debug, PartialEq)]
496pub enum MaskedNodeResult {
497    Visible(NodeInfo),
498    /// Node exists in the DB but is hidden from this mask.
499    Restricted,
500}
501
502/// One rule-owned edge between two nodes, with the rule name, edge type,
503/// direction (src_key → dst_key), and weight if the rule stores one.
504#[derive(Debug, Clone, PartialEq, Serialize)]
505pub struct Explanation {
506    pub rule: String,
507    pub edge_type: String,
508    pub src_key: String,
509    pub dst_key: String,
510    pub weight: Option<f64>,
511    pub predicate: PredicateSummary,
512}
513
514/// Report returned by [`GraphDb::backup_to`].
515#[derive(Debug, Clone)]
516pub struct BackupReport {
517    /// Filenames copied into the destination directory (sorted ascending).
518    pub files: Vec<String>,
519    /// Total bytes written across all copied files.
520    pub bytes: u64,
521    /// `true` when the destination opened cleanly and passed post-copy checks.
522    ///
523    /// For stores that have a `snapshot.bin` this means: all V8 section CRCs
524    /// matched **and** the destination opened without error.
525    ///
526    /// For WAL-only stores (no `snapshot.bin`) there is no snapshot to
527    /// CRC-check; `verified` is `true` when the destination opened and
528    /// replayed the WAL without error (record-level checksums in the WAL
529    /// provide the integrity signal, not section CRCs).
530    pub verified: bool,
531}
532
533/// One directed edge in export form, with optional rule attribution for derived edges.
534///
535/// Returned by [`GraphDb::all_edges_for_export`].
536#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
537pub struct ExportEdge {
538    pub edge_type: String,
539    pub src: String,
540    pub dst: String,
541    pub derived: bool,
542    /// Rule name that created this edge, if derived. `None` for manual edges.
543    pub rule: Option<String>,
544}
545
546/// Construct the standard write-query result set (columns: created, properties_set, deleted).
547fn write_result_set() -> ResultSet {
548    ResultSet::new(vec![
549        "created".into(),
550        "properties_set".into(),
551        "deleted".into(),
552    ])
553}
554
555fn resolve_merge_set_value(op: &Operand, params: &BTreeMap<String, Value>) -> Result<Value> {
556    match op {
557        Operand::Lit(v) => Ok(v.clone()),
558        Operand::Param(name) => params
559            .get(name)
560            .cloned()
561            .ok_or_else(|| GraphError::QueryError {
562                detail: format!("missing parameter `{name}`"),
563            }),
564        _ => Err(GraphError::QueryError {
565            detail: "ON CREATE/ON MATCH SET value must be a literal or $parameter".into(),
566        }),
567    }
568}
569
570fn operand_node_vars(op: &Operand, out: &mut Vec<String>) {
571    match op {
572        Operand::Prop { var, .. } | Operand::Var(var) => {
573            if !out.contains(var) {
574                out.push(var.clone());
575            }
576        }
577        Operand::FuncCall { args, .. } => {
578            for arg in args {
579                operand_node_vars(arg, out);
580            }
581        }
582        Operand::BinArith { left, right, .. } => {
583            operand_node_vars(left, out);
584            operand_node_vars(right, out);
585        }
586        Operand::Case { branches, default } => {
587            // Branch conditions reference vars already bound (and mask-filtered)
588            // by the MATCH phase, so collecting from the value operands + ELSE
589            // is sufficient for RETURN-projection var discovery.
590            for (_, value) in branches {
591                operand_node_vars(value, out);
592            }
593            if let Some(d) = default {
594                operand_node_vars(d, out);
595            }
596        }
597        Operand::Lit(_) | Operand::Param(_) => {}
598    }
599}
600
601fn ret_node_vars(items: &[RetItem]) -> Vec<String> {
602    let mut out = Vec::new();
603    for item in items {
604        match &item.value {
605            RetVal::Var(v) | RetVal::Prop { var: v, .. } => {
606                if !out.contains(v) {
607                    out.push(v.clone());
608                }
609            }
610            RetVal::FuncCall { args, .. } => {
611                for arg in args {
612                    operand_node_vars(arg, &mut out);
613                }
614            }
615            RetVal::ScalarExpr(op) => operand_node_vars(op, &mut out),
616            RetVal::Agg { .. } => {}
617        }
618    }
619    out
620}
621
622fn add_var(out: &mut Vec<String>, v: &str) {
623    if !out.iter().any(|x| x == v) {
624        out.push(v.to_string());
625    }
626}
627
628fn pattern_node_vars(pats: &[Pattern]) -> Vec<String> {
629    let mut out = Vec::new();
630    for p in pats {
631        if let Some(v) = &p.start.var {
632            add_var(&mut out, v);
633        }
634        for (_, dest) in &p.chain {
635            if let Some(v) = &dest.var {
636                add_var(&mut out, v);
637            }
638        }
639    }
640    out
641}
642
643fn pattern_rel_vars(pats: &[Pattern]) -> Vec<String> {
644    let mut out = Vec::new();
645    for p in pats {
646        for (rel, _) in &p.chain {
647            if rel.hops.is_none() {
648                if let Some(v) = &rel.var {
649                    add_var(&mut out, v);
650                }
651            }
652        }
653    }
654    out
655}
656
657fn rel_type_alias(var: &str) -> String {
658    format!("__rt_{var}")
659}
660
661fn ret_column_name(item: &RetItem) -> String {
662    if let Some(alias) = &item.alias {
663        return alias.clone();
664    }
665    match &item.value {
666        RetVal::Var(v) => v.clone(),
667        RetVal::Prop { var, field } => format!("{var}.{field}"),
668        RetVal::FuncCall { name, args } => {
669            let arg_strs: Vec<String> = args
670                .iter()
671                .map(|a| match a {
672                    Operand::Var(v) => v.clone(),
673                    Operand::Prop { var, field } => format!("{var}.{field}"),
674                    Operand::Lit(_) => "<lit>".to_string(),
675                    Operand::Param(p) => format!("${p}"),
676                    Operand::FuncCall { name: n, .. } => format!("{n}(...)"),
677                    Operand::BinArith { .. } => "<arith>".to_string(),
678                    Operand::Case { .. } => "<case>".to_string(),
679                })
680                .collect();
681            format!("{name}({})", arg_strs.join(", "))
682        }
683        RetVal::ScalarExpr(_) => "<expr>".to_string(),
684        RetVal::Agg { .. } => "<agg>".to_string(),
685    }
686}
687
688fn eval_set_return_operand<F: Fs>(
689    db: &GraphDb<F>,
690    match_rs: &ResultSet,
691    row: usize,
692    rel_vars: &[String],
693    op: &Operand,
694    params: &BTreeMap<String, Value>,
695) -> Result<Option<Value>> {
696    match op {
697        Operand::Lit(v) => Ok(Some(v.clone())),
698        Operand::Param(name) => params.get(name).cloned().ok_or_else(|| GraphError::QueryError {
699            detail: format!("missing parameter `{name}`"),
700        }).map(Some),
701        Operand::Var(name) if rel_vars.iter().any(|r| r == name) => Err(GraphError::QueryError {
702            detail: format!(
703                "cannot return relationship variable '{name}' bare; return its properties ({name}.field) instead"
704            ),
705        }),
706        Operand::Var(name) => Ok(match_rs.get(row, name).cloned()),
707        Operand::Prop { var, field } => {
708            if rel_vars.iter().any(|r| r == var) {
709                return Ok(None);
710            }
711            let Some(Value::Str(key)) = match_rs.get(row, var) else {
712                return Ok(None);
713            };
714            Ok(db.get_prop(key, field))
715        }
716        Operand::FuncCall { name, args } => {
717            eval_set_return_func(db, match_rs, row, rel_vars, name, args, params)
718        }
719        Operand::BinArith { op, left, right } => {
720            let lv = eval_set_return_operand(db, match_rs, row, rel_vars, left, params)?;
721            let rv = eval_set_return_operand(db, match_rs, row, rel_vars, right, params)?;
722            eval_set_return_arith(op, lv, rv)
723        }
724        // CASE is supported in read-query RETURN; in a write-statement RETURN
725        // projection (CREATE/MERGE/SET … RETURN) it is not yet wired.
726        Operand::Case { .. } => Err(GraphError::QueryError {
727            detail: "CASE is not supported in a write-statement RETURN projection; \
728                     use a read query"
729                .into(),
730        }),
731    }
732}
733
734fn eval_set_return_arith(
735    op: &ArithOp,
736    lv: Option<Value>,
737    rv: Option<Value>,
738) -> Result<Option<Value>> {
739    match (lv, rv) {
740        (None, _) | (_, None) => Ok(None),
741        (Some(Value::Int(a)), Some(Value::Int(b))) => {
742            let result = match op {
743                ArithOp::Sub => a.saturating_sub(b),
744                ArithOp::Mul => a.saturating_mul(b),
745                ArithOp::Add => a.saturating_add(b),
746                ArithOp::Div => {
747                    if b == 0 {
748                        return Err(GraphError::QueryError {
749                            detail: "division by zero".into(),
750                        });
751                    }
752                    a.checked_div(b).unwrap_or(i64::MAX)
753                }
754            };
755            Ok(Some(Value::Int(result)))
756        }
757        (Some(lv), Some(rv)) => {
758            let a = match &lv {
759                Value::Float(f) => *f,
760                Value::Int(i) => *i as f64,
761                _ => {
762                    return Err(GraphError::QueryError {
763                        detail: format!("arithmetic operand must be numeric, got {lv:?}"),
764                    })
765                }
766            };
767            let b = match &rv {
768                Value::Float(f) => *f,
769                Value::Int(i) => *i as f64,
770                _ => {
771                    return Err(GraphError::QueryError {
772                        detail: format!("arithmetic operand must be numeric, got {rv:?}"),
773                    })
774                }
775            };
776            let result = match op {
777                ArithOp::Sub => a - b,
778                ArithOp::Mul => a * b,
779                ArithOp::Add => a + b,
780                ArithOp::Div => {
781                    if b == 0.0 {
782                        return Err(GraphError::QueryError {
783                            detail: "division by zero".into(),
784                        });
785                    }
786                    a / b
787                }
788            };
789            Ok(Some(Value::Float(result)))
790        }
791    }
792}
793
794fn eval_set_return_func<F: Fs>(
795    db: &GraphDb<F>,
796    match_rs: &ResultSet,
797    row: usize,
798    rel_vars: &[String],
799    name: &str,
800    args: &[Operand],
801    params: &BTreeMap<String, Value>,
802) -> Result<Option<Value>> {
803    let norm = name.to_ascii_lowercase();
804    if norm == "type" {
805        if args.len() != 1 {
806            return Err(GraphError::QueryError {
807                detail: format!("type() requires exactly 1 argument, got {}", args.len()),
808            });
809        }
810        let Operand::Var(rel) = &args[0] else {
811            return Err(GraphError::QueryError {
812                detail: "type() argument must be a relationship variable (e.g. type(r))".into(),
813            });
814        };
815        return Ok(match_rs.get(row, &rel_type_alias(rel)).cloned());
816    }
817    let mut vals = Vec::with_capacity(args.len());
818    for arg in args {
819        vals.push(eval_set_return_operand(
820            db, match_rs, row, rel_vars, arg, params,
821        )?);
822    }
823    match norm.as_str() {
824        "tolower" => {
825            if vals.len() != 1 {
826                return Err(GraphError::QueryError {
827                    detail: format!("toLower() requires exactly 1 argument, got {}", vals.len()),
828                });
829            }
830            Ok(vals[0].clone().map(|val| match val {
831                Value::Str(s) => Value::Str(s.to_ascii_lowercase()),
832                other => other,
833            }))
834        }
835        "toupper" => {
836            if vals.len() != 1 {
837                return Err(GraphError::QueryError {
838                    detail: format!("toUpper() requires exactly 1 argument, got {}", vals.len()),
839                });
840            }
841            Ok(vals[0].clone().map(|val| match val {
842                Value::Str(s) => Value::Str(s.to_ascii_uppercase()),
843                other => other,
844            }))
845        }
846        "size" => match vals.first().cloned().flatten() {
847            None => Ok(None),
848            Some(Value::Str(s)) => Ok(Some(Value::Int(s.len() as i64))),
849            Some(Value::List(items)) => Ok(Some(Value::Int(items.len() as i64))),
850            Some(_) => Ok(None),
851        },
852        "coalesce" => Ok(vals.into_iter().flatten().next()),
853        "abs" => match vals.first().cloned().flatten() {
854            None => Ok(None),
855            Some(Value::Int(n)) => Ok(Some(Value::Int(n.saturating_abs()))),
856            Some(Value::Float(f)) => Ok(Some(Value::Float(f.abs()))),
857            Some(_) => Ok(None),
858        },
859        "round" => match vals.first().cloned().flatten() {
860            None => Ok(None),
861            Some(Value::Float(f)) => Ok(Some(Value::Float(f.round()))),
862            Some(Value::Int(n)) => Ok(Some(Value::Int(n))),
863            Some(_) => Ok(None),
864        },
865        _ => Err(GraphError::QueryError {
866            detail: format!(
867                "unknown function `{name}`; supported: toLower, toUpper, size, coalesce, type, abs, round, textMatches"
868            ),
869        }),
870    }
871}
872
873fn eval_set_return_item<F: Fs>(
874    db: &GraphDb<F>,
875    match_rs: &ResultSet,
876    row: usize,
877    rel_vars: &[String],
878    item: &RetItem,
879    params: &BTreeMap<String, Value>,
880) -> Result<Option<Value>> {
881    match &item.value {
882        RetVal::Var(v) => eval_set_return_operand(
883            db,
884            match_rs,
885            row,
886            rel_vars,
887            &Operand::Var(v.clone()),
888            params,
889        ),
890        RetVal::Prop { var, field } => eval_set_return_operand(
891            db,
892            match_rs,
893            row,
894            rel_vars,
895            &Operand::Prop {
896                var: var.clone(),
897                field: field.clone(),
898            },
899            params,
900        ),
901        RetVal::FuncCall { name, args } => {
902            eval_set_return_func(db, match_rs, row, rel_vars, name, args, params)
903        }
904        RetVal::ScalarExpr(op) => eval_set_return_operand(db, match_rs, row, rel_vars, op, params),
905        RetVal::Agg { .. } => Err(GraphError::QueryError {
906            detail: "aggregates are not supported in MATCH … SET … RETURN".into(),
907        }),
908    }
909}
910
911/// Project user RETURN from original MATCH rows after SET. No rematch.
912fn project_set_return_rows<F: Fs>(
913    db: &GraphDb<F>,
914    rel_vars: &[String],
915    match_rs: &ResultSet,
916    returns: &[RetItem],
917    params: &BTreeMap<String, Value>,
918) -> Result<ResultSet> {
919    let columns: Vec<String> = returns.iter().map(ret_column_name).collect();
920    let mut out = ResultSet::new(columns);
921    for row in 0..match_rs.len() {
922        let mut cells = Vec::with_capacity(returns.len());
923        for item in returns {
924            cells.push(eval_set_return_item(
925                db, match_rs, row, rel_vars, item, params,
926            )?);
927        }
928        out.push_row(cells);
929    }
930    Ok(out)
931}
932
933/// Single construction point for a `GraphMut` view over the split-borrowed graph fields.
934/// Callers use `std::mem::take` on the engine before calling this, then restore it after.
935/// Extract a `Vec<f64>` from a `Value::List` whose items are all numeric.
936/// Returns `None` for non-list values or lists with non-numeric elements.
937fn value_as_float_list(v: &Value) -> Option<Vec<f64>> {
938    match v {
939        Value::List(items) => items
940            .iter()
941            .map(|item| match item {
942                Value::Float(f) => Some(*f),
943                Value::Int(i) => Some(*i as f64),
944                _ => None,
945            })
946            .collect(),
947        _ => None,
948    }
949}
950
951fn make_graph_mut<'a>(
952    ids: &'a IdMap,
953    syms: &'a mut Interner,
954    labels: &'a [u32],
955    props: core_storage::v8::seam::ColumnsView<'a>,
956    topo: &'a mut Topology,
957    edge_props: &'a mut EdgeProps,
958) -> GraphMut<'a> {
959    GraphMut {
960        ids,
961        syms,
962        labels,
963        props,
964        topo,
965        edge_props,
966    }
967}
968
969/// Build a `ColumnsView` from the disjoint `props` overlay and optional V8 base.
970///
971/// Takes explicit field references rather than `&self` so the caller can hold
972/// simultaneous mutable borrows of other fields (e.g. `syms`, `topo`).
973fn build_props_view<'a>(
974    props: &'a ColumnStore,
975    base: &'a Option<std::sync::Arc<core_storage::v8::MappedBase>>,
976) -> core_storage::v8::seam::ColumnsView<'a> {
977    match base {
978        None => core_storage::v8::seam::ColumnsView::owned(props),
979        Some(b) => {
980            let archived = b
981                .columns()
982                .expect("base columns section bounds validated at open");
983            core_storage::v8::seam::ColumnsView::with_base(props, archived)
984        }
985    }
986}
987
988fn build_topo_view<'a>(
989    overlay: &'a Topology,
990    base: &'a Option<std::sync::Arc<core_storage::v8::MappedBase>>,
991) -> core_storage::v8::seam::TopologyView<'a> {
992    match base {
993        None => core_storage::v8::seam::TopologyView::owned(overlay),
994        Some(b) => {
995            let archived_csr = b
996                .topology()
997                .expect("base topology section bounds validated at open");
998            core_storage::v8::seam::TopologyView::with_base(overlay, archived_csr)
999        }
1000    }
1001}
1002
1003/// When [`GraphDb`] calls `Fs::sync` after a WAL append.
1004///
1005/// Default is [`Strict`](FsyncPolicy::Strict): every `log_then_apply_with`
1006/// fsyncs (single `insert_node` / `set_prop`). Ingest and `write_batch`
1007/// emit one `WalRecord::Batch` and fsync once at that frame (Batched).
1008/// [`Relaxed`](FsyncPolicy::Relaxed) skips WAL sync; [`GraphDb::snapshot`]
1009/// is still durable via `write_atomic`. Crash-recovery DST stays Strict.
1010#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
1011pub enum FsyncPolicy {
1012    /// Every WAL commit calls `fs.sync` (today's behavior).
1013    #[default]
1014    Strict,
1015    /// Sync only at a `Batch` frame end. Single-op path stays Strict unless
1016    /// this policy is set on the database.
1017    Batched,
1018    /// Never call `fs.sync`. [`GraphDb::snapshot`] still syncs via `write_atomic`.
1019    Relaxed,
1020}
1021
1022/// A precondition for a compare-and-set batch write.
1023///
1024/// All preconditions in a [`GraphDb::write_batch_cas`] or
1025/// [`crate::SharedDb::submit_batch_cas`] call are checked atomically before
1026/// any operation in the batch is applied.  If any precondition fails, the
1027/// entire batch is rejected with [`GraphError::CasConflict`] and no WAL frame
1028/// is written.
1029///
1030/// # Touch definition
1031///
1032/// A node's last-change commit (`last_changed`) is updated when any of the
1033/// following state-changing WAL records touch it:
1034///
1035/// - `InsertNode` / `InsertNodeId` — the newly-inserted node.
1036/// - `SetProp` / `SetPropId` / `RemoveProp` — the property-bearing node.
1037/// - `InsertEdge` / `InsertEdgeId` / `DeleteEdge` — **both** src and dst
1038///   endpoints (an edge change touches both sides).
1039/// - `DeleteNode` — the node is tombstoned; `last_changed` returns `None`
1040///   for deleted keys so the pre-deletion entry is never observed.
1041///
1042/// History markers (`DerivedEdgeAdded` / `DerivedEdgeRetracted`) are
1043/// state no-ops.  The underlying mutation that triggered rule firing already
1044/// updated the relevant nodes' last-change entries.  Rule-management records
1045/// (`CreateRule`, `DeleteRule`, `RebuildRule`) and view/full-text declarations
1046/// do not touch any node's last-change.
1047#[derive(Debug, Clone, PartialEq, Eq)]
1048pub enum Precondition {
1049    /// The node's last-change commit must equal `expected`.
1050    ///
1051    /// Fails with [`GraphError::CasConflict`] when:
1052    /// - The node does not exist (`last_changed` returns `None`), or
1053    /// - The recorded commit seq does not match `expected`.
1054    NodeUnchangedSince { key: String, expected: u64 },
1055    /// The node must not exist (not inserted, or already deleted).
1056    ///
1057    /// Fails with [`GraphError::CasConflict`] (expected=`u64::MAX`,
1058    /// actual=`last_changed(key).unwrap_or(0)`) when the node is live.
1059    NodeAbsent { key: String },
1060}
1061
1062pub struct GraphDb<F: Fs> {
1063    fs: F,
1064    ids: IdMap,
1065    syms: Interner,
1066    topo: Topology,
1067    props: ColumnStore,
1068    labels: Vec<u32>, // node id -> label symbol
1069    edge_props: EdgeProps,
1070    engine: RuleEngine,
1071    view_store: ViewStore,
1072    /// Incremental inverted index for full-text-lite search.
1073    /// Rebuild-on-open: populated from WAL replay + rebuild_all at open end.
1074    fulltext: FulltextIndex,
1075    /// Opt-in equality index over scalar node properties.
1076    /// Rebuild-on-open: declarations replay from the WAL, postings rebuild at
1077    /// open end (mirrors `fulltext`).
1078    prop_index: PropertyIndex,
1079    event_sink: Option<Box<dyn Fn(MutationEvent) + Send + Sync>>,
1080    /// WAL fsync cadence. Default [`FsyncPolicy::Strict`].
1081    fsync: FsyncPolicy,
1082    /// Monotonically increasing per-commit counter.  A single `log_then_apply_with`
1083    /// call increments this once; all events emitted from that call share the same
1084    /// `commit_seq` value.
1085    commit_seq: u64,
1086    /// RBAC role definitions loaded from `roles.json` at open.
1087    ///
1088    /// `Some(roles)` — loaded successfully (may be empty when no roles are defined).
1089    /// `None` — `roles.json` was present but corrupt; `mask_for_role` returns
1090    /// `Err` for any request (fail-loud, never silently grant empty visibility).
1091    roles: Option<Vec<RoleDef>>,
1092    /// Live subscriptions.  Entries with a dead `Weak` are pruned on the next
1093    /// distribute_events call.
1094    subscriptions: Vec<SubEntry>,
1095    /// Live query subscriptions. Re-executed on every commit when non-empty.
1096    /// Dead `Weak` entries are pruned inside `distribute_events`.
1097    query_subscriptions: Vec<QuerySubEntry>,
1098    /// Queue capacity for new subscriptions created by this db.  Default is
1099    /// [`DEFAULT_SUB_CAPACITY`]; can be overridden via [`set_sub_capacity`]
1100    /// to test Lagged behaviour with small queues.
1101    sub_capacity: usize,
1102    /// True for as-of instances opened via [`GraphDb::open_at`].
1103    /// Every mutation method and `snapshot()` returns [`GraphError::ReadOnly`]
1104    /// when this flag is set.
1105    read_only: bool,
1106    /// Total WAL commit count at the time [`open_at`] was called.
1107    /// 0 for normal (non-as-of) instances.
1108    total_wal_commits: u64,
1109    /// Immutable mmap-backed base snapshot (V8).  When `Some`, `self.topo` is
1110    /// the WAL-replay overlay (empty at open time, populated by apply()) and
1111    /// reads go through a merged `TopologyView`.  `self.props` is always
1112    /// fully materialized (base + WAL replay) for HNSW/IVF and view compat.
1113    base: Option<Arc<core_storage::v8::MappedBase>>,
1114    // ── MVCC epoch reader state ───────────────────────────────────────────────
1115    /// Most-recent full overlay clone.  Initialized at end of `open_with` /
1116    /// `open_at_with`; refreshed every `FOLD_EVERY_K` commits.
1117    /// `None` only between struct creation and the first fold.
1118    fold_overlay: Option<Arc<crate::reader::FrozenOverlay>>,
1119    /// Per-commit deltas accumulated since the last fold.
1120    delta_tail: Vec<Arc<crate::reader::CommitDelta>>,
1121    /// How many commits have occurred since the last fold.
1122    commits_since_fold: usize,
1123    /// When true, `log_then_apply_with` buffers event notifications instead of
1124    /// firing them immediately.  Used by the group-commit drain thread to defer
1125    /// events until after the group fsync (R2: durability before notification).
1126    /// Cleared to false once the drain thread flushes or discards the buffer.
1127    defer_events: bool,
1128    /// Buffered events accumulated while `defer_events` is true.
1129    deferred_events: Vec<DeferredEvent>,
1130    /// Set to true by the group-commit drain thread when a group fsync fails
1131    /// after WAL truncation.  All subsequent mutation attempts return an IO
1132    /// error until the database is reopened.
1133    degraded: bool,
1134    /// Set to `true` after `ensure_v8_base_sections_loaded` has read provenance,
1135    /// HNSW, and IVF sections from the mmap base into the engine's retained
1136    /// fields.  `false` on all opens until first use; always `true` for non-V8
1137    /// opens (base is None, fast-path sets flag immediately).
1138    v8_sections_loaded: std::sync::atomic::AtomicBool,
1139    /// Serializes the one-time section population in `ensure_v8_base_sections_loaded`.
1140    v8_sections_mutex: std::sync::Mutex<()>,
1141    /// Per-node last-change commit sequence.  `last_change[node_id] = seq` means
1142    /// the node was last modified by commit `seq`.
1143    ///
1144    /// Loaded from V8 section 11 at open; updated on every state-changing commit
1145    /// and WAL replay frame.  V5-V7 stores start with an empty map; pre-WAL-horizon
1146    /// nodes return `None` from `last_changed` until they are next mutated.
1147    ///
1148    /// See [`Precondition`] for the full touch definition.
1149    last_change: HashMap<u32, u64>,
1150    /// WAL archive retention policy set by [`set_wal_archive_retention`].
1151    /// `None` = unlimited (keep all archives); `Some(N)` = keep N newest archives,
1152    /// pruning older ones at snapshot time.  0 is treated as unlimited.
1153    wal_archive_retention: Option<u32>,
1154    /// Global frame index of the first commit that is still reachable through
1155    /// surviving archives.  Persisted to `wal.floor` sidecar when pruning occurs.
1156    /// Default 0 = all history reachable.
1157    wal_horizon_floor: u64,
1158    /// True when the surviving archive chain forms a continuous WAL history
1159    /// starting from the store's first commit (the genesis chain).
1160    ///
1161    /// `open_at` may replay archive-resident commits from empty state only when
1162    /// this flag is true AND `wal_horizon_floor == 0`.  Cleared whenever:
1163    ///   - a WAL-truncating snapshot (`keep_wal=false`) is taken after archives
1164    ///     already exist (breaks the chain for subsequent archives), or
1165    ///   - any archive is pruned (floor advances past zero).
1166    ///
1167    /// Persisted via the `wal.genesis` marker file; loaded from it at open.
1168    archive_genesis_chain: bool,
1169    /// Transient write-authz context set by `write_batch_authz` /
1170    /// `query_write_authz` for the duration of ONE mutation call.
1171    /// Always `None` at rest.  Never serialized, never WAL-replayed.
1172    pending_write_authz: Option<WriteAuthz>,
1173    /// Slow-query threshold in milliseconds.  0 = disabled.
1174    /// Seeded from `MUSHROOMDB_SLOW_QUERY_MS` at open; override via
1175    /// [`GraphDb::set_slow_query_threshold_ms`] (tests must use the setter
1176    /// — env vars are process-global and race parallel test threads).
1177    slow_query_threshold_ms: u64,
1178    /// Ring buffer of recent slow queries (interior-mutable so `query(&self)`
1179    /// can record entries without requiring `&mut self`).
1180    slow_queries: std::sync::Mutex<SlowQueryLog>,
1181    /// Instant at which the database was opened (used by `/metrics` uptime).
1182    started_at: std::time::Instant,
1183}
1184
1185/// One group of deferred event notifications, held until the group fsync
1186/// completes.  Replayed by [`GraphDb::flush_deferred_events`].
1187struct DeferredEvent {
1188    rec: core_storage::WalRecord,
1189    engine_deltas: Vec<EngineEdgeDelta>,
1190    seq: u64,
1191    ingest: Option<(String, usize)>,
1192}
1193
1194/// Options for [`GraphDb::open_with_options`].
1195#[derive(Clone, Copy, Debug)]
1196pub struct OpenOptions {
1197    /// Rewrite an old-format snapshot to the current VERSION after a
1198    /// successful load (default `true`). The old snapshot is kept as
1199    /// `snapshot.bin.bak` until the next clean open at the current version,
1200    /// at which point the `.bak` is deleted.
1201    ///
1202    /// Set to `false` to open a store without touching any on-disk files
1203    /// (useful for read-only inspection of a store at an older format).
1204    pub auto_migrate: bool,
1205}
1206
1207impl Default for OpenOptions {
1208    fn default() -> Self {
1209        Self { auto_migrate: true }
1210    }
1211}
1212
1213/// Authorization context carried by `write_batch_authz` / `query_write_authz`.
1214///
1215/// `None` at the call site = full authority (today's zero-cost behavior).
1216/// `Some(WriteAuthz)` = role-scoped: the decision table (plan §"authz decision
1217/// table") is evaluated per-op inside `commit_logged_batch` BEFORE any WAL
1218/// record is built.  A denial returns an error with no WAL frame written.
1219///
1220/// The mask is ALWAYS `Omit`-mode: role-token paths must never acknowledge
1221/// hidden-node existence to callers.
1222#[derive(Clone, Debug)]
1223pub struct WriteAuthz {
1224    pub role: String,
1225    pub scope: WriteScope,
1226    /// Resolved by `mask_for_role` under the same write guard as the mutation.
1227    /// Always `Omit`-mode — never `Stub`.
1228    pub mask: crate::mask::NodeMask,
1229}
1230
1231/// Write `bytes` to `snapshot.bin.bak` atomically with full fsync.
1232///
1233/// Uses [`RealFs::write_atomic`] which applies `F_FULLFSYNC` on macOS and
1234/// `sync_all` on other platforms, then renames the `.tmp` file into place and
1235/// syncs the directory entry. This is the only correct path for writing the
1236/// `.bak` — plain `std::fs::write + sync_all` misses both `F_FULLFSYNC` and
1237/// the directory sync.
1238pub fn write_snapshot_bak(dir: &std::path::Path, bytes: &[u8]) -> crate::Result<()> {
1239    use core_storage::fs::{FileId, Fs as _};
1240    RealFs::new(dir)
1241        .map_err(core_storage::GraphError::Io)?
1242        .write_atomic(FileId::SnapshotBak, bytes)
1243        .map_err(core_storage::GraphError::Io)
1244}
1245
1246/// Return the on-disk snapshot format version without decoding the full snapshot.
1247///
1248/// Reads only the 6-byte header (magic + version LE). Returns `None` when no
1249/// snapshot file exists (WAL-only store). Returns an error if the header is
1250/// malformed.
1251pub fn snapshot_version_at(dir: &std::path::Path) -> crate::Result<Option<u16>> {
1252    use std::io::Read as _;
1253    let path = dir.join("snapshot.bin");
1254    let mut header = [0u8; 6];
1255    let n = match std::fs::File::open(&path) {
1256        Ok(mut f) => f.read(&mut header).map_err(core_storage::GraphError::Io)?,
1257        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
1258        Err(e) => return Err(core_storage::GraphError::Io(e)),
1259    };
1260    core_storage::snapshot::peek_version(&header[..n])
1261}
1262
1263/// Options for [`GraphDb::snapshot_with`].
1264#[derive(Debug, Clone, Default)]
1265pub struct SnapshotOptions {
1266    /// When `true`, the WAL is preserved after the snapshot write.
1267    /// Pre-snapshot commits remain reachable via [`GraphDb::open_at`].
1268    /// When `false` (the default), the WAL is truncated to a minimal
1269    /// baseline so cold-start replay stays fast.
1270    pub keep_wal: bool,
1271    /// When `true`, the current WAL is renamed to `wal.<commit_seq>.archive`
1272    /// before a fresh WAL baseline is written (history-preserving snapshot).
1273    ///
1274    /// This is the feature opt-in: `false` (the default) leaves the existing
1275    /// truncation / keep-wal behaviour byte-identical.  `archive_wal` takes
1276    /// precedence over `keep_wal` when both are set.
1277    ///
1278    /// Archives can be scanned by [`GraphDb::node_history`],
1279    /// [`GraphDb::edge_history`], [`GraphDb::was_linked`], and
1280    /// [`GraphDb::open_at`], extending the reachable history horizon across
1281    /// snapshot boundaries.
1282    pub archive_wal: bool,
1283}
1284
1285/// Derive the scan-label sym for the commit-skip fast-path.
1286///
1287/// Walks `ops` to find the plan's leading scan op (`ScanLabel`, `IndexScan`,
1288/// or `IndexIntersect`) with a concrete label string, then interns it.
1289///
1290/// Returns `None` in all cases where skipping is unsafe:
1291/// - Any `Expand` op is present (edge traversal; edges change results regardless
1292///   of node labels).
1293/// - The leading scan has no label (`ScanLabel { label: None }` — full scan).
1294/// - No recognizable leading scan op is found.
1295///
1296/// This is the conservative v0.4.3 boundary. The caller stores the result in
1297/// [`QuerySubEntry::scan_label`] at subscribe time; `None` means always execute.
1298fn extract_scan_label(ops: &[PlanOp], syms: &mut Interner) -> Option<u32> {
1299    // Any Expand → must always re-execute (edges can change join results).
1300    if ops.iter().any(|op| matches!(op, PlanOp::Expand { .. })) {
1301        return None;
1302    }
1303    for op in ops {
1304        match op {
1305            PlanOp::ScanLabel {
1306                label: Some(label), ..
1307            } => return Some(syms.intern(label)),
1308            PlanOp::IndexScan {
1309                label: Some(label), ..
1310            } => return Some(syms.intern(label)),
1311            PlanOp::IndexIntersect {
1312                label: Some(label), ..
1313            } => return Some(syms.intern(label)),
1314            _ => {}
1315        }
1316    }
1317    None
1318}
1319
1320impl GraphDb<RealFs> {
1321    /// Open the database at `dir` with default options.
1322    ///
1323    /// Equivalent to `open_with_options(dir, OpenOptions::default())`.
1324    /// Old-format snapshots (V5, V6) are automatically migrated to the
1325    /// current version on a successful load (see [`OpenOptions::auto_migrate`]).
1326    pub fn open(dir: &std::path::Path) -> Result<Self> {
1327        Self::open_with_options(dir, OpenOptions::default())
1328    }
1329
1330    /// Open the database at `dir` with explicit options.
1331    ///
1332    /// When `opts.auto_migrate` is `true` (the default) and the on-disk
1333    /// snapshot is an older format version, this function:
1334    ///   1. Copies the current `snapshot.bin` to `snapshot.bin.bak` (atomic
1335    ///      + fsynced) before any modification.
1336    ///   2. Rewrites `snapshot.bin` at the current format version via
1337    ///      [`GraphDb::snapshot_with`] with `keep_wal: true` (WAL preserved).
1338    ///
1339    /// If migration fails the error is returned and the original files are
1340    /// intact (the `.bak` was written before the new snapshot was attempted).
1341    ///
1342    /// A clean open that finds the snapshot already at the current version
1343    /// deletes any leftover `.bak` file.
1344    ///
1345    /// WAL-only stores (no snapshot) are never auto-migrated on open.
1346    pub fn open_with_options(dir: &std::path::Path, opts: OpenOptions) -> Result<Self> {
1347        // Header-only peek — 6 bytes, no full decode.
1348        let snap_version = snapshot_version_at(dir)?;
1349
1350        // Full load: decode snapshot + replay WAL + rebuild indexes.
1351        let mut db = Self::open_with(RealFs::new(dir)?)?;
1352
1353        if opts.auto_migrate {
1354            match snap_version {
1355                Some(ver) if ver < core_storage::snapshot::VERSION => {
1356                    let _tm = std::time::Instant::now();
1357                    // Copy the original snapshot to .bak at OS level — no in-memory
1358                    // buffer required for a 2+ GiB file.
1359                    //
1360                    // Crash-safety: snapshot.bin remains intact (write_atomic inside
1361                    // snapshot_with uses a .tmp+rename) until the V8 write succeeds.
1362                    // A torn .bak on crash is acceptable because the original
1363                    // snapshot.bin is the authoritative source until after the rename.
1364                    std::fs::copy(dir.join("snapshot.bin"), dir.join("snapshot.bin.bak"))
1365                        .map_err(core_storage::GraphError::Io)?;
1366                    trace_migrate!("bak copy done", _tm);
1367                    // Rewrite snapshot at current version; keep WAL intact.
1368                    db.snapshot_with(SnapshotOptions {
1369                        keep_wal: true,
1370                        ..SnapshotOptions::default()
1371                    })?;
1372                    trace_migrate!("snapshot_with done", _tm);
1373                }
1374                Some(_) => {
1375                    // Already current version: remove any leftover .bak.
1376                    let bak = dir.join("snapshot.bin.bak");
1377                    if bak.exists() {
1378                        std::fs::remove_file(&bak).map_err(core_storage::GraphError::Io)?;
1379                    }
1380                }
1381                None => {
1382                    // WAL-only store — nothing to migrate on open.
1383                }
1384            }
1385        }
1386
1387        Ok(db)
1388    }
1389
1390    /// Open a read-only view of the database as it existed after `commit`.
1391    ///
1392    /// Commit indices are 0-based over the current WAL: commit 0 is the state
1393    /// after the first WAL frame, commit N-1 is the state after the N-th (most
1394    /// recent) frame.  Call [`GraphDb::open`] to read the full current state.
1395    ///
1396    /// **Replay base.** [`GraphDb::snapshot`] truncates the WAL when it runs,
1397    /// so as-of can only reach commits recorded in the current WAL (those
1398    /// written after the most recent snapshot, or all commits if no snapshot
1399    /// was ever taken).  Commit 0 in `open_at` always refers to the first
1400    /// frame in the WAL that exists on disk, not the first ever write to the
1401    /// database.  When the on-disk snapshot recorded that it truncated the
1402    /// WAL (V7, default `keep_wal: false`), it is loaded as the base state
1403    /// before frame replay, so the as-of view includes all pre-snapshot data.
1404    /// Snapshots written with `keep_wal: true` (and legacy V5/V6 snapshots)
1405    /// are ignored and replay is WAL-only, as before.
1406    ///
1407    /// **Read-only.** Every mutation method and `snapshot()` on the returned
1408    /// instance returns [`GraphError::ReadOnly`].  Queries, `explain()`, and
1409    /// `stats()` work normally.
1410    ///
1411    /// # Errors
1412    /// - [`GraphError::CommitOutOfRange`] if `commit >= wal_commit_count` (including
1413    ///   when the WAL is empty after a snapshot).
1414    pub fn open_at(dir: &std::path::Path, commit: u64) -> Result<Self> {
1415        Self::open_at_with(RealFs::new(dir)?, commit)
1416    }
1417
1418    /// Run a **read-only** Cypher query against the graph as it existed at
1419    /// `commit` — the "time-travel" / agent-replay query. Opens a temporal view
1420    /// of this store's directory at that commit and executes the read there.
1421    ///
1422    /// The current instance is unaffected. Write statements are rejected (the
1423    /// temporal view is read-only). `commit` is a 0-based WAL commit index;
1424    /// `commit == wal_commit_count` (or `open_at`'s range) yields the newest
1425    /// state. Prefer this over holding many historical instances open.
1426    ///
1427    /// # Errors
1428    /// - [`GraphError::CommitOutOfRange`] if `commit` is past the WAL horizon.
1429    /// - A query error for a malformed or write query.
1430    pub fn query_at(
1431        &self,
1432        commit: u64,
1433        cypher: &str,
1434        params: &std::collections::BTreeMap<String, Value>,
1435    ) -> Result<ResultSet> {
1436        let dir = self.fs.dir().to_path_buf();
1437        let temporal = Self::open_at(&dir, commit)?;
1438        if is_write_tokens(&lex(cypher).map_err(|e| GraphError::QueryError {
1439            detail: format!("lex: {e}"),
1440        })?) {
1441            return Err(GraphError::QueryError {
1442                detail: "query_at is read-only: write statements are not permitted in a \
1443                         time-travel query"
1444                    .into(),
1445            });
1446        }
1447        temporal.query(cypher, params)
1448    }
1449}
1450
1451impl<F: Fs> GraphDb<F> {
1452    pub fn open_with(fs: F) -> Result<Self> {
1453        let mut db = Self {
1454            fs,
1455            ids: IdMap::new(),
1456            syms: Interner::new(),
1457            topo: Topology::new(),
1458            props: ColumnStore::new(),
1459            labels: Vec::new(),
1460            edge_props: EdgeProps::new(),
1461            engine: RuleEngine::new(),
1462            view_store: ViewStore::new(),
1463            fulltext: FulltextIndex::new(),
1464            prop_index: PropertyIndex::new(),
1465            event_sink: None,
1466            fsync: FsyncPolicy::Strict,
1467            commit_seq: 0,
1468            roles: Some(vec![]),
1469            subscriptions: Vec::new(),
1470            query_subscriptions: Vec::new(),
1471            sub_capacity: DEFAULT_SUB_CAPACITY,
1472            read_only: false,
1473            total_wal_commits: 0,
1474            base: None,
1475            fold_overlay: None,
1476            delta_tail: Vec::new(),
1477            commits_since_fold: 0,
1478            defer_events: false,
1479            deferred_events: Vec::new(),
1480            degraded: false,
1481            v8_sections_loaded: std::sync::atomic::AtomicBool::new(false),
1482            v8_sections_mutex: std::sync::Mutex::new(()),
1483            last_change: HashMap::new(),
1484            wal_archive_retention: None,
1485            wal_horizon_floor: 0,
1486            archive_genesis_chain: false,
1487            pending_write_authz: None,
1488            slow_query_threshold_ms: std::env::var("MUSHROOMDB_SLOW_QUERY_MS")
1489                .ok()
1490                .and_then(|v| v.parse().ok())
1491                .unwrap_or(100),
1492            slow_queries: std::sync::Mutex::new(SlowQueryLog {
1493                entries: std::collections::VecDeque::new(),
1494                total: 0,
1495            }),
1496            started_at: std::time::Instant::now(),
1497        };
1498        db.wal_horizon_floor = db.fs.read_horizon_floor()?;
1499        db.archive_genesis_chain = db.fs.has_genesis_marker();
1500        // Opening cleanup: remove orphaned archives — archives whose frames all
1501        // fall below the horizon floor.  Orphans arise when a crash interrupted
1502        // the retention-prune sequence after the floor was written but before
1503        // all surplus archives were deleted.  Safe to delete: floor already
1504        // accounts for their frames.
1505        db.cleanup_orphaned_archives()?;
1506        let _t0 = std::time::Instant::now();
1507        // Peek 6 bytes to determine snapshot version without reading the full
1508        // file. For RealFs this is a true partial read (O(1)); for SimFs the
1509        // default impl reads all bytes and truncates (still correct).
1510        let snap_header = db.fs.read_prefix(FileId::Snapshot, 6)?;
1511        let is_v8 = snap_header.len() >= 6
1512            && &snap_header[0..4] == b"GDB1"
1513            && u16::from_le_bytes([snap_header[4], snap_header[5]])
1514                == core_storage::snapshot::VERSION_8;
1515        if is_v8 {
1516            // V8: map the file zero-copy (RealFs) or read full bytes (SimFs).
1517            // No 2.4GB heap Vec is allocated on RealFs.
1518            let mapped = Arc::new(
1519                if let Some(snap_path) = db.fs.snapshot_path() {
1520                    core_storage::v8::MappedBase::map(&snap_path)
1521                } else {
1522                    let snap_bytes = db.fs.read(FileId::Snapshot)?;
1523                    core_storage::v8::MappedBase::from_bytes(snap_bytes)
1524                }
1525                .map_err(|e| GraphError::Corrupt {
1526                    detail: format!("v8: mmap open: {e:?}"),
1527                })?,
1528            );
1529            db.restore_v8_base(Arc::clone(&mapped))?;
1530            trace_open!("restore_v8_base", _t0);
1531            db.base = Some(mapped);
1532            trace_open!("base assigned", _t0);
1533        } else if !snap_header.is_empty() {
1534            // Legacy V5-V7: full read required for decode.
1535            let snap_bytes = db.fs.read(FileId::Snapshot)?;
1536            if let Some(state) = core_storage::snapshot::decode(&snap_bytes)? {
1537                db.restore_snapshot_state(state)?;
1538            }
1539        }
1540        // else: snap_header is empty = no snapshot file, fresh store.
1541        //
1542        // Seed commit_seq from the highest seq persisted in last_change so that
1543        // WAL-replay frames (which start at commit_seq+1) always exceed any seq
1544        // already stored in the snapshot.  Without this, a db with one snapshot
1545        // commit would save last_change["a"]=1, then on reopen the first WAL
1546        // frame would replay at seq=1 again — colliding and making WAL-tail
1547        // mutations indistinguishable from the snapshot baseline.
1548        //
1549        // Safety invariant (seq-recycling):
1550        //   Recycled seqs (those below the seeded baseline) were NEVER stored in
1551        //   last_change because they belonged to a previous db lifetime — a new
1552        //   db starts at commit_seq=0 with an empty last_change.  Therefore no
1553        //   CAS precondition can carry a recycled seq as its `expected` value
1554        //   and accidentally match a live node's last_change entry.
1555        //
1556        // `expected:0` on a deleted-then-reinserted node:
1557        //   After deletion, last_changed() returns None; callers that call
1558        //   last_changed() and then use NodeUnchangedSince get None.unwrap_or(0)
1559        //   = 0.  The reinserted node gets seq > 0, so a subsequent CAS with
1560        //   expected=0 correctly conflicts.  The only way to observe actual=0 in
1561        //   a CasConflict would be a caller that invented expected=0 without ever
1562        //   calling last_changed() — unreachable via the documented API contract.
1563        if let Some(&max_seq) = db.last_change.values().max() {
1564            db.commit_seq = db.commit_seq.max(max_seq);
1565        }
1566        let bytes = db.fs.read(FileId::Wal)?;
1567        let (records, valid_len) = decode_all(&bytes);
1568        if valid_len < bytes.len() {
1569            db.fs.write_atomic(FileId::Wal, &bytes[..valid_len])?;
1570        }
1571        // WAL-present path: build indexes eagerly BEFORE replay so that the
1572        // first replayed record does not trigger the lazy-init guard (which
1573        // would call reindex_all_load_ivf on an empty graph, defeating the
1574        // point of restoring IVF/HNSW blobs from the snapshot).
1575        if !records.is_empty() {
1576            db.ensure_v8_base_sections_loaded();
1577            trace_open!("lazy sections loaded (WAL path)", _t0);
1578            db.engine.consume_retained_state_eager(
1579                &db.ids,
1580                &db.syms,
1581                &db.labels,
1582                build_props_view(&db.props, &db.base),
1583            );
1584        }
1585        for rec in records {
1586            db.apply(&rec)?;
1587            // Drain per-frame to keep pending_deltas O(1) during replay (I-2).
1588            // No subscriber exists yet; discard is correct.
1589            let _ = db.engine.drain_deltas();
1590            // Track commit_seq during replay so last_change entries are
1591            // consistent with the seqs assigned by log_then_apply_with on
1592            // subsequent live commits.  After N replayed frames, commit_seq=N;
1593            // live commits begin at N+1.
1594            db.commit_seq += 1;
1595            let replay_seq = db.commit_seq;
1596            db.update_last_change_from_rec(&rec, replay_seq);
1597        }
1598        // Enforce I-2: if the per-frame drain above is ever removed or skipped,
1599        // this assert catches the regression in debug builds immediately.
1600        debug_assert_eq!(
1601            db.engine.pending_delta_count(),
1602            0,
1603            "pending_deltas non-empty after replay — \
1604             per-frame drain must run inside the loop to keep memory O(1)"
1605        );
1606        // T2 note: the per-frame drain IS the suppression seam for replay.
1607        // Any future as-of replay path (Plan-15 T2) must drain here to feed
1608        // replaying subscribers; the mechanism is already in place.
1609        let _ = db.engine.drain_deltas(); // belt-and-braces no-op after loop drain
1610        trace_open!("wal replay done", _t0);
1611        // Rebuild view values after WAL replay only when there is no V8 base.
1612        // With a V8 base, view values are correct in the snapshot and are updated
1613        // incrementally during WAL replay (on_edge_changed / on_prop_changed).
1614        // A full rebuild would read overlay-only props (empty after restore_v8_base)
1615        // and overwrite correct base values with wrong results (e.g. NeighborAgg
1616        // Sum reads no "score" in overlay → writes 0.0, shadowing the correct
1617        // base value).
1618        if db.base.is_none() {
1619            let topo_view = TopologyView::owned(&db.topo);
1620            db.view_store
1621                .rebuild_all(&mut db.props, &topo_view, &db.ids, &db.syms, &db.labels);
1622        }
1623        // Rebuild full-text index after WAL replay.  Corrects drift from
1624        // per-record incremental apply during replay.
1625        db.fulltext.rebuild_all(
1626            &db.ids,
1627            &db.labels,
1628            &db.syms,
1629            build_props_view(&db.props, &db.base),
1630        );
1631        db.prop_index.rebuild_all(
1632            &db.ids,
1633            &db.labels,
1634            &db.syms,
1635            build_props_view(&db.props, &db.base),
1636        );
1637        // Load roles sidecar. Missing file = no roles (Some(vec![])).
1638        // Corrupt/unparseable = poisoned (None); mask_for_role will fail-loud.
1639        db.roles = Self::load_roles_from_fs(&db.fs)?;
1640        // Capture the initial MVCC fold so reader() is ready immediately.
1641        db.fold_now();
1642        trace_open!("open_with complete", _t0);
1643        Ok(db)
1644    }
1645
1646    /// As-of replay for [`GraphDb::open_at`]: snapshot base (only when the
1647    /// snapshot truncated the WAL) plus the first `commit + 1` WAL frames;
1648    /// see [`GraphDb::open_at`] for the semantics.  The per-frame drain
1649    /// mirrors `open_with` exactly so pending_delta_count is 0 on exit.
1650    /// Restore all persisted state from a decoded snapshot. Shared by
1651    /// `open_with` and (when the snapshot truncated the WAL) `open_at_with`.
1652    fn restore_snapshot_state(
1653        &mut self,
1654        state: core_storage::snapshot::SnapshotState,
1655    ) -> Result<()> {
1656        self.ids = state.ids;
1657        self.syms = state.syms;
1658        self.topo = state.topo;
1659        self.props = state.props;
1660        self.labels = state.labels;
1661        self.edge_props = state.edge_props;
1662        // Cross-section label integrity for V5/V7 snapshots: same invariants as
1663        // restore_v8_base.  A crafted bincode snapshot with a short `labels` vec,
1664        // out-of-range sym ids, or a sentinel label on a live node would otherwise
1665        // open successfully and panic later in `NodeRef::label()` or
1666        // `neighborhood_masked()`.  Catching it here turns those into typed
1667        // `GraphError::Corrupt` at open time.
1668        {
1669            let ids_len = self.ids.len();
1670            if self.labels.len() != ids_len {
1671                return Err(GraphError::Corrupt {
1672                    detail: format!(
1673                        "snapshot: labels vec has {} entries but id table has {} total slots",
1674                        self.labels.len(),
1675                        ids_len,
1676                    ),
1677                });
1678            }
1679            let syms_len = self.syms.len() as u32;
1680            for (i, &sym) in self.labels.iter().enumerate() {
1681                let is_tombstoned = self.ids.is_tombstoned(i as u32);
1682                if sym == u32::MAX {
1683                    if !is_tombstoned {
1684                        return Err(GraphError::Corrupt {
1685                            detail: format!(
1686                                "snapshot: live node at id slot {i} has sentinel label (u32::MAX)"
1687                            ),
1688                        });
1689                    }
1690                } else if sym >= syms_len {
1691                    return Err(GraphError::Corrupt {
1692                        detail: format!(
1693                            "snapshot: label at id slot {i} references sym {sym} \
1694                             which is out of interner range ({syms_len})"
1695                        ),
1696                    });
1697                }
1698            }
1699        }
1700        let defs: Vec<RuleDef> = state
1701            .rule_defs
1702            .iter()
1703            .map(|b| {
1704                decode_rule_def(b).map_err(|e| GraphError::Corrupt {
1705                    detail: format!("snapshot rule_def deserialize: {e}"),
1706                })
1707            })
1708            .collect::<Result<Vec<_>>>()?;
1709        self.engine =
1710            RuleEngine::from_persist(defs, state.provenance, state.rule_tripped, state.rule_fires);
1711        // Candidate indexes are rebuilt lazily on the first mutation (see
1712        // RuleEngine::on_node_changed).  HNSW blobs and IVF centroids from the
1713        // snapshot are retained without deserializing so that:
1714        //   - clean-open (empty WAL): indexes stay empty; blobs load on first
1715        //     ANN query via ensure_hnsw_loaded, or on first mutation via the
1716        //     lazy-init guard which calls reindex_all_load_ivf + load_hnsw_state.
1717        //   - WAL-present: open_with calls consume_retained_state_eager before
1718        //     replay so HNSW/IVF are live before any record fires the hooks.
1719        let ivf_bytes = if state.ivf_state.is_empty() {
1720            Vec::new()
1721        } else {
1722            bincode::serialize(&state.ivf_state).expect("IVF state serialize cannot fail")
1723        };
1724        // Store blobs without eagerly deserializing them.
1725        self.engine
1726            .store_snapshot_state(state.hnsw_state, ivf_bytes);
1727        // Restore view defs from snapshot (V5).
1728        // The ColumnStore already contains view values from the snapshot;
1729        // use restore_view (no collision check, no backfill) so the store
1730        // is aware of the definitions.  rebuild_all runs after WAL replay.
1731        for def_bytes in &state.view_defs {
1732            let def: ViewDef =
1733                bincode::deserialize(def_bytes).map_err(|e| GraphError::Corrupt {
1734                    detail: format!("snapshot view_def deserialize: {e}"),
1735                })?;
1736            self.view_store
1737                .restore_view(def)
1738                .map_err(|e| GraphError::Corrupt {
1739                    detail: format!("snapshot view restore: {e}"),
1740                })?;
1741        }
1742        Ok(())
1743    }
1744
1745    /// Restore all persisted state from a V8 `MappedBase` snapshot, **except**
1746    /// topology (`self.topo` stays empty and serves as the WAL-replay overlay).
1747    ///
1748    /// `self.props` IS fully materialised from the base so that HNSW/IVF blob
1749    /// deserialization and view rebuild have access to all column data.
1750    fn restore_v8_base(&mut self, mapped: Arc<core_storage::v8::MappedBase>) -> Result<()> {
1751        self.ids = archived_to_idmap(mapped.ids().map_err(|e| GraphError::Corrupt {
1752            detail: format!("v8: ids section: {e:?}"),
1753        })?);
1754        self.syms = archived_to_interner(mapped.syms().map_err(|e| GraphError::Corrupt {
1755            detail: format!("v8: syms section: {e:?}"),
1756        })?);
1757
1758        // C1: self.props is left as an empty overlay. Column reads go through
1759        // props_view() (ColumnsView::with_base), which consults the archived base
1760        // section zero-copy. This avoids the O(columns) heap copy at every open.
1761
1762        // self.topo deliberately left as Topology::new() — overlay path.
1763
1764        let meta = decode_meta(mapped.meta_bytes().map_err(|e| GraphError::Corrupt {
1765            detail: format!("v8: meta section: {e:?}"),
1766        })?)
1767        .map_err(|e| GraphError::Corrupt {
1768            detail: format!("v8: meta decode: {e:?}"),
1769        })?;
1770        self.labels = meta.labels;
1771        // Cross-section label integrity: labels must cover every id slot (live
1772        // and tombstoned), every non-sentinel sym must be within the interner's
1773        // bound, and no live (non-tombstoned) node may carry the u32::MAX
1774        // sentinel label.  Without this check, a crafted snapshot where the META
1775        // section (small, CRC-validated) holds a short `labels` vec, out-of-range
1776        // sym ids, or a sentinel label on a live node, would open successfully
1777        // and then panic in `NodeRef::label()`, `neighborhood_masked()`, and
1778        // related read paths.  Catching the inconsistency here converts those
1779        // panics into typed `GraphError::Corrupt` at open time.
1780        {
1781            let ids_len = self.ids.len();
1782            if self.labels.len() != ids_len {
1783                return Err(GraphError::Corrupt {
1784                    detail: format!(
1785                        "v8: labels section has {} entries but id table has {} total slots",
1786                        self.labels.len(),
1787                        ids_len,
1788                    ),
1789                });
1790            }
1791            let syms_len = self.syms.len() as u32;
1792            for (i, &sym) in self.labels.iter().enumerate() {
1793                let is_tombstoned = self.ids.is_tombstoned(i as u32);
1794                if sym == u32::MAX {
1795                    // Sentinel is only valid for tombstoned slots.
1796                    if !is_tombstoned {
1797                        return Err(GraphError::Corrupt {
1798                            detail: format!(
1799                                "v8: live node at id slot {i} has sentinel label (u32::MAX)"
1800                            ),
1801                        });
1802                    }
1803                } else if sym >= syms_len {
1804                    return Err(GraphError::Corrupt {
1805                        detail: format!(
1806                            "v8: label at id slot {i} references sym {sym} \
1807                             which is out of interner range ({syms_len})"
1808                        ),
1809                    });
1810                }
1811            }
1812        }
1813        // C3: self.edge_props stays as an empty overlay.  Reads go through
1814        // edge_props_view() which consults the mmap'd base section zero-copy
1815        // via EdgePropsView::with_base.  No heap decode at open time.
1816
1817        // Restore rule engine.
1818        let (rule_def_bytes, rule_tripped, rule_fires) =
1819            archived_rules_meta_to_owned(mapped.rules_meta_section().map_err(|e| {
1820                GraphError::Corrupt {
1821                    detail: format!("v8: rules_meta section: {e:?}"),
1822                }
1823            })?);
1824        let defs: Vec<RuleDef> = rule_def_bytes
1825            .iter()
1826            .map(|b| {
1827                decode_rule_def(b).map_err(|e| GraphError::Corrupt {
1828                    detail: format!("v8: rule_def deserialize: {e}"),
1829                })
1830            })
1831            .collect::<Result<Vec<_>>>()?;
1832        self.engine = RuleEngine::from_persist(defs, BTreeMap::new(), rule_tripped, rule_fires);
1833        // C4+C5: provenance, HNSW, and IVF sections are NOT read here.
1834        // `ensure_v8_base_sections_loaded` reads them on first use from
1835        // `self.base` (set by the caller immediately after this returns).
1836        // A clean open touches only: header + IDS + SYMS + META + RULES_META.
1837
1838        // Restore view definitions.
1839        let view_defs =
1840            archived_views_to_owned(mapped.views_section().map_err(|e| GraphError::Corrupt {
1841                detail: format!("v8: views section: {e:?}"),
1842            })?);
1843        for def_bytes in &view_defs {
1844            let def: ViewDef =
1845                bincode::deserialize(def_bytes).map_err(|e| GraphError::Corrupt {
1846                    detail: format!("v8: view_def deserialize: {e}"),
1847                })?;
1848            self.view_store
1849                .restore_view(def)
1850                .map_err(|e| GraphError::Corrupt {
1851                    detail: format!("v8: view restore: {e}"),
1852                })?;
1853        }
1854        // Load the last-change map from section 11 (small section; load eagerly).
1855        // Pre-Task-3 snapshots lack this section; `last_change_bytes` returns &[]
1856        // in that case and `decode_last_change_bytes` returns an empty map.
1857        let last_change_raw = mapped
1858            .last_change_bytes()
1859            .map_err(|e| GraphError::Corrupt {
1860                detail: format!("v8: last_change section: {e:?}"),
1861            })?;
1862        self.last_change = decode_last_change_bytes(last_change_raw);
1863
1864        // Validate that all deferred sections (provenance, HNSW, IVF) fit within
1865        // the file.  Pure bounds check — no bytes read, no page faults triggered.
1866        // Catches truncated snapshots at open time before the lazy deferred reads.
1867        mapped.validate_section_bounds().map_err(|e| match e {
1868            GraphError::Corrupt { detail } => GraphError::Corrupt {
1869                detail: format!("v8: section bounds: {detail}"),
1870            },
1871            other => other,
1872        })?;
1873        Ok(())
1874    }
1875
1876    /// Read provenance, HNSW, and IVF sections from the mmap base into the
1877    /// engine's retained fields on first call.  Subsequent calls are a no-op
1878    /// (AtomicBool fast-path).
1879    ///
1880    /// Must be called before any code path that reads or mutates engine
1881    /// provenance, HNSW, or IVF state:
1882    /// - WAL replay (before `consume_retained_state_eager`)
1883    /// - First mutation (`log_then_apply_with`)
1884    /// - Read-only paths (`stats`, `explain`, `node_edges`)
1885    /// - Snapshot (`snapshot_with`)
1886    ///
1887    /// No-op for fresh stores and V5-V7 opens (`self.base` is `None`).
1888    fn ensure_v8_base_sections_loaded(&self) {
1889        use std::sync::atomic::Ordering;
1890        if self.v8_sections_loaded.load(Ordering::Acquire) {
1891            return;
1892        }
1893        let _guard = self
1894            .v8_sections_mutex
1895            .lock()
1896            .expect("v8 sections mutex poisoned");
1897        if self.v8_sections_loaded.load(Ordering::Acquire) {
1898            return; // another caller populated while we waited
1899        }
1900        let _t = std::time::Instant::now();
1901        if let Some(base) = &self.base {
1902            // Provenance: raw rkyv bytes; CRC validated inside section_bytes.
1903            // Bounds are already validated at open time (restore_v8_base →
1904            // validate_section_bounds) — unreachable post-validate_section_bounds;
1905            // unwrap_or_default is a safety belt against impossible errors.
1906            let prov_bytes = base
1907                .provenance_raw_bytes()
1908                .map(|b| b.to_vec())
1909                .unwrap_or_default();
1910            self.engine.store_provenance_bytes(prov_bytes);
1911            // HNSW: decode rkyv blobs into owned map.
1912            let hnsw_state = base
1913                .hnsw_section()
1914                .map(archived_hnsw_to_owned)
1915                .unwrap_or_default();
1916            // IVF: raw bincode bytes; deserialized on first mutation/query.
1917            let ivf_bytes = base.ivf_bytes().map(|b| b.to_vec()).unwrap_or_default();
1918            self.engine.store_snapshot_state(hnsw_state, ivf_bytes);
1919        }
1920        self.v8_sections_loaded.store(true, Ordering::Release);
1921        if std::env::var("MUSHROOMDB_TRACE_OPEN").is_ok() {
1922            eprintln!(
1923                "[MUSHROOMDB_TRACE_OPEN] ensure_v8_base_sections_loaded: {:>9.3?}",
1924                _t.elapsed()
1925            );
1926        }
1927    }
1928
1929    /// Return a `TopologyView` that merges the mmap'd base (when present) with
1930    /// the in-memory WAL overlay.  Used by all read paths in db.rs that need
1931    /// the full merged topology without going through `self.view()`.
1932    fn topo_view(&self) -> TopologyView<'_> {
1933        match self.base {
1934            None => TopologyView::owned(&self.topo),
1935            Some(ref base) => {
1936                // SAFETY: base lives as long as self; section bounds validated at open.
1937                // topology() uses access_unchecked; all field reads are bounds-checked in seam.rs.
1938                let archived = base
1939                    .topology()
1940                    .expect("base topology section bounds validated at open");
1941                TopologyView::with_base(&self.topo, archived)
1942            }
1943        }
1944    }
1945
1946    /// Return a `ColumnsView` that merges the mmap'd base columns (when a V8
1947    /// snapshot is open) with the in-memory WAL overlay.  Reads consult the
1948    /// overlay first, then fall through to the archived base section zero-copy.
1949    fn props_view(&self) -> core_storage::v8::seam::ColumnsView<'_> {
1950        match self.base {
1951            None => core_storage::v8::seam::ColumnsView::owned(&self.props),
1952            Some(ref base) => {
1953                // columns() uses access_unchecked; field reads are bounds-checked in seam.rs.
1954                let archived = base
1955                    .columns()
1956                    .expect("base columns section bounds validated at open");
1957                core_storage::v8::seam::ColumnsView::with_base(&self.props, archived)
1958            }
1959        }
1960    }
1961
1962    /// Return an `EdgePropsView` that merges the mmap'd base edge-props section
1963    /// (when a V8 snapshot is open) with the in-memory WAL overlay.
1964    ///
1965    /// Reads consult the overlay first (for post-snapshot mutations), then fall
1966    /// through to the archived base section zero-copy.  Tombstones in the
1967    /// overlay mask deleted-from-base entries.
1968    fn edge_props_view(&self) -> EdgePropsView<'_> {
1969        match self.base {
1970            None => EdgePropsView::owned(&self.edge_props),
1971            Some(ref base) => {
1972                // edge_props_section() uses access_unchecked; field reads bounds-checked in seam.rs.
1973                let archived = base
1974                    .edge_props_section()
1975                    .expect("base edge_props section bounds validated at open");
1976                EdgePropsView::with_base(&self.edge_props, archived)
1977            }
1978        }
1979    }
1980
1981    fn open_at_with(fs: F, commit: u64) -> Result<Self> {
1982        let mut db = Self {
1983            fs,
1984            ids: IdMap::new(),
1985            syms: Interner::new(),
1986            topo: Topology::new(),
1987            props: ColumnStore::new(),
1988            labels: Vec::new(),
1989            edge_props: EdgeProps::new(),
1990            engine: RuleEngine::new(),
1991            view_store: ViewStore::new(),
1992            fulltext: FulltextIndex::new(),
1993            prop_index: PropertyIndex::new(),
1994            event_sink: None,
1995            fsync: FsyncPolicy::Strict,
1996            commit_seq: 0,
1997            roles: Some(vec![]),
1998            subscriptions: Vec::new(),
1999            query_subscriptions: Vec::new(),
2000            sub_capacity: DEFAULT_SUB_CAPACITY,
2001            read_only: false, // set to true after replay
2002            total_wal_commits: 0,
2003            base: None,
2004            fold_overlay: None,
2005            delta_tail: Vec::new(),
2006            commits_since_fold: 0,
2007            defer_events: false,
2008            deferred_events: Vec::new(),
2009            degraded: false,
2010            v8_sections_loaded: std::sync::atomic::AtomicBool::new(false),
2011            v8_sections_mutex: std::sync::Mutex::new(()),
2012            last_change: HashMap::new(),
2013            wal_archive_retention: None,
2014            wal_horizon_floor: 0,
2015            archive_genesis_chain: false,
2016            pending_write_authz: None,
2017            slow_query_threshold_ms: std::env::var("MUSHROOMDB_SLOW_QUERY_MS")
2018                .ok()
2019                .and_then(|v| v.parse().ok())
2020                .unwrap_or(100),
2021            slow_queries: std::sync::Mutex::new(SlowQueryLog {
2022                entries: std::collections::VecDeque::new(),
2023                total: 0,
2024            }),
2025            started_at: std::time::Instant::now(),
2026        };
2027        db.wal_horizon_floor = db.fs.read_horizon_floor()?;
2028        db.archive_genesis_chain = db.fs.has_genesis_marker();
2029        // Same orphaned-archive cleanup as open_with: floor was written first
2030        // during pruning, so a crash may have left stale archives below floor.
2031        db.cleanup_orphaned_archives()?;
2032        // Collect archive frames (oldest-first) and live WAL frames.
2033        // Archives represent pre-snapshot history; the snapshot captures the
2034        // cumulative state at the time of archiving.  Crash-window guarantee:
2035        //   A: crash before rename → WAL intact, no archive. Reopen: normal.
2036        //   B: crash after rename, before new WAL → archive present, WAL
2037        //      absent. Reopen: snapshot loaded (full state), no WAL replay.
2038        //   C: crash after new baseline WAL written → normal post-archive.
2039        let archive_ns = db.fs.list_archives()?;
2040        let mut archive_frames_all: Vec<WalRecord> = Vec::new();
2041        for n in &archive_ns {
2042            let arc_bytes = db.fs.read_archive(*n)?;
2043            let (arc_frames, _) = decode_all(&arc_bytes);
2044            archive_frames_all.extend(arc_frames);
2045        }
2046        let total_archive_frames = archive_frames_all.len() as u64;
2047
2048        let live_bytes = db.fs.read(FileId::Wal)?;
2049        let (live_records, _valid_len) = decode_all(&live_bytes);
2050        let total_surviving = total_archive_frames + live_records.len() as u64;
2051        // Global total including any pruned history below the horizon floor.
2052        let total = db.wal_horizon_floor + total_surviving;
2053
2054        // Horizon and range check.
2055        if commit < db.wal_horizon_floor {
2056            return Err(GraphError::CommitOutOfRange { commit, total });
2057        }
2058        if commit >= total {
2059            return Err(GraphError::CommitOutOfRange { commit, total });
2060        }
2061
2062        // Local index into surviving frames (0 = first frame of oldest archive).
2063        let local = commit - db.wal_horizon_floor;
2064
2065        if local < total_archive_frames {
2066            // Target commit is in an archive.  Correct replay from empty state
2067            // is only possible when the archive chain is an uninterrupted
2068            // genesis chain (first archive taken from a fresh store, no prior
2069            // WAL truncation) and no archives have been pruned (floor == 0).
2070            //
2071            // If either condition is violated the prefix needed to reconstruct
2072            // the requested state is gone; refuse rather than return wrong data.
2073            if db.wal_horizon_floor > 0 || !db.archive_genesis_chain {
2074                return Err(GraphError::CommitOutOfRange { commit, total });
2075            }
2076            // Replay all archive frames up to and including the target commit
2077            // from an empty database state.  Archives must be replayed in order
2078            // so that dense-id intern tables are built up correctly.
2079            for rec in archive_frames_all.into_iter().take((local + 1) as usize) {
2080                db.apply(&rec)?;
2081                let _ = db.engine.drain_deltas();
2082            }
2083        } else {
2084            // Target commit is in the live WAL: load snapshot as base, then
2085            // replay the needed live WAL prefix.
2086            //
2087            // Base state: a truncating snapshot (wal_truncated=true) compacts
2088            // all pre-truncation / pre-archive commits.  Dense-id records in
2089            // the live WAL reference ids/interns that the snapshot provides.
2090            // Peek 6 bytes (same pattern as open_with).
2091            let snap_header = db.fs.read_prefix(FileId::Snapshot, 6)?;
2092            let is_v8 = snap_header.len() >= 6
2093                && &snap_header[0..4] == b"GDB1"
2094                && u16::from_le_bytes([snap_header[4], snap_header[5]])
2095                    == core_storage::snapshot::VERSION_8;
2096            if is_v8 {
2097                let state = if let Some(snap_path) = db.fs.snapshot_path() {
2098                    let mapped = core_storage::v8::MappedBase::map(&snap_path).map_err(|e| {
2099                        GraphError::Corrupt {
2100                            detail: format!("v8: open_at mmap: {e:?}"),
2101                        }
2102                    })?;
2103                    core_storage::snapshot::decode_v8_from_mapped(&mapped)?
2104                } else {
2105                    let snap_bytes = db.fs.read(FileId::Snapshot)?;
2106                    core_storage::snapshot::decode(&snap_bytes)?
2107                };
2108                if let Some(state) = state {
2109                    if state.wal_truncated {
2110                        db.restore_snapshot_state(state)?;
2111                    }
2112                }
2113            } else if !snap_header.is_empty() {
2114                let snap_bytes = db.fs.read(FileId::Snapshot)?;
2115                if let Some(state) = core_storage::snapshot::decode(&snap_bytes)? {
2116                    if state.wal_truncated {
2117                        db.restore_snapshot_state(state)?;
2118                    }
2119                }
2120            }
2121            // else: snap_header empty = no snapshot file.
2122            let live_local = local - total_archive_frames;
2123            for rec in live_records.into_iter().take((live_local + 1) as usize) {
2124                db.apply(&rec)?;
2125                let _ = db.engine.drain_deltas();
2126            }
2127        }
2128        // Pin: pending_delta_count must be 0 after as-of replay, mirroring T1's
2129        // post-loop assert in open_with.
2130        debug_assert_eq!(
2131            db.engine.pending_delta_count(),
2132            0,
2133            "pending_deltas non-empty after open_at replay — \
2134             per-frame drain must run inside the loop to keep memory O(1)"
2135        );
2136        let _ = db.engine.drain_deltas(); // belt-and-braces no-op
2137                                          // Rebuild view values after WAL replay so derived-edge-driven views
2138                                          // reflect the as-of state.  open_at always uses the legacy path (no V8
2139                                          // base), so topo_view is always owned.
2140        {
2141            let topo_view = TopologyView::owned(&db.topo);
2142            db.view_store
2143                .rebuild_all(&mut db.props, &topo_view, &db.ids, &db.syms, &db.labels);
2144        }
2145        // Rebuild full-text index for as-of view (mirrors open_with pattern).
2146        db.fulltext.rebuild_all(
2147            &db.ids,
2148            &db.labels,
2149            &db.syms,
2150            build_props_view(&db.props, &db.base),
2151        );
2152        db.prop_index.rebuild_all(
2153            &db.ids,
2154            &db.labels,
2155            &db.syms,
2156            build_props_view(&db.props, &db.base),
2157        );
2158        // Load roles sidecar (current roles, not point-in-time).
2159        db.roles = Self::load_roles_from_fs(&db.fs)?;
2160        db.read_only = true;
2161        db.total_wal_commits = total;
2162        // Capture initial fold so reader() is immediately usable.
2163        db.fold_now();
2164        Ok(db)
2165    }
2166
2167    /// Whether this instance is a read-only as-of view.
2168    pub fn is_read_only(&self) -> bool {
2169        self.read_only
2170    }
2171
2172    // ── MVCC epoch reader ─────────────────────────────────────────────────────
2173
2174    /// Clone the current overlay state into a new `FrozenOverlay` and reset
2175    /// the delta tail. Called automatically every `FOLD_EVERY_K` commits and at
2176    /// the end of `open_with` / `open_at_with` to prime the reader.
2177    fn fold_now(&mut self) {
2178        let frozen = crate::reader::FrozenOverlay {
2179            ids: self.ids.clone(),
2180            syms: self.syms.clone(),
2181            topo: self.topo.clone(),
2182            props: self.props.clone(),
2183            labels: self.labels.clone(),
2184            edge_props: self.edge_props.clone(),
2185            roles: self.roles.clone(),
2186            fulltext: self.fulltext.clone(),
2187        };
2188        self.fold_overlay = Some(Arc::new(frozen));
2189        self.delta_tail.clear();
2190        self.commits_since_fold = 0;
2191    }
2192
2193    /// Capture a lock-free reader snapshot of the current db state.
2194    ///
2195    /// The read lock is held only for the duration of this call (to clone a
2196    /// handful of `Arc` handles). Subsequent query operations run without any
2197    /// lock.
2198    pub fn reader(&self) -> crate::reader::ReaderSnapshot {
2199        crate::reader::ReaderSnapshot::new(
2200            self.fold_overlay
2201                .clone()
2202                .expect("fold_overlay is always Some after open_with; call reader() after open"),
2203            self.base.clone(),
2204            self.delta_tail.clone(),
2205        )
2206    }
2207
2208    /// Total number of WAL commits at the time [`open_at`] was called.
2209    /// Returns 0 for normal (non-as-of) instances.
2210    pub fn total_wal_commits(&self) -> u64 {
2211        self.total_wal_commits
2212    }
2213
2214    /// Apply a record to in-memory state. Used by both live writes and replay,
2215    /// so replay is definitionally identical to the original execution.
2216    fn apply(&mut self, rec: &WalRecord) -> Result<()> {
2217        match rec {
2218            WalRecord::InsertNode { label, key, props } => {
2219                let id = self.ids.try_insert(key)?;
2220                let sym = self.syms.intern(label);
2221                if self.labels.len() <= id as usize {
2222                    // gap slots are sentinels, never valid label symbols
2223                    self.labels.resize(id as usize + 1, u32::MAX);
2224                }
2225                self.labels[id as usize] = sym;
2226                for (field, value) in props {
2227                    self.props.set(id, field, value.clone());
2228                }
2229                // Initialize view values for the new node before the engine runs so
2230                // delta-based increments start from a known zero baseline.
2231                self.view_store
2232                    .init_node_views(id, &mut self.props, &self.syms, &self.labels);
2233                // Fire rules for the newly inserted node.
2234                let cursor = self.engine.pending_delta_count();
2235                let mut eng = std::mem::take(&mut self.engine);
2236                {
2237                    let mut gm = make_graph_mut(
2238                        &self.ids,
2239                        &mut self.syms,
2240                        &self.labels,
2241                        build_props_view(&self.props, &self.base),
2242                        &mut self.topo,
2243                        &mut self.edge_props,
2244                    );
2245                    eng.on_node_changed(id, None, &mut gm);
2246                }
2247                self.engine = eng;
2248                // Process derived-edge deltas for view maintenance.
2249                // Fast path: skip the O(delta_count) allocation when no views exist.
2250                if !self.view_store.is_empty() {
2251                    #[cfg(test)]
2252                    DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
2253                    let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
2254                    for d in &new_deltas {
2255                        self.view_store.on_edge_changed(
2256                            d.etype_sym,
2257                            d.src_id,
2258                            d.dst_id,
2259                            d.fired,
2260                            &mut self.props,
2261                            &build_topo_view(&self.topo, &self.base),
2262                            &self.ids,
2263                            &self.syms,
2264                            &self.labels,
2265                            self.base.as_ref().map(|b| {
2266                                b.columns()
2267                                    .expect("base columns section bounds validated at open")
2268                            }),
2269                        );
2270                    }
2271                }
2272                // Full-text index maintenance: index enabled fields for this label.
2273                if self.fulltext.has_label(label) {
2274                    for (field, value) in props {
2275                        if self.fulltext.is_enabled(label, field) {
2276                            self.fulltext.add_tokens(id, field, value);
2277                        }
2278                    }
2279                }
2280                // Property (equality) index maintenance.
2281                if self.prop_index.has_label(label) {
2282                    for (field, value) in props {
2283                        self.prop_index.set(label, field, id, value);
2284                    }
2285                }
2286            }
2287            WalRecord::InsertEdge {
2288                edge_type,
2289                src_key,
2290                dst_key,
2291            } => {
2292                let src = self.ids.get(src_key).ok_or_else(|| GraphError::Corrupt {
2293                    detail: format!("wal replay references unknown key {src_key}"),
2294                })?;
2295                let dst = self.ids.get(dst_key).ok_or_else(|| GraphError::Corrupt {
2296                    detail: format!("wal replay references unknown key {dst_key}"),
2297                })?;
2298                let etype = self.syms.intern(edge_type);
2299                // Skip if the edge is already visible in the merged base+overlay
2300                // view.  This keeps WAL replay idempotent when the WAL contains
2301                // pre-snapshot records that are already encoded in a V8 base
2302                // (keep_wal=true opens and crash-before-truncation scenarios).
2303                if self.base.is_some()
2304                    && self
2305                        .topo_view()
2306                        .neighbors(etype, Direction::Out, src)
2307                        .contains(&dst)
2308                {
2309                    return Ok(());
2310                }
2311                self.topo.add_edge(etype, src, dst);
2312                // View maintenance for manual edge insert.
2313                self.view_store.on_edge_changed(
2314                    etype,
2315                    src,
2316                    dst,
2317                    true,
2318                    &mut self.props,
2319                    &build_topo_view(&self.topo, &self.base),
2320                    &self.ids,
2321                    &self.syms,
2322                    &self.labels,
2323                    self.base.as_ref().map(|b| {
2324                        b.columns()
2325                            .expect("base columns section bounds validated at open")
2326                    }),
2327                );
2328                // Rule engine: via-hop rules must update when user edges change.
2329                let cursor = self.engine.pending_delta_count();
2330                let mut eng = std::mem::take(&mut self.engine);
2331                {
2332                    let mut gm = make_graph_mut(
2333                        &self.ids,
2334                        &mut self.syms,
2335                        &self.labels,
2336                        build_props_view(&self.props, &self.base),
2337                        &mut self.topo,
2338                        &mut self.edge_props,
2339                    );
2340                    eng.on_edge_changed(edge_type, src, dst, &mut gm);
2341                }
2342                self.engine = eng;
2343                if !self.view_store.is_empty() {
2344                    let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
2345                    for d in &new_deltas {
2346                        self.view_store.on_edge_changed(
2347                            d.etype_sym,
2348                            d.src_id,
2349                            d.dst_id,
2350                            d.fired,
2351                            &mut self.props,
2352                            &build_topo_view(&self.topo, &self.base),
2353                            &self.ids,
2354                            &self.syms,
2355                            &self.labels,
2356                            self.base.as_ref().map(|b| {
2357                                b.columns()
2358                                    .expect("base columns section bounds validated at open")
2359                            }),
2360                        );
2361                    }
2362                }
2363            }
2364            WalRecord::SetProp { key, field, value } => {
2365                let id = self.ids.get(key).ok_or_else(|| GraphError::Corrupt {
2366                    detail: format!("wal replay references unknown key {key}"),
2367                })?;
2368                let old_value = build_props_view(&self.props, &self.base)
2369                    .get(id, field)
2370                    .map(|vr| vr.into_value());
2371                self.props.set(id, field, value.clone());
2372                // Fire rules for the changed field.
2373                let cursor = self.engine.pending_delta_count();
2374                let mut eng = std::mem::take(&mut self.engine);
2375                {
2376                    let mut gm = make_graph_mut(
2377                        &self.ids,
2378                        &mut self.syms,
2379                        &self.labels,
2380                        build_props_view(&self.props, &self.base),
2381                        &mut self.topo,
2382                        &mut self.edge_props,
2383                    );
2384                    eng.on_node_changed(id, Some((field, old_value)), &mut gm);
2385                }
2386                self.engine = eng;
2387                // Derived-edge deltas → view updates.
2388                if !self.view_store.is_empty() {
2389                    #[cfg(test)]
2390                    DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
2391                    let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
2392                    for d in &new_deltas {
2393                        self.view_store.on_edge_changed(
2394                            d.etype_sym,
2395                            d.src_id,
2396                            d.dst_id,
2397                            d.fired,
2398                            &mut self.props,
2399                            &build_topo_view(&self.topo, &self.base),
2400                            &self.ids,
2401                            &self.syms,
2402                            &self.labels,
2403                            self.base.as_ref().map(|b| {
2404                                b.columns()
2405                                    .expect("base columns section bounds validated at open")
2406                            }),
2407                        );
2408                    }
2409                }
2410                // Neighbor-aggregate views that read `field` must also update.
2411                self.view_store.on_prop_changed(
2412                    id,
2413                    field,
2414                    &mut self.props,
2415                    &build_topo_view(&self.topo, &self.base),
2416                    &self.ids,
2417                    &self.syms,
2418                    &self.labels,
2419                    self.base.as_ref().map(|b| {
2420                        b.columns()
2421                            .expect("base columns section bounds validated at open")
2422                    }),
2423                );
2424                // Full-text index maintenance: update tokens for this field if indexed.
2425                if self.fulltext.field_indexed(field) {
2426                    let label_opt = self.labels.get(id as usize).and_then(|&sym| {
2427                        if sym == u32::MAX {
2428                            None
2429                        } else {
2430                            self.syms.resolve(sym)
2431                        }
2432                    });
2433                    if let Some(label) = label_opt {
2434                        if self.fulltext.is_enabled(label, field) {
2435                            self.fulltext.remove_node_field(id, field);
2436                            self.fulltext.add_tokens(id, field, value);
2437                        }
2438                    }
2439                }
2440                // Property (equality) index maintenance: re-key this node's value.
2441                if self.prop_index.field_indexed(field) {
2442                    let label_opt = self.labels.get(id as usize).and_then(|&sym| {
2443                        if sym == u32::MAX {
2444                            None
2445                        } else {
2446                            self.syms.resolve(sym)
2447                        }
2448                    });
2449                    if let Some(label) = label_opt {
2450                        self.prop_index.set(label, field, id, value);
2451                    }
2452                }
2453            }
2454            WalRecord::Intern { id, text } => {
2455                if let Some(existing) = self.syms.get(text) {
2456                    if existing != *id {
2457                        return Err(GraphError::Corrupt {
2458                            detail: format!(
2459                                "wal intern mismatch for {text:?}: have {existing}, record {id}"
2460                            ),
2461                        });
2462                    }
2463                } else {
2464                    let got = self.syms.intern(text);
2465                    if got != *id {
2466                        return Err(GraphError::Corrupt {
2467                            detail: format!(
2468                                "wal intern assigned {got} for {text:?}, record wanted {id}"
2469                            ),
2470                        });
2471                    }
2472                }
2473            }
2474            WalRecord::InsertNodeId { label, key, props } => {
2475                let id = self.ids.try_insert(key)?;
2476                if self.labels.len() <= id as usize {
2477                    self.labels.resize(id as usize + 1, u32::MAX);
2478                }
2479                self.labels[id as usize] = *label;
2480                let label_str = self
2481                    .syms
2482                    .resolve(*label)
2483                    .ok_or_else(|| GraphError::Corrupt {
2484                        detail: format!("wal InsertNodeId unknown label intern {label}"),
2485                    })?
2486                    .to_string();
2487                for (field_sym, value) in props {
2488                    let field =
2489                        self.syms
2490                            .resolve(*field_sym)
2491                            .ok_or_else(|| GraphError::Corrupt {
2492                                detail: format!(
2493                                    "wal InsertNodeId unknown field intern {field_sym}"
2494                                ),
2495                            })?;
2496                    self.props.set(id, field, value.clone());
2497                }
2498                self.view_store
2499                    .init_node_views(id, &mut self.props, &self.syms, &self.labels);
2500                let cursor = self.engine.pending_delta_count();
2501                let mut eng = std::mem::take(&mut self.engine);
2502                {
2503                    let mut gm = make_graph_mut(
2504                        &self.ids,
2505                        &mut self.syms,
2506                        &self.labels,
2507                        build_props_view(&self.props, &self.base),
2508                        &mut self.topo,
2509                        &mut self.edge_props,
2510                    );
2511                    eng.on_node_changed(id, None, &mut gm);
2512                }
2513                self.engine = eng;
2514                if !self.view_store.is_empty() {
2515                    #[cfg(test)]
2516                    DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
2517                    let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
2518                    for d in &new_deltas {
2519                        self.view_store.on_edge_changed(
2520                            d.etype_sym,
2521                            d.src_id,
2522                            d.dst_id,
2523                            d.fired,
2524                            &mut self.props,
2525                            &build_topo_view(&self.topo, &self.base),
2526                            &self.ids,
2527                            &self.syms,
2528                            &self.labels,
2529                            self.base.as_ref().map(|b| {
2530                                b.columns()
2531                                    .expect("base columns section bounds validated at open")
2532                            }),
2533                        );
2534                    }
2535                }
2536                if self.fulltext.has_label(&label_str) {
2537                    for (field_sym, value) in props {
2538                        let Some(field) = self.syms.resolve(*field_sym) else {
2539                            continue;
2540                        };
2541                        if self.fulltext.is_enabled(&label_str, field) {
2542                            self.fulltext.add_tokens(id, field, value);
2543                        }
2544                    }
2545                }
2546                if self.prop_index.has_label(&label_str) {
2547                    for (field_sym, value) in props {
2548                        let Some(field) = self.syms.resolve(*field_sym) else {
2549                            continue;
2550                        };
2551                        self.prop_index.set(&label_str, field, id, value);
2552                    }
2553                }
2554            }
2555            WalRecord::InsertEdgeId { etype, src, dst } => {
2556                // Replay-over-snapshot: dense ids in the pre-snapshot WAL may
2557                // already be tombstoned. Skip rather than attaching edges to
2558                // dead ids (DeleteNode keys the live re-insert, not the old id).
2559                if self.ids.is_tombstoned(*src)
2560                    || self.ids.is_tombstoned(*dst)
2561                    || self.ids.key_of(*src).is_none()
2562                    || self.ids.key_of(*dst).is_none()
2563                {
2564                    return Ok(());
2565                }
2566                // Skip if already visible in the merged view (same idempotency
2567                // guard as InsertEdge above: prevents double-counting when
2568                // pre-snapshot WAL records are replayed over a V8 base).
2569                if self.base.is_some()
2570                    && self
2571                        .topo_view()
2572                        .neighbors(*etype, Direction::Out, *src)
2573                        .contains(dst)
2574                {
2575                    return Ok(());
2576                }
2577                self.topo.add_edge(*etype, *src, *dst);
2578                self.view_store.on_edge_changed(
2579                    *etype,
2580                    *src,
2581                    *dst,
2582                    true,
2583                    &mut self.props,
2584                    &build_topo_view(&self.topo, &self.base),
2585                    &self.ids,
2586                    &self.syms,
2587                    &self.labels,
2588                    self.base.as_ref().map(|b| {
2589                        b.columns()
2590                            .expect("base columns section bounds validated at open")
2591                    }),
2592                );
2593                // Rule engine: via-hop rules fire when user via-edges are inserted.
2594                // Resolve etype back to string so on_edge_changed can match rules by name.
2595                if let Some(etype_str) = self.syms.resolve(*etype).map(|s| s.to_string()) {
2596                    let cursor = self.engine.pending_delta_count();
2597                    let mut eng = std::mem::take(&mut self.engine);
2598                    {
2599                        let mut gm = make_graph_mut(
2600                            &self.ids,
2601                            &mut self.syms,
2602                            &self.labels,
2603                            build_props_view(&self.props, &self.base),
2604                            &mut self.topo,
2605                            &mut self.edge_props,
2606                        );
2607                        eng.on_edge_changed(&etype_str, *src, *dst, &mut gm);
2608                    }
2609                    self.engine = eng;
2610                    if !self.view_store.is_empty() {
2611                        let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
2612                        for d in &new_deltas {
2613                            self.view_store.on_edge_changed(
2614                                d.etype_sym,
2615                                d.src_id,
2616                                d.dst_id,
2617                                d.fired,
2618                                &mut self.props,
2619                                &build_topo_view(&self.topo, &self.base),
2620                                &self.ids,
2621                                &self.syms,
2622                                &self.labels,
2623                                self.base.as_ref().map(|b| {
2624                                    b.columns()
2625                                        .expect("base columns section bounds validated at open")
2626                                }),
2627                            );
2628                        }
2629                    }
2630                }
2631            }
2632            WalRecord::SetPropId { id, field, value } => {
2633                if self.ids.is_tombstoned(*id) || self.ids.key_of(*id).is_none() {
2634                    return Ok(());
2635                }
2636                let field_str = self
2637                    .syms
2638                    .resolve(*field)
2639                    .ok_or_else(|| GraphError::Corrupt {
2640                        detail: format!("wal SetPropId unknown field intern {field}"),
2641                    })?
2642                    .to_string();
2643                let old_value = build_props_view(&self.props, &self.base)
2644                    .get(*id, &field_str)
2645                    .map(|vr| vr.into_value());
2646                self.props.set(*id, &field_str, value.clone());
2647                let cursor = self.engine.pending_delta_count();
2648                let mut eng = std::mem::take(&mut self.engine);
2649                {
2650                    let mut gm = make_graph_mut(
2651                        &self.ids,
2652                        &mut self.syms,
2653                        &self.labels,
2654                        build_props_view(&self.props, &self.base),
2655                        &mut self.topo,
2656                        &mut self.edge_props,
2657                    );
2658                    eng.on_node_changed(*id, Some((field_str.as_str(), old_value)), &mut gm);
2659                }
2660                self.engine = eng;
2661                if !self.view_store.is_empty() {
2662                    #[cfg(test)]
2663                    DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
2664                    let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
2665                    for d in &new_deltas {
2666                        self.view_store.on_edge_changed(
2667                            d.etype_sym,
2668                            d.src_id,
2669                            d.dst_id,
2670                            d.fired,
2671                            &mut self.props,
2672                            &build_topo_view(&self.topo, &self.base),
2673                            &self.ids,
2674                            &self.syms,
2675                            &self.labels,
2676                            self.base.as_ref().map(|b| {
2677                                b.columns()
2678                                    .expect("base columns section bounds validated at open")
2679                            }),
2680                        );
2681                    }
2682                }
2683                self.view_store.on_prop_changed(
2684                    *id,
2685                    &field_str,
2686                    &mut self.props,
2687                    &build_topo_view(&self.topo, &self.base),
2688                    &self.ids,
2689                    &self.syms,
2690                    &self.labels,
2691                    self.base.as_ref().map(|b| {
2692                        b.columns()
2693                            .expect("base columns section bounds validated at open")
2694                    }),
2695                );
2696                if self.fulltext.field_indexed(&field_str) {
2697                    let label_opt = self.labels.get(*id as usize).and_then(|&sym| {
2698                        if sym == u32::MAX {
2699                            None
2700                        } else {
2701                            self.syms.resolve(sym)
2702                        }
2703                    });
2704                    if let Some(label) = label_opt {
2705                        if self.fulltext.is_enabled(label, &field_str) {
2706                            self.fulltext.remove_node_field(*id, &field_str);
2707                            self.fulltext.add_tokens(*id, &field_str, value);
2708                        }
2709                    }
2710                }
2711                if self.prop_index.field_indexed(&field_str) {
2712                    let label_opt = self.labels.get(*id as usize).and_then(|&sym| {
2713                        if sym == u32::MAX {
2714                            None
2715                        } else {
2716                            self.syms.resolve(sym)
2717                        }
2718                    });
2719                    if let Some(label) = label_opt {
2720                        self.prop_index.set(label, &field_str, *id, value);
2721                    }
2722                }
2723            }
2724            WalRecord::CreateRule { def_bytes } => {
2725                let def: RuleDef = decode_rule_def(def_bytes).map_err(|e| GraphError::Corrupt {
2726                    detail: format!("CreateRule def_bytes deserialize failed: {e}"),
2727                })?;
2728                // Replay-over-snapshot idempotency: the rule was captured in the snapshot
2729                // so the engine already has it; silently skip to avoid a spurious
2730                // RuleInvalid error in the crash window between snapshot write and WAL
2731                // truncation.
2732                if self.engine.rules().any(|r| r.name == def.name) {
2733                    return Ok(());
2734                }
2735                let cursor = self.engine.pending_delta_count();
2736                let mut eng = std::mem::take(&mut self.engine);
2737                let result = {
2738                    let mut gm = make_graph_mut(
2739                        &self.ids,
2740                        &mut self.syms,
2741                        &self.labels,
2742                        build_props_view(&self.props, &self.base),
2743                        &mut self.topo,
2744                        &mut self.edge_props,
2745                    );
2746                    eng.create_rule(def, &mut gm)
2747                };
2748                self.engine = eng;
2749                result.map_err(|e| GraphError::RuleInvalid { detail: e })?;
2750                // Derived-edge fires from backfill → view updates.
2751                // Fast path: skip O(edge_count) allocation when no views exist.
2752                if !self.view_store.is_empty() {
2753                    #[cfg(test)]
2754                    DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
2755                    let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
2756                    for d in &new_deltas {
2757                        self.view_store.on_edge_changed(
2758                            d.etype_sym,
2759                            d.src_id,
2760                            d.dst_id,
2761                            d.fired,
2762                            &mut self.props,
2763                            &build_topo_view(&self.topo, &self.base),
2764                            &self.ids,
2765                            &self.syms,
2766                            &self.labels,
2767                            self.base.as_ref().map(|b| {
2768                                b.columns()
2769                                    .expect("base columns section bounds validated at open")
2770                            }),
2771                        );
2772                    }
2773                }
2774            }
2775            WalRecord::DeleteRule { name } => {
2776                // Replay-over-snapshot idempotency: the snapshot already captured the
2777                // post-delete state so the rule is absent; silently skip to avoid a
2778                // spurious RuleNotFound error in the crash window between snapshot write
2779                // and WAL truncation.
2780                if !self.engine.rules().any(|r| r.name == *name) {
2781                    return Ok(());
2782                }
2783                let cursor = self.engine.pending_delta_count();
2784                let mut eng = std::mem::take(&mut self.engine);
2785                let result = {
2786                    let mut gm = make_graph_mut(
2787                        &self.ids,
2788                        &mut self.syms,
2789                        &self.labels,
2790                        build_props_view(&self.props, &self.base),
2791                        &mut self.topo,
2792                        &mut self.edge_props,
2793                    );
2794                    eng.delete_rule(name, &mut gm)
2795                };
2796                self.engine = eng;
2797                result.map_err(|_| GraphError::RuleNotFound { name: name.clone() })?;
2798                // Derived-edge retractions → view updates.
2799                if !self.view_store.is_empty() {
2800                    #[cfg(test)]
2801                    DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
2802                    let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
2803                    for d in &new_deltas {
2804                        self.view_store.on_edge_changed(
2805                            d.etype_sym,
2806                            d.src_id,
2807                            d.dst_id,
2808                            d.fired,
2809                            &mut self.props,
2810                            &build_topo_view(&self.topo, &self.base),
2811                            &self.ids,
2812                            &self.syms,
2813                            &self.labels,
2814                            self.base.as_ref().map(|b| {
2815                                b.columns()
2816                                    .expect("base columns section bounds validated at open")
2817                            }),
2818                        );
2819                    }
2820                }
2821            }
2822            WalRecord::RemoveProp { key, field } => {
2823                // Recovery-safe: unknown key or already-absent field is a
2824                // clean no-op. Crash-window replay over a snapshot that
2825                // already applied this record must not Err.
2826                let Some(id) = self.ids.get(key) else {
2827                    return Ok(());
2828                };
2829                // Read old value through the seam for rule retraction.
2830                let old = build_props_view(&self.props, &self.base)
2831                    .get(id, field)
2832                    .map(|vr| vr.into_value());
2833                self.props.remove(id, field);
2834                // If the base still supplies the value after the overlay removal,
2835                // record a tombstone so ColumnsView::get does not resurrect it.
2836                // This covers both the base-only case AND the both-resident case:
2837                //   base-only (in_overlay=false): old prop was only in base, remove
2838                //     is a no-op on overlay, base still visible → tombstone needed.
2839                //   both-resident (in_overlay=true): overlay had v2, base has v1;
2840                //     removing overlay uncovers v1 → tombstone needed.
2841                // Idempotent on double-replay: second pass sees the tombstone →
2842                // get() returns None → condition is false → no duplicate tombstone.
2843                if build_props_view(&self.props, &self.base)
2844                    .get(id, field)
2845                    .is_some()
2846                {
2847                    self.props.record_prop_tombstone(id, field);
2848                }
2849                let cursor = self.engine.pending_delta_count();
2850                let mut eng = std::mem::take(&mut self.engine);
2851                {
2852                    let mut gm = make_graph_mut(
2853                        &self.ids,
2854                        &mut self.syms,
2855                        &self.labels,
2856                        build_props_view(&self.props, &self.base),
2857                        &mut self.topo,
2858                        &mut self.edge_props,
2859                    );
2860                    eng.on_node_changed(id, Some((field, old)), &mut gm);
2861                }
2862                self.engine = eng;
2863                // Derived-edge deltas → view updates.
2864                if !self.view_store.is_empty() {
2865                    #[cfg(test)]
2866                    DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
2867                    let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
2868                    for d in &new_deltas {
2869                        self.view_store.on_edge_changed(
2870                            d.etype_sym,
2871                            d.src_id,
2872                            d.dst_id,
2873                            d.fired,
2874                            &mut self.props,
2875                            &build_topo_view(&self.topo, &self.base),
2876                            &self.ids,
2877                            &self.syms,
2878                            &self.labels,
2879                            self.base.as_ref().map(|b| {
2880                                b.columns()
2881                                    .expect("base columns section bounds validated at open")
2882                            }),
2883                        );
2884                    }
2885                }
2886                // Neighbor-aggregate views that read `field` must also update.
2887                self.view_store.on_prop_changed(
2888                    id,
2889                    field,
2890                    &mut self.props,
2891                    &build_topo_view(&self.topo, &self.base),
2892                    &self.ids,
2893                    &self.syms,
2894                    &self.labels,
2895                    self.base.as_ref().map(|b| {
2896                        b.columns()
2897                            .expect("base columns section bounds validated at open")
2898                    }),
2899                );
2900                // Full-text index maintenance: remove tokens for this field.
2901                if self.fulltext.field_indexed(field) {
2902                    self.fulltext.remove_node_field(id, field);
2903                }
2904                // Property (equality) index maintenance: drop this node's entry.
2905                if self.prop_index.field_indexed(field) {
2906                    if let Some(label) = self.labels.get(id as usize).and_then(|&sym| {
2907                        (sym != u32::MAX).then(|| self.syms.resolve(sym)).flatten()
2908                    }) {
2909                        self.prop_index.remove_node(label, field, id);
2910                    }
2911                }
2912            }
2913            WalRecord::DeleteEdge {
2914                edge_type,
2915                src_key,
2916                dst_key,
2917            } => {
2918                // Recovery-safe: unknown keys, unknown etype, or already-
2919                // absent edge is a clean no-op (remove_edge returns false).
2920                let Some(src) = self.ids.get(src_key) else {
2921                    return Ok(());
2922                };
2923                let Some(dst) = self.ids.get(dst_key) else {
2924                    return Ok(());
2925                };
2926                let Some(etype) = self.syms.get(edge_type) else {
2927                    return Ok(());
2928                };
2929                // I3: phantom-tombstone guard.  When a V8 base is present, a
2930                // DeleteEdge WAL record for an edge that was already absorbed into
2931                // the new base (i.e. neither in overlay nor in base) must be skipped.
2932                // Without this guard, remove_edge records a tombstone for an edge
2933                // that no longer exists, incorrectly understating edge_count.
2934                if self.base.is_some()
2935                    && !self
2936                        .topo_view()
2937                        .neighbors(etype, core_storage::topology::Direction::Out, src)
2938                        .contains(&dst)
2939                {
2940                    return Ok(());
2941                }
2942                self.topo.remove_edge(etype, src, dst);
2943                self.edge_props.remove_edge(etype, src, dst);
2944                // View maintenance for manual edge delete (topo already updated above).
2945                self.view_store.on_edge_changed(
2946                    etype,
2947                    src,
2948                    dst,
2949                    false,
2950                    &mut self.props,
2951                    &build_topo_view(&self.topo, &self.base),
2952                    &self.ids,
2953                    &self.syms,
2954                    &self.labels,
2955                    self.base.as_ref().map(|b| {
2956                        b.columns()
2957                            .expect("base columns section bounds validated at open")
2958                    }),
2959                );
2960                // Rule engine: via-hop rules must retract when user via-edges are deleted.
2961                let cursor = self.engine.pending_delta_count();
2962                let mut eng = std::mem::take(&mut self.engine);
2963                {
2964                    let mut gm = make_graph_mut(
2965                        &self.ids,
2966                        &mut self.syms,
2967                        &self.labels,
2968                        build_props_view(&self.props, &self.base),
2969                        &mut self.topo,
2970                        &mut self.edge_props,
2971                    );
2972                    eng.on_edge_changed(edge_type, src, dst, &mut gm);
2973                }
2974                self.engine = eng;
2975                if !self.view_store.is_empty() {
2976                    let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
2977                    for d in &new_deltas {
2978                        self.view_store.on_edge_changed(
2979                            d.etype_sym,
2980                            d.src_id,
2981                            d.dst_id,
2982                            d.fired,
2983                            &mut self.props,
2984                            &build_topo_view(&self.topo, &self.base),
2985                            &self.ids,
2986                            &self.syms,
2987                            &self.labels,
2988                            self.base.as_ref().map(|b| {
2989                                b.columns()
2990                                    .expect("base columns section bounds validated at open")
2991                            }),
2992                        );
2993                    }
2994                }
2995            }
2996            WalRecord::DeleteNode { key } => {
2997                // Recovery-safe: already-tombstoned / unknown key is a clean
2998                // no-op. Crash-window replay over a snapshot that already
2999                // applied this record cannot recover the retired id from the
3000                // key (`IdMap::get` is None), so every subsequent step is
3001                // skipped. Each step is independently idempotent if invoked
3002                // twice on a still-live id: retraction is a no-op on empty
3003                // provenance, `remove_edge` returns false, `remove_all` is a
3004                // no-op, `ids.delete` returns None, label sentinel is sticky.
3005                let Some(n) = self.ids.get(key) else {
3006                    return Ok(());
3007                };
3008
3009                // (1) Retract derived edges + de-index while props/labels live.
3010                let cursor = self.engine.pending_delta_count();
3011                let mut eng = std::mem::take(&mut self.engine);
3012                {
3013                    let mut gm = make_graph_mut(
3014                        &self.ids,
3015                        &mut self.syms,
3016                        &self.labels,
3017                        build_props_view(&self.props, &self.base),
3018                        &mut self.topo,
3019                        &mut self.edge_props,
3020                    );
3021                    eng.on_node_removed(n, &mut gm);
3022                }
3023                self.engine = eng;
3024                // Derived-edge retractions → view updates for neighbors.
3025                if !self.view_store.is_empty() {
3026                    #[cfg(test)]
3027                    DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
3028                    let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
3029                    for d in &new_deltas {
3030                        self.view_store.on_edge_changed(
3031                            d.etype_sym,
3032                            d.src_id,
3033                            d.dst_id,
3034                            d.fired,
3035                            &mut self.props,
3036                            &build_topo_view(&self.topo, &self.base),
3037                            &self.ids,
3038                            &self.syms,
3039                            &self.labels,
3040                            self.base.as_ref().map(|b| {
3041                                b.columns()
3042                                    .expect("base columns section bounds validated at open")
3043                            }),
3044                        );
3045                    }
3046                }
3047
3048                // (2) Sweep ALL remaining edges incident to n, both directions,
3049                // every etype. This cascade is intentionally mask-independent:
3050                // topology integrity requires removing every edge touching the
3051                // deleted node regardless of the caller's visibility scope.
3052                // (The mask limits which nodes a role's read phase can return;
3053                // the WAL delete always executes with full storage authority.)
3054                // Collect then remove so neighbor slices stay valid during
3055                // iteration. Remove from topo first, then call view maintenance
3056                // so Avg/Min/Max recompute sees the correct (reduced) neighbor set.
3057                let etypes: Vec<u32> = self.topo.etypes().collect();
3058                let mut doomed = Vec::new();
3059                for et in &etypes {
3060                    for &dst in self.topo.neighbors(*et, Direction::Out, n).as_ref() {
3061                        doomed.push((*et, n, dst));
3062                    }
3063                    for &src in self.topo.neighbors(*et, Direction::In, n).as_ref() {
3064                        doomed.push((*et, src, n));
3065                    }
3066                }
3067                for (et, s, d) in doomed {
3068                    self.topo.remove_edge(et, s, d);
3069                    self.edge_props.remove_edge(et, s, d);
3070                    // View maintenance: n's own view values will be cleared by
3071                    // remove_all below; only update surviving neighbors.
3072                    self.view_store.on_edge_changed(
3073                        et,
3074                        s,
3075                        d,
3076                        false,
3077                        &mut self.props,
3078                        &build_topo_view(&self.topo, &self.base),
3079                        &self.ids,
3080                        &self.syms,
3081                        &self.labels,
3082                        self.base.as_ref().map(|b| {
3083                            b.columns()
3084                                .expect("base columns section bounds validated at open")
3085                        }),
3086                    );
3087                }
3088
3089                // (3) Drop every remaining prop (`ColumnStore::remove_all`).
3090                self.props.remove_all(n);
3091                // Full-text index maintenance: remove all tokens for this node.
3092                self.fulltext.remove_node(n);
3093                // Property (equality) index maintenance: drop all entries for n.
3094                self.prop_index.remove_node_all(n);
3095
3096                // (4) Retire the dense id and stamp the label sentinel.
3097                self.ids.delete(key);
3098                if let Some(slot) = self.labels.get_mut(n as usize) {
3099                    *slot = u32::MAX;
3100                }
3101            }
3102            WalRecord::Batch(inner) => {
3103                // Apply each inner record in order through the same apply path.
3104                // Inner records are validated free of nested Batch by encode_record.
3105                for rec in inner {
3106                    self.apply(rec)?;
3107                }
3108            }
3109            WalRecord::RebuildRule { name } => {
3110                // Replay-over-snapshot idempotency: the snapshot may already
3111                // reflect a later delete_rule, so the rule is absent; skip.
3112                if !self.engine.rules().any(|r| r.name == *name) {
3113                    return Ok(());
3114                }
3115                let cursor = self.engine.pending_delta_count();
3116                let mut eng = std::mem::take(&mut self.engine);
3117                let result = {
3118                    let mut gm = make_graph_mut(
3119                        &self.ids,
3120                        &mut self.syms,
3121                        &self.labels,
3122                        build_props_view(&self.props, &self.base),
3123                        &mut self.topo,
3124                        &mut self.edge_props,
3125                    );
3126                    eng.rebuild(name, &mut gm)
3127                };
3128                self.engine = eng;
3129                result.map_err(|_| GraphError::RuleNotFound { name: name.clone() })?;
3130                // Derived-edge delta changes → view updates.
3131                if !self.view_store.is_empty() {
3132                    #[cfg(test)]
3133                    DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
3134                    let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
3135                    for d in &new_deltas {
3136                        self.view_store.on_edge_changed(
3137                            d.etype_sym,
3138                            d.src_id,
3139                            d.dst_id,
3140                            d.fired,
3141                            &mut self.props,
3142                            &build_topo_view(&self.topo, &self.base),
3143                            &self.ids,
3144                            &self.syms,
3145                            &self.labels,
3146                            self.base.as_ref().map(|b| {
3147                                b.columns()
3148                                    .expect("base columns section bounds validated at open")
3149                            }),
3150                        );
3151                    }
3152                }
3153            }
3154            WalRecord::CreateView { def_bytes } => {
3155                let def: ViewDef =
3156                    bincode::deserialize(def_bytes).map_err(|e| GraphError::Corrupt {
3157                        detail: format!("CreateView def_bytes deserialize failed: {e}"),
3158                    })?;
3159                // Replay-over-snapshot idempotency: view already present → skip.
3160                if self.view_store.has_view(&def.name) {
3161                    return Ok(());
3162                }
3163                self.view_store
3164                    .create_view(
3165                        def,
3166                        &mut self.props,
3167                        &build_topo_view(&self.topo, &self.base),
3168                        &self.ids,
3169                        &self.syms,
3170                        &self.labels,
3171                    )
3172                    .map_err(|e| GraphError::RuleInvalid { detail: e })?;
3173            }
3174            WalRecord::DeleteView { name } => {
3175                // Replay-over-snapshot idempotency: view already absent → skip.
3176                if !self.view_store.has_view(name) {
3177                    return Ok(());
3178                }
3179                self.view_store
3180                    .delete_view(name, &mut self.props, &self.ids, &self.labels, &self.syms)
3181                    .map_err(|_| GraphError::RuleNotFound { name: name.clone() })?;
3182            }
3183            WalRecord::EnableFulltext { label, field } => {
3184                // Replay-over-snapshot idempotency: already enabled → skip.
3185                if self.fulltext.is_enabled(label, field) {
3186                    return Ok(());
3187                }
3188                self.fulltext.enable(label, field);
3189                // Backfill: index all live nodes of this label that have the field.
3190                let n = self.ids.len() as u32;
3191                for id in 0..n {
3192                    let Some(&sym) = self.labels.get(id as usize) else {
3193                        continue;
3194                    };
3195                    if sym == u32::MAX {
3196                        continue; // tombstoned
3197                    }
3198                    let Some(lbl) = self.syms.resolve(sym) else {
3199                        continue;
3200                    };
3201                    if lbl != label {
3202                        continue;
3203                    }
3204                    if let Some(value) = build_props_view(&self.props, &self.base)
3205                        .get(id, field)
3206                        .map(|vr| vr.into_value())
3207                    {
3208                        self.fulltext.add_tokens(id, field, &value);
3209                    }
3210                }
3211            }
3212            WalRecord::DisableFulltext { label, field } => {
3213                // Replay-over-snapshot idempotency: already disabled → skip.
3214                if !self.fulltext.is_enabled(label, field) {
3215                    return Ok(());
3216                }
3217                // If another label still indexes this field, the postings column
3218                // is kept — but it must not contain node_ids from the now-disabled
3219                // label.  Remove them before calling disable() so the field_indexed
3220                // guard inside disable() sees the correct post-removal state.
3221                if self.fulltext.field_indexed_by_other(label, field) {
3222                    if let Some(label_sym) = self.syms.get(label) {
3223                        for (node_id, &lsym) in self.labels.iter().enumerate() {
3224                            if lsym == label_sym {
3225                                self.fulltext.remove_node_field(node_id as u32, field);
3226                            }
3227                        }
3228                    }
3229                }
3230                self.fulltext.disable(label, field);
3231            }
3232            WalRecord::EnableIndex { label, field } => {
3233                // Replay-over-snapshot idempotency: already enabled → skip.
3234                if self.prop_index.is_enabled(label, field) {
3235                    return Ok(());
3236                }
3237                self.prop_index.enable(label, field);
3238                // Backfill: index all live nodes of this label that have the field.
3239                let n = self.ids.len() as u32;
3240                for id in 0..n {
3241                    let Some(&sym) = self.labels.get(id as usize) else {
3242                        continue;
3243                    };
3244                    if sym == u32::MAX {
3245                        continue; // tombstoned
3246                    }
3247                    let Some(lbl) = self.syms.resolve(sym) else {
3248                        continue;
3249                    };
3250                    if lbl != label {
3251                        continue;
3252                    }
3253                    if let Some(value) = build_props_view(&self.props, &self.base)
3254                        .get(id, field)
3255                        .map(|vr| vr.into_value())
3256                    {
3257                        self.prop_index.set(label, field, id, &value);
3258                    }
3259                }
3260            }
3261            WalRecord::DisableIndex { label, field } => {
3262                self.prop_index.disable(label, field);
3263            }
3264            // History markers carry no replay state — rules re-derive edges
3265            // deterministically on open/replay. Skip unconditionally.
3266            WalRecord::DerivedEdgeAdded { .. } | WalRecord::DerivedEdgeRetracted { .. } => {}
3267            // ── rename_node ──────────────────────────────────────────────────
3268            WalRecord::RenameNode { old_key, new_key } => {
3269                // Recovery-safe: if old_key is already gone (key was renamed
3270                // by a snapshot or a prior replay frame), skip cleanly.
3271                if self.ids.get(old_key).is_none() {
3272                    return Ok(());
3273                }
3274                // The rename only updates the key-table; the dense id, all
3275                // topo edges, props, labels, and rule state are id-indexed and
3276                // require no change.
3277                self.ids
3278                    .rename(old_key, new_key)
3279                    .map_err(|e| GraphError::Corrupt {
3280                        detail: format!("wal replay RenameNode {old_key}→{new_key}: {e}"),
3281                    })?;
3282            }
3283        }
3284        Ok(())
3285    }
3286
3287    /// Intern `s` in `syms` and emit a WAL `Intern` record so `*Id` records
3288    /// replay on WAL-only `open_at` (no snapshot intern table). Apply is
3289    /// idempotent when the string is already bound. Always emit: after
3290    /// `snapshot()` the WAL is truncated and live intern is not on disk.
3291    fn intern_wal(&mut self, s: &str) -> (u32, WalRecord) {
3292        let id = if let Some(id) = self.syms.get(s) {
3293            id
3294        } else {
3295            self.syms.intern(s)
3296        };
3297        (
3298            id,
3299            WalRecord::Intern {
3300                id,
3301                text: s.to_string(),
3302            },
3303        )
3304    }
3305
3306    /// Rewrite user-facing records into dense-id records. On `Err`, no live
3307    /// state is left mutated: speculative interns made while building the
3308    /// output are rolled back, so a later successful mutation cannot log an
3309    /// `Intern` record whose id replay would never reproduce.
3310    fn rewrite_wal_dense(&mut self, recs: Vec<WalRecord>) -> Result<Vec<WalRecord>> {
3311        let syms_checkpoint = self.syms.len();
3312        let result = self.rewrite_wal_dense_inner(recs);
3313        if result.is_err() {
3314            self.syms.truncate(syms_checkpoint);
3315        }
3316        result
3317    }
3318
3319    fn rewrite_wal_dense_inner(&mut self, recs: Vec<WalRecord>) -> Result<Vec<WalRecord>> {
3320        let mut out = Vec::with_capacity(recs.len());
3321        // Node ids allocated by later apply(InsertNodeId) in this same batch.
3322        let mut pending: std::collections::HashMap<String, u32> = std::collections::HashMap::new();
3323        let mut interned = std::collections::HashSet::<u32>::new();
3324        let mut next = u32::try_from(self.ids.len()).map_err(|_| GraphError::Corrupt {
3325            detail: "id space exhausted".into(),
3326        })?;
3327        let lookup = |ids: &IdMap,
3328                      pending: &std::collections::HashMap<String, u32>,
3329                      key: &str|
3330         -> Option<u32> { ids.get(key).or_else(|| pending.get(key).copied()) };
3331        for rec in recs {
3332            match rec {
3333                WalRecord::InsertNode { label, key, props } => {
3334                    let (label_id, intern) = self.intern_wal(&label);
3335                    if interned.insert(label_id) {
3336                        out.push(intern);
3337                    }
3338                    let mut props_id = Vec::with_capacity(props.len());
3339                    for (field, value) in props {
3340                        let (field_id, intern) = self.intern_wal(&field);
3341                        if interned.insert(field_id) {
3342                            out.push(intern);
3343                        }
3344                        props_id.push((field_id, value));
3345                    }
3346                    if lookup(&self.ids, &pending, &key).is_none() {
3347                        pending.insert(key.clone(), next);
3348                        next = next.checked_add(1).ok_or_else(|| GraphError::Corrupt {
3349                            detail: "id space exhausted".into(),
3350                        })?;
3351                    }
3352                    out.push(WalRecord::InsertNodeId {
3353                        label: label_id,
3354                        key,
3355                        props: props_id,
3356                    });
3357                }
3358                WalRecord::SetProp { key, field, value } => {
3359                    let id =
3360                        lookup(&self.ids, &pending, &key).ok_or_else(|| GraphError::Corrupt {
3361                            detail: format!("dense WAL rewrite missing key {key}"),
3362                        })?;
3363                    let (field_id, intern) = self.intern_wal(&field);
3364                    if interned.insert(field_id) {
3365                        out.push(intern);
3366                    }
3367                    out.push(WalRecord::SetPropId {
3368                        id,
3369                        field: field_id,
3370                        value,
3371                    });
3372                }
3373                WalRecord::InsertEdge {
3374                    edge_type,
3375                    src_key,
3376                    dst_key,
3377                } => {
3378                    let (etype, intern) = self.intern_wal(&edge_type);
3379                    if interned.insert(etype) {
3380                        out.push(intern);
3381                    }
3382                    let src = lookup(&self.ids, &pending, &src_key).ok_or_else(|| {
3383                        GraphError::Corrupt {
3384                            detail: format!("dense WAL rewrite missing src {src_key}"),
3385                        }
3386                    })?;
3387                    let dst = lookup(&self.ids, &pending, &dst_key).ok_or_else(|| {
3388                        GraphError::Corrupt {
3389                            detail: format!("dense WAL rewrite missing dst {dst_key}"),
3390                        }
3391                    })?;
3392                    out.push(WalRecord::InsertEdgeId { etype, src, dst });
3393                }
3394                WalRecord::RenameNode {
3395                    ref old_key,
3396                    ref new_key,
3397                } => {
3398                    // Track the rename in `pending` so subsequent InsertEdge /
3399                    // SetProp records in this batch can resolve the new key.
3400                    let id = lookup(&self.ids, &pending, old_key).ok_or_else(|| {
3401                        GraphError::Corrupt {
3402                            detail: format!(
3403                                "dense WAL rewrite: RenameNode old key {old_key} not found"
3404                            ),
3405                        }
3406                    })?;
3407                    pending.remove(old_key.as_str());
3408                    pending.insert(new_key.clone(), id);
3409                    out.push(rec);
3410                }
3411                other => out.push(other),
3412            }
3413        }
3414        Ok(out)
3415    }
3416
3417    fn log_dense(&mut self, recs: Vec<WalRecord>) -> Result<()> {
3418        let recs = self.rewrite_wal_dense(recs)?;
3419        match recs.len() {
3420            0 => Ok(()),
3421            1 => self.log_then_apply(recs.into_iter().next().unwrap()),
3422            _ => self.log_then_apply(WalRecord::Batch(recs)),
3423        }
3424    }
3425
3426    /// Durable write, then notify the event sink. Replay (`apply` during
3427    /// `open`) never enters this function, so it is the replay-silent seam.
3428    fn log_then_apply(&mut self, rec: WalRecord) -> Result<()> {
3429        self.log_then_apply_with(rec, None, self.fsync)
3430    }
3431
3432    /// Whether this frame must fsync under `policy`.
3433    ///
3434    /// Batched contract: user-visible batches (>1 mutation) fsync; single
3435    /// mutations do not. The dense rewrite wraps a single mutation in a
3436    /// `Batch([Intern.., <one *Id record>])`, so `Intern` records are excluded
3437    /// from the count — removing that filter would make every single-op write
3438    /// fsync under Batched (or, if the threshold were raised instead, skip a
3439    /// needed fsync for real two-op batches).
3440    fn wal_needs_sync(policy: FsyncPolicy, rec: &WalRecord) -> bool {
3441        match policy {
3442            FsyncPolicy::Relaxed => false,
3443            FsyncPolicy::Strict => true,
3444            FsyncPolicy::Batched => match rec {
3445                // Intern + one mutation is the single-op rewrite, not a user batch.
3446                WalRecord::Batch(inner) => {
3447                    inner
3448                        .iter()
3449                        .filter(|r| !matches!(r, WalRecord::Intern { .. }))
3450                        .count()
3451                        > 1
3452                }
3453                _ => false,
3454            },
3455        }
3456    }
3457
3458    /// # Apply-infallibility invariant (load-bearing)
3459    ///
3460    /// The ordering is: WAL append → fsync → apply. If `apply` returned `Err`
3461    /// for a `Batch` frame after a successful WAL write, the WAL would contain
3462    /// the full frame while in-memory state would reflect only the ops before
3463    /// the failure. On reopen, WAL replay would then apply the entire batch —
3464    /// diverging permanently from what the pre-crash process had in memory.
3465    ///
3466    /// For `Batch` frames this situation cannot arise because:
3467    /// - All validation runs via `commit_logged_batch`/`MutPreview` **before**
3468    ///   the WAL write. `MutPreview` uses the same `&mut self` that apply will
3469    ///   use, with no concurrent mutation between validation exit and apply entry.
3470    /// - Every `apply` arm for a validated op is either infallible by construction
3471    ///   (`InsertNode`, `RemoveProp`, `DeleteEdge`, `DeleteNode`), has idempotency
3472    ///   guards that return `Ok(())` (`CreateRule`, `DeleteRule`), or is
3473    ///   guaranteed-present by validation (`InsertEdge`/`SetProp` key lookups).
3474    /// - `on_node_changed` and `on_node_removed` return `()` — never `Err`.
3475    ///
3476    /// A `debug_assert!` below fires in debug builds if `apply` ever returns
3477    /// `Err` for a `Batch` frame, making any future regression immediately visible
3478    /// in tests rather than silently diverging crash-recovery behaviour.
3479    fn log_then_apply_with(
3480        &mut self,
3481        rec: WalRecord,
3482        ingest: Option<(String, usize)>,
3483        policy: FsyncPolicy,
3484    ) -> Result<()> {
3485        // Read-only guard: as-of instances must never write the WAL.
3486        if self.read_only {
3487            return Err(GraphError::ReadOnly);
3488        }
3489        // Degraded guard: fsync failure left WAL truncated; in-memory state
3490        // is ahead of the on-disk WAL, so further mutations would deepen the
3491        // divergence.  Reopen the database to recover.
3492        if self.degraded {
3493            return Err(GraphError::Io(std::io::Error::other(
3494                "database degraded after group-commit fsync failure; reopen required",
3495            )));
3496        }
3497        // Ensure retained provenance bytes are decoded into the live mutable
3498        // fields before any mutation touches self.engine.provenance.  This is a
3499        // no-op if provenance was never stored (fresh store) or has already been
3500        // consumed (subsequent mutations).  WAL replay calls apply() directly
3501        // and is covered by consume_retained_state_eager before replay.
3502        self.ensure_v8_base_sections_loaded();
3503        self.engine.ensure_provenance_loaded_mut();
3504        // Invariant (I-1): no stale deltas may enter from a previous apply.
3505        // If any engine method ever accumulates deltas before erroring, they would
3506        // contaminate the *next* commit's event stream. This assert fires in debug
3507        // builds, making any future regression visible at the earliest point.
3508        debug_assert_eq!(
3509            self.engine.pending_delta_count(),
3510            0,
3511            "stale engine deltas at log_then_apply_with entry — \
3512             a previous apply arm may have accumulated deltas before erroring; \
3513             the caller must drain_deltas() on any error path before returning"
3514        );
3515        self.fs.append(FileId::Wal, &encode_record(&rec))?;
3516        if Self::wal_needs_sync(policy, &rec) {
3517            self.fs.sync(FileId::Wal)?;
3518        }
3519        // Marker writing always needs the engine deltas, but the engine only
3520        // accumulates them when emit_deltas is true (normally gated on subscribers
3521        // or views being present).  Enable emission for this apply if it is
3522        // currently off, then restore the original state unconditionally via an
3523        // RAII guard — this prevents a panic in apply() from leaking the flag.
3524        struct RestoreEmitDeltas(*mut RuleEngine, bool);
3525        impl Drop for RestoreEmitDeltas {
3526            fn drop(&mut self) {
3527                // SAFETY: pointer into self (GraphDb); guard is dropped within
3528                // this frame before log_then_apply_with returns.
3529                unsafe { (*self.0).set_emit_deltas(self.1) };
3530            }
3531        }
3532        let original_emit = self.engine.emit_deltas();
3533        if !original_emit {
3534            self.engine.set_emit_deltas(true);
3535        }
3536        // SAFETY: raw pointer into self; guard dropped within this frame.
3537        let _emit_guard = RestoreEmitDeltas(&mut self.engine as *mut _, original_emit);
3538
3539        let apply_result = self.apply(&rec);
3540        // For Batch frames, post-validation apply must be infallible (see above).
3541        // A debug_assert here catches any future change that makes apply fallible
3542        // before the caller notices via silent WAL/memory divergence.
3543        if matches!(&rec, WalRecord::Batch(_)) {
3544            debug_assert!(
3545                apply_result.is_ok(),
3546                "Batch apply returned Err after successful WAL write — \
3547                 the validate-then-apply invariant has been violated; \
3548                 see log_then_apply_with invariant doc"
3549            );
3550        }
3551        if apply_result.is_err() {
3552            // Discard any partial deltas accumulated by the failed apply.
3553            // They must not ride the next commit's event stream (I-1).
3554            // _emit_guard restores emit_deltas on drop automatically.
3555            let _ = self.engine.drain_deltas();
3556            let _ = self.engine.take_rebuild_needed();
3557            apply_result?;
3558        }
3559        self.commit_seq += 1;
3560        let seq = self.commit_seq;
3561        // Update per-node last-change map for the committed record.
3562        // Must happen after commit_seq is incremented so the seq is correct.
3563        self.update_last_change_from_rec(&rec, seq);
3564        // Drain engine deltas and distribute to subscribers before the existing
3565        // MutationEvent sink fires — both happen post-fsync, post-apply.
3566        // _emit_guard restores emit_deltas after this line when it drops.
3567        let engine_deltas = self.engine.drain_deltas();
3568
3569        // Append history-marker WAL records for any derived-edge changes so
3570        // that `edge_history` and `was_linked` can surface rule-attributed
3571        // events. Markers are STATE NO-OPS during replay; they are written
3572        // without an additional fsync (the triggering commit's sync already
3573        // happened; the next commit's sync covers these lazily).
3574        if !engine_deltas.is_empty() {
3575            let markers: Vec<WalRecord> = engine_deltas
3576                .iter()
3577                .map(|d| {
3578                    if d.fired {
3579                        WalRecord::DerivedEdgeAdded {
3580                            rule: d.rule.clone(),
3581                            edge_type: d.edge_type.clone(),
3582                            src_key: d.src_key.clone(),
3583                            dst_key: d.dst_key.clone(),
3584                        }
3585                    } else {
3586                        WalRecord::DerivedEdgeRetracted {
3587                            rule: d.rule.clone(),
3588                            edge_type: d.edge_type.clone(),
3589                            src_key: d.src_key.clone(),
3590                            dst_key: d.dst_key.clone(),
3591                        }
3592                    }
3593                })
3594                .collect();
3595            let marker_frame = if markers.len() == 1 {
3596                markers.into_iter().next().unwrap()
3597            } else {
3598                WalRecord::Batch(markers)
3599            };
3600            // Ignore append errors: markers are best-effort history
3601            // annotations. Losing them does not affect state correctness.
3602            let _ = self.fs.append(FileId::Wal, &encode_record(&marker_frame));
3603        }
3604
3605        // Record MVCC CommitDelta for the epoch reader.  The WAL record is
3606        // stored as-is (including any nested Batch / Intern records); the
3607        // ReaderSnapshot's apply_one function handles all variants.
3608        {
3609            let derived_inserts = engine_deltas
3610                .iter()
3611                .filter(|d| d.fired)
3612                .map(|d| (d.etype_sym, d.src_id, d.dst_id))
3613                .collect();
3614            let derived_deletes = engine_deltas
3615                .iter()
3616                .filter(|d| !d.fired)
3617                .map(|d| (d.etype_sym, d.src_id, d.dst_id))
3618                .collect();
3619            let delta = Arc::new(crate::reader::CommitDelta {
3620                records: vec![rec.clone()],
3621                derived_inserts,
3622                derived_deletes,
3623            });
3624            self.delta_tail.push(delta);
3625            self.commits_since_fold += 1;
3626            if self.commits_since_fold >= crate::reader::FOLD_EVERY_K {
3627                self.fold_now();
3628            }
3629        }
3630
3631        if self.defer_events {
3632            // Group-commit drain thread: hold events until after the group
3633            // fsync so subscribers only observe durable data (R2).
3634            self.deferred_events.push(DeferredEvent {
3635                rec: rec.clone(),
3636                engine_deltas,
3637                seq,
3638                ingest,
3639            });
3640        } else {
3641            self.distribute_events(&rec, &engine_deltas, seq);
3642            self.emit_committed(&rec, ingest);
3643        }
3644        // Drift is only known after apply, so auto-rebuild cannot join the
3645        // triggering op's WAL frame. Issue RebuildRule as a second commit.
3646        // Skip when `rec` is itself RebuildRule: rebuild resets drift, so a
3647        // retrigger loop is impossible if the fit succeeded, but we still
3648        // drain the flag so a leftover cannot re-enter.
3649        let rebuilds = self.engine.take_rebuild_needed();
3650        if !matches!(&rec, WalRecord::RebuildRule { .. }) {
3651            let mut failed = Vec::new();
3652            for name in rebuilds {
3653                if self.engine.rules().any(|r| r.name == name) {
3654                    // User op is already durable. A failed second commit must
3655                    // not surface as the caller's error.
3656                    if let Err(e) =
3657                        self.log_then_apply(WalRecord::RebuildRule { name: name.clone() })
3658                    {
3659                        eprintln!(
3660                            "auto-rebuild of rule {name:?} failed after durable user commit: {e}"
3661                        );
3662                        failed.push(name);
3663                    }
3664                }
3665            }
3666            for name in failed {
3667                self.engine.queue_rebuild_needed(name);
3668            }
3669        }
3670        Ok(())
3671    }
3672
3673    /// Install a post-commit hook. Replaces any previous sink.
3674    ///
3675    /// The sink runs inside `log_then_apply` after a successful
3676    /// durable commit, while the caller still holds `&mut self`. When this
3677    /// database is behind a [`crate::SharedDb`], that means the **write
3678    /// guard is held**. The sink must never call `read` / `write` (or any
3679    /// other method) on the same `SharedDb` — the `RwLock` is not
3680    /// re-entrant and doing so deadlocks. The sink is `Send + Sync`;
3681    /// `std::sync::mpsc::Sender` is not `Sync` and will not type-check.
3682    /// Intended examples: `std::sync::mpsc::SyncSender`,
3683    /// `tokio::sync::mpsc::Sender`, `tokio::sync::broadcast::Sender`
3684    /// (non-blocking `send`), or `Arc<Mutex<Vec<MutationEvent>>>`.
3685    pub fn set_event_sink(&mut self, sink: Box<dyn Fn(MutationEvent) + Send + Sync>) {
3686        self.event_sink = Some(sink);
3687    }
3688
3689    /// Whether a post-commit event sink is currently installed.
3690    pub fn has_event_sink(&self) -> bool {
3691        self.event_sink.is_some()
3692    }
3693
3694    /// Set WAL fsync cadence. Default [`FsyncPolicy::Strict`].
3695    pub fn set_fsync_policy(&mut self, p: FsyncPolicy) {
3696        self.fsync = p;
3697    }
3698
3699    /// Return the current WAL fsync cadence.
3700    pub fn fsync_policy(&self) -> FsyncPolicy {
3701        self.fsync
3702    }
3703
3704    // ── Group-commit event deferral ───────────────────────────────────────────
3705
3706    /// Enable or disable deferred event mode.
3707    ///
3708    /// When `true`, event notifications (subscription `DbEvent`s and legacy
3709    /// `MutationEvent` sink calls) are buffered rather than fired immediately.
3710    /// Call [`flush_deferred_events`] after the group fsync to deliver them,
3711    /// or [`discard_deferred_events`] if the fsync failed and the group must
3712    /// be treated as lost.
3713    pub fn set_deferred_events_mode(&mut self, defer: bool) {
3714        self.defer_events = defer;
3715    }
3716
3717    /// Fire all buffered events accumulated since [`set_deferred_events_mode`]
3718    /// was set to true.  Clears the buffer.
3719    ///
3720    /// Called by the drain thread AFTER a successful group fsync, so
3721    /// subscribers observe only data that is durably on disk.
3722    pub fn flush_deferred_events(&mut self) {
3723        let events = std::mem::take(&mut self.deferred_events);
3724        for de in events {
3725            self.distribute_events(&de.rec, &de.engine_deltas, de.seq);
3726            self.emit_committed(&de.rec, de.ingest);
3727        }
3728    }
3729
3730    /// Discard all buffered events without firing them.
3731    ///
3732    /// Called by the drain thread when a group fsync fails: the WAL has been
3733    /// truncated back to the pre-group offset, so the committed-but-unsynced
3734    /// ops must not be observable to subscribers.
3735    pub fn discard_deferred_events(&mut self) {
3736        self.deferred_events.clear();
3737    }
3738
3739    // ── Degraded state ────────────────────────────────────────────────────────
3740
3741    /// Mark this database as degraded.
3742    ///
3743    /// Called by the group-commit drain thread after a group fsync failure and
3744    /// WAL truncation: the in-memory state is now ahead of the on-disk WAL, so
3745    /// further mutations would deepen the divergence.  All subsequent calls to
3746    /// [`log_then_apply_with`] return `Err` until the database is reopened.
3747    pub fn set_degraded(&mut self) {
3748        self.degraded = true;
3749    }
3750
3751    fn emit(&self, ev: MutationEvent) {
3752        if let Some(sink) = &self.event_sink {
3753            sink(ev);
3754        }
3755    }
3756
3757    fn emit_committed(&self, rec: &WalRecord, ingest: Option<(String, usize)>) {
3758        match rec {
3759            WalRecord::Batch(inner) => {
3760                for r in inner {
3761                    if let Some(ev) = event_from_record(r, &self.syms, &self.ids) {
3762                        self.emit(ev);
3763                    }
3764                }
3765                match ingest {
3766                    Some((label, inserted)) => {
3767                        self.emit(MutationEvent::Ingested { label, inserted })
3768                    }
3769                    None => {
3770                        let ops = inner
3771                            .iter()
3772                            .filter(|r| !matches!(r, WalRecord::Intern { .. }))
3773                            .count();
3774                        if ops > 1 {
3775                            self.emit(MutationEvent::BatchApplied { ops });
3776                        }
3777                    }
3778                }
3779            }
3780            other => {
3781                if let Some(ev) = event_from_record(other, &self.syms, &self.ids) {
3782                    self.emit(ev);
3783                }
3784            }
3785        }
3786    }
3787
3788    // -----------------------------------------------------------------------
3789    // Subscription API
3790    // -----------------------------------------------------------------------
3791
3792    /// Distribute post-commit events to all live subscribers.
3793    ///
3794    /// Build a row-key → row-data map from a [`ResultSet`].
3795    ///
3796    /// Each row is serialized to JSON to form its key; a debug fallback is used
3797    /// if serialization fails. Used by both the initial-seed path in
3798    /// [`Self::subscribe_query`] and the per-commit diff path in
3799    /// [`Self::distribute_events`] to keep the two in sync.
3800    fn result_to_row_map(
3801        result: &core_query::ResultSet,
3802    ) -> std::collections::HashMap<String, Vec<Option<Value>>> {
3803        (0..result.len())
3804            .map(|i| {
3805                let row = result.row(i).to_vec();
3806                let key = serde_json::to_string(&row).unwrap_or_else(|_| format!("{row:?}"));
3807                (key, row)
3808            })
3809            .collect()
3810    }
3811
3812    /// Collect the set of label syms touched by a WAL record.
3813    ///
3814    /// Returns `Some(set)` when every record in this commit can be attributed to
3815    /// a known label sym. Returns `None` when the commit must not be skipped:
3816    /// edge records, unresolvable key→label lookups, or any record type not in
3817    /// the explicit handled set.
3818    ///
3819    /// Handled record types and their actions:
3820    /// - `InsertNode`   → look up label in interner (fails → None)
3821    /// - `InsertNodeId` → label sym is carried directly
3822    /// - `SetProp`      → resolve key→id→label (fails → None)
3823    /// - `DeleteNode`   → resolve key→id→label (fails → None)
3824    /// - `Batch`        → recurse into every inner record
3825    /// - `InsertEdge`, `DeleteEdge`, `InsertEdgeId` → always None (edge records)
3826    /// - everything else → None (conservative)
3827    fn commit_touched_labels(
3828        rec: &WalRecord,
3829        syms: &Interner,
3830        ids: &IdMap,
3831        labels: &[u32],
3832    ) -> Option<BTreeSet<u32>> {
3833        let mut out = BTreeSet::new();
3834        if Self::collect_touched_labels(rec, syms, ids, labels, &mut out) {
3835            Some(out)
3836        } else {
3837            None
3838        }
3839    }
3840
3841    fn collect_touched_labels(
3842        rec: &WalRecord,
3843        syms: &Interner,
3844        ids: &IdMap,
3845        labels: &[u32],
3846        out: &mut BTreeSet<u32>,
3847    ) -> bool {
3848        match rec {
3849            // String-key insert: the dense rewrite converts this to
3850            // [Intern, InsertNodeId], so this arm fires only for legacy WAL
3851            // records written before the dense path was added.
3852            WalRecord::InsertNode { label, .. } => {
3853                if let Some(sym) = syms.get(label) {
3854                    out.insert(sym);
3855                    true
3856                } else {
3857                    false
3858                }
3859            }
3860            // Dense-id insert (produced by rewrite_wal_dense for every
3861            // insert_node call in the current codebase).
3862            WalRecord::InsertNodeId { label, .. } => {
3863                out.insert(*label);
3864                true
3865            }
3866            // String-key prop set: dense path converts to [Intern, SetPropId].
3867            WalRecord::SetProp { key, .. } => {
3868                if let Some(sym) = Self::resolve_key_label_sym(key, ids, labels) {
3869                    out.insert(sym);
3870                    true
3871                } else {
3872                    false
3873                }
3874            }
3875            // Dense-id prop set (produced by rewrite_wal_dense for set_prop).
3876            WalRecord::SetPropId { id, .. } => {
3877                if let Some(sym) = labels.get(*id as usize).copied().filter(|&s| s != u32::MAX) {
3878                    out.insert(sym);
3879                    true
3880                } else {
3881                    false
3882                }
3883            }
3884            WalRecord::DeleteNode { key } => {
3885                if let Some(sym) = Self::resolve_key_label_sym(key, ids, labels) {
3886                    out.insert(sym);
3887                    true
3888                } else {
3889                    false
3890                }
3891            }
3892            WalRecord::Batch(inner) => inner
3893                .iter()
3894                .all(|r| Self::collect_touched_labels(r, syms, ids, labels, out)),
3895            // Intern is a pure metadata record — it does not touch any node's
3896            // label and is safe to skip for the label-skip predicate.
3897            WalRecord::Intern { .. } => true,
3898            // Edge records: always re-execute (edges can change join results).
3899            WalRecord::InsertEdge { .. }
3900            | WalRecord::DeleteEdge { .. }
3901            | WalRecord::InsertEdgeId { .. } => false,
3902            _ => false,
3903        }
3904    }
3905
3906    /// Resolve a node key to its label sym via the dense id table.
3907    /// Returns `None` if the key is unknown or the label is a tombstone sentinel.
3908    fn resolve_key_label_sym(key: &str, ids: &IdMap, labels: &[u32]) -> Option<u32> {
3909        let id = ids.get(key)?;
3910        let sym = labels.get(id as usize).copied()?;
3911        (sym != u32::MAX).then_some(sym)
3912    }
3913
3914    /// Distribute post-commit events to all live subscribers.
3915    ///
3916    /// Called from `log_then_apply_with` after apply + fsync, before the
3917    /// legacy MutationEvent sink. Prunes dead `Weak` entries in-place.
3918    ///
3919    /// Query subscriptions (subscribe_query) re-execute their plan on every
3920    /// call and diff the result against the previous run. Zero overhead when
3921    /// no query subscriptions are active.
3922    fn distribute_events(&mut self, rec: &WalRecord, engine_deltas: &[EngineEdgeDelta], seq: u64) {
3923        if self.subscriptions.is_empty() && self.query_subscriptions.is_empty() {
3924            return;
3925        }
3926
3927        if !self.subscriptions.is_empty() {
3928            // Build write events from the WAL record.
3929            let write_events: Vec<DbEvent> =
3930                Self::write_events_from_record(rec, seq, &self.syms, &self.ids);
3931
3932            // Build edge events from engine deltas.  Weight is looked up from
3933            // edge_props at distribution time (after apply), so it's always fresh.
3934            let edge_events: Vec<DbEvent> = engine_deltas
3935                .iter()
3936                .map(|d| {
3937                    if d.fired {
3938                        let weight = self
3939                            .edge_props
3940                            .get(d.etype_sym, d.src_id, d.dst_id, "weight")
3941                            .and_then(|v| {
3942                                if let core_storage::Value::Float(f) = v {
3943                                    Some(*f)
3944                                } else {
3945                                    None
3946                                }
3947                            });
3948                        DbEvent::EdgeFired {
3949                            rule: d.rule.clone(),
3950                            src_key: d.src_key.clone(),
3951                            dst_key: d.dst_key.clone(),
3952                            edge_type: d.edge_type.clone(),
3953                            weight,
3954                            commit_seq: seq,
3955                        }
3956                    } else {
3957                        DbEvent::EdgeRetracted {
3958                            rule: d.rule.clone(),
3959                            src_key: d.src_key.clone(),
3960                            dst_key: d.dst_key.clone(),
3961                            edge_type: d.edge_type.clone(),
3962                            commit_seq: seq,
3963                        }
3964                    }
3965                })
3966                .collect();
3967
3968            // Prune dead entries; push matching events to live ones.
3969            self.subscriptions.retain(|entry| {
3970                let Some(inner) = entry.inner.upgrade() else {
3971                    return false;
3972                };
3973                for ev in &write_events {
3974                    if event_matches(ev, &entry.filter) {
3975                        inner.push(ev.clone());
3976                    }
3977                }
3978                for ev in &edge_events {
3979                    if event_matches(ev, &entry.filter) {
3980                        inner.push(ev.clone());
3981                    }
3982                }
3983                true
3984            });
3985
3986            // Turn off delta accumulation if all subscribers dropped and no views remain.
3987            if self.subscriptions.is_empty() && self.view_store.is_empty() {
3988                self.engine.set_emit_deltas(false);
3989            }
3990        }
3991
3992        // Query subscriptions: full re-run per commit, then diff rows.
3993        // IMPORTANT: full re-execution on every commit — use LIMIT to bound cost.
3994        // Differential evaluation is roadmap / Phase 5.
3995        if !self.query_subscriptions.is_empty() {
3996            // Take the list out so we can call self.view() without borrow conflict.
3997            let mut query_subs = std::mem::take(&mut self.query_subscriptions);
3998            let empty_params = BTreeMap::new();
3999            query_subs.retain_mut(|entry| {
4000                let Some(inner) = entry.inner.upgrade() else {
4001                    return false; // subscriber dropped — prune
4002                };
4003                // Label-skip: if the plan has a known scan label and this commit
4004                // can be proven to touch only different labels (and no rule-derived
4005                // edge deltas fired), the result set cannot have changed — skip.
4006                if let Some(scan_sym) = entry.scan_label {
4007                    if engine_deltas.is_empty() {
4008                        let touched =
4009                            Self::commit_touched_labels(rec, &self.syms, &self.ids, &self.labels);
4010                        if touched.map(|t| !t.contains(&scan_sym)).unwrap_or(false) {
4011                            return true; // safe to skip — result set unchanged
4012                        }
4013                    }
4014                }
4015                QUERY_SUB_EXECS_TL.with(|c| c.set(c.get() + 1));
4016                let result = match execute(&self.view(), &entry.ops, &Params(&empty_params)) {
4017                    Ok(r) => r,
4018                    Err(e) => {
4019                        // Keep the subscription alive; skip the diff for this commit.
4020                        // Re-run errors are transient (e.g., planner change) and
4021                        // self-heal when the next commit succeeds.
4022                        eprintln!("[mushroomdb] subscribe_query re-run failed: {e}");
4023                        return true;
4024                    }
4025                };
4026                // Build new row map: serialized-key → row data.
4027                let new_row_map = Self::result_to_row_map(&result);
4028                // Removed rows: in prev but not in new.
4029                for (key, row) in &entry.prev_row_map {
4030                    if !new_row_map.contains_key(key) {
4031                        inner.push(DbEvent::QueryRowRemoved {
4032                            columns: entry.columns.clone(),
4033                            row: row.clone(),
4034                        });
4035                    }
4036                }
4037                // Added rows: in new but not in prev.
4038                for (key, row) in &new_row_map {
4039                    if !entry.prev_row_map.contains_key(key) {
4040                        inner.push(DbEvent::QueryRowAdded {
4041                            columns: entry.columns.clone(),
4042                            row: row.clone(),
4043                        });
4044                    }
4045                }
4046                entry.prev_row_map = new_row_map;
4047                true
4048            });
4049            self.query_subscriptions = query_subs;
4050        }
4051    }
4052
4053    /// Returns `true` if any live subscriber or view definition requires delta
4054    /// accumulation. Used to set `engine.emit_deltas` on subscribe/view DDL.
4055    fn needs_emit_deltas(&self) -> bool {
4056        !self.view_store.is_empty()
4057            || self
4058                .subscriptions
4059                .iter()
4060                .any(|e| e.inner.upgrade().is_some())
4061    }
4062
4063    /// Convert a WAL record into `DbEvent` write events with the given seq.
4064    fn write_events_from_record(
4065        rec: &WalRecord,
4066        seq: u64,
4067        intern: &Interner,
4068        ids: &IdMap,
4069    ) -> Vec<DbEvent> {
4070        match rec {
4071            WalRecord::InsertNode { label, key, .. } => vec![DbEvent::NodeInserted {
4072                label: label.clone(),
4073                key: key.clone(),
4074                commit_seq: seq,
4075            }],
4076            // *Id arms run after a successful apply, so resolution can only
4077            // fail on a programming error. Skip the event rather than emit a
4078            // fabricated "" that clients can't tell from a real empty value
4079            // (mirrors event_from_record returning None).
4080            WalRecord::InsertNodeId { label, key, .. } => intern
4081                .resolve(*label)
4082                .map(|label| DbEvent::NodeInserted {
4083                    label: label.to_string(),
4084                    key: key.clone(),
4085                    commit_seq: seq,
4086                })
4087                .into_iter()
4088                .collect(),
4089            WalRecord::SetProp { key, field, .. } => vec![DbEvent::PropSet {
4090                key: key.clone(),
4091                field: field.clone(),
4092                commit_seq: seq,
4093            }],
4094            WalRecord::SetPropId { id, field, .. } => ids
4095                .key_of(*id)
4096                .zip(intern.resolve(*field))
4097                .map(|(key, field)| DbEvent::PropSet {
4098                    key: key.to_string(),
4099                    field: field.to_string(),
4100                    commit_seq: seq,
4101                })
4102                .into_iter()
4103                .collect(),
4104            WalRecord::RemoveProp { key, field } => vec![DbEvent::PropRemoved {
4105                key: key.clone(),
4106                field: field.clone(),
4107                commit_seq: seq,
4108            }],
4109            WalRecord::InsertEdge {
4110                edge_type,
4111                src_key,
4112                dst_key,
4113            } => vec![DbEvent::EdgeInserted {
4114                edge_type: edge_type.clone(),
4115                src: src_key.clone(),
4116                dst: dst_key.clone(),
4117                commit_seq: seq,
4118            }],
4119            WalRecord::InsertEdgeId { etype, src, dst } => (|| {
4120                Some(DbEvent::EdgeInserted {
4121                    edge_type: intern.resolve(*etype)?.to_string(),
4122                    src: ids.key_of(*src)?.to_string(),
4123                    dst: ids.key_of(*dst)?.to_string(),
4124                    commit_seq: seq,
4125                })
4126            })()
4127            .into_iter()
4128            .collect(),
4129            WalRecord::DeleteEdge {
4130                edge_type,
4131                src_key,
4132                dst_key,
4133            } => vec![DbEvent::EdgeDeleted {
4134                edge_type: edge_type.clone(),
4135                src: src_key.clone(),
4136                dst: dst_key.clone(),
4137                commit_seq: seq,
4138            }],
4139            WalRecord::DeleteNode { key } => vec![DbEvent::NodeDeleted {
4140                key: key.clone(),
4141                commit_seq: seq,
4142            }],
4143            WalRecord::Batch(inner) => inner
4144                .iter()
4145                .flat_map(|r| Self::write_events_from_record(r, seq, intern, ids))
4146                .collect(),
4147            WalRecord::CreateRule { .. }
4148            | WalRecord::DeleteRule { .. }
4149            | WalRecord::RebuildRule { .. }
4150            | WalRecord::CreateView { .. }
4151            | WalRecord::DeleteView { .. }
4152            | WalRecord::EnableFulltext { .. }
4153            | WalRecord::DisableFulltext { .. }
4154            | WalRecord::EnableIndex { .. }
4155            | WalRecord::DisableIndex { .. }
4156            | WalRecord::Intern { .. }
4157            // History markers produce no DbEvent — the engine delta already
4158            // fired the EdgeFired/EdgeRetracted subscription events.
4159            | WalRecord::DerivedEdgeAdded { .. }
4160            | WalRecord::DerivedEdgeRetracted { .. }
4161            | WalRecord::RenameNode { .. } => vec![],
4162        }
4163    }
4164
4165    /// Subscribe to edge-fire and edge-retract events for one named rule.
4166    ///
4167    /// Returns `Err(GraphError::RuleNotFound)` if `rule_name` is not
4168    /// currently registered. Dropping the returned [`Subscription`] handle
4169    /// unregisters the subscriber — no further events are queued, no
4170    /// resources leak.
4171    pub fn subscribe_rule(&mut self, rule_name: &str) -> core_storage::Result<Subscription> {
4172        if self.read_only {
4173            return Err(core_storage::GraphError::ReadOnly);
4174        }
4175        if !self.engine.rules().any(|r| r.name == rule_name) {
4176            return Err(core_storage::GraphError::RuleNotFound {
4177                name: rule_name.to_string(),
4178            });
4179        }
4180        let inner = SubInner::new(self.sub_capacity());
4181        self.subscriptions.push(SubEntry {
4182            filter: SubFilter::Rule(rule_name.to_string()),
4183            inner: std::sync::Arc::downgrade(&inner),
4184        });
4185        self.engine.set_emit_deltas(true);
4186        Ok(Subscription(inner))
4187    }
4188
4189    /// Subscribe to edge-fire and edge-retract events for **all** rules.
4190    ///
4191    /// Returns `Err(GraphError::ReadOnly)` if called on an as-of instance —
4192    /// as-of instances never commit, so `distribute_events` never runs and the
4193    /// subscription would never deliver events.
4194    pub fn subscribe_all_rules(&mut self) -> core_storage::Result<Subscription> {
4195        if self.read_only {
4196            return Err(core_storage::GraphError::ReadOnly);
4197        }
4198        let inner = SubInner::new(self.sub_capacity());
4199        self.subscriptions.push(SubEntry {
4200            filter: SubFilter::AllRules,
4201            inner: std::sync::Arc::downgrade(&inner),
4202        });
4203        self.engine.set_emit_deltas(true);
4204        Ok(Subscription(inner))
4205    }
4206
4207    /// Subscribe to write events: node insert/delete, prop set/remove.
4208    ///
4209    /// Does not include edge-fire / edge-retract (rule-derived edge events).
4210    ///
4211    /// Returns `Err(GraphError::ReadOnly)` if called on an as-of instance —
4212    /// as-of instances never commit, so `distribute_events` never runs and the
4213    /// subscription would never deliver events.
4214    pub fn subscribe_writes(&mut self) -> core_storage::Result<Subscription> {
4215        if self.read_only {
4216            return Err(core_storage::GraphError::ReadOnly);
4217        }
4218        let inner = SubInner::new(self.sub_capacity());
4219        self.subscriptions.push(SubEntry {
4220            filter: SubFilter::Writes,
4221            inner: std::sync::Arc::downgrade(&inner),
4222        });
4223        self.engine.set_emit_deltas(true);
4224        Ok(Subscription(inner))
4225    }
4226
4227    /// Subscribe to incremental Cypher query results.
4228    ///
4229    /// Parses and plans `cypher`; rejects the query if the plan is not in the
4230    /// allowlisted subset (see [`core_query::cypher::is_subscribable`]):
4231    ///   - `MATCH (n:Label) WHERE … RETURN … [LIMIT n]`
4232    ///   - `MATCH (a)-[r:TYPE]->(b) RETURN … [LIMIT n]`  (exactly one hop)
4233    ///
4234    /// SKIP is not supported — it shifts the result window on every commit,
4235    /// causing spurious Added/Removed churn for rows whose data never changed.
4236    /// Multi-hop Expand chains are not supported; each additional MATCH clause
4237    /// widens scope beyond the documented single-scan / single-hop subset.
4238    ///
4239    /// After each successful commit, the plan is **fully re-executed** and the
4240    /// result is diffed against the previous run. Added rows produce
4241    /// [`DbEvent::QueryRowAdded`]; removed rows produce
4242    /// [`DbEvent::QueryRowRemoved`].
4243    ///
4244    /// **Full re-run per commit; use LIMIT to bound execution cost.**
4245    /// The existing 1 M intermediate-row cap applies. Differential evaluation
4246    /// is roadmap / Phase 5.
4247    ///
4248    /// Returns `Err(GraphError::ReadOnly)` if called on an as-of instance —
4249    /// as-of instances never commit, so `distribute_events` never runs and the
4250    /// subscription would never deliver events.
4251    ///
4252    /// Returns `Err(GraphError::QueryError)` if the query fails to parse, plan,
4253    /// or if the plan shape is not in the allowlist.
4254    pub fn subscribe_query(&mut self, cypher: &str) -> Result<Subscription> {
4255        if self.read_only {
4256            return Err(GraphError::ReadOnly);
4257        }
4258        let tokens = lex(cypher).map_err(|e| GraphError::QueryError {
4259            detail: format!("lex: {e}"),
4260        })?;
4261        let ast = parse(&tokens).map_err(|e| GraphError::QueryError {
4262            detail: format!("parse: {e}"),
4263        })?;
4264        let ops = plan(&ast).map_err(|e| GraphError::QueryError {
4265            detail: format!("plan: {e}"),
4266        })?;
4267        if !is_subscribable(&ops) {
4268            return Err(GraphError::QueryError {
4269                detail: "subscribe_query only supports allowlisted plan shapes: \
4270                         MATCH (n:Label) WHERE … RETURN … [LIMIT n] or \
4271                         MATCH (a)-[r:TYPE]->(b) RETURN … [LIMIT n] (exactly one hop). \
4272                         Not supported: multi-hop Expand chains, SKIP (creates \
4273                         unstable offset windows), ORDER BY, DISTINCT, aggregates, \
4274                         variable-length paths, OPTIONAL MATCH, WITH, UNWIND. \
4275                         Use LIMIT to bound re-execution cost."
4276                    .to_string(),
4277            });
4278        }
4279        // Execute once to capture initial state (initial rows are not emitted as
4280        // events — the subscriber learns the baseline via the first query call).
4281        let empty_params = BTreeMap::new();
4282        let initial = execute(&self.view(), &ops, &Params(&empty_params)).map_err(|e| {
4283            GraphError::QueryError {
4284                detail: format!("execute: {e}"),
4285            }
4286        })?;
4287        let columns = initial.columns().to_vec();
4288        let prev_row_map = Self::result_to_row_map(&initial);
4289        let inner = SubInner::new(self.sub_capacity());
4290        // Derive the scan-label sym for the commit-skip fast-path.  Any Expand op
4291        // or unrecognized leading scan → None (always re-execute).
4292        let scan_label = extract_scan_label(&ops, &mut self.syms);
4293        self.query_subscriptions.push(QuerySubEntry {
4294            ops,
4295            columns,
4296            prev_row_map,
4297            inner: std::sync::Arc::downgrade(&inner),
4298            scan_label,
4299        });
4300        Ok(Subscription(inner))
4301    }
4302
4303    /// Queue capacity used for new subscriptions.
4304    fn sub_capacity(&self) -> usize {
4305        self.sub_capacity
4306    }
4307
4308    /// Override per-subscriber queue capacity for subsequently created
4309    /// subscriptions on this db instance.
4310    ///
4311    /// Default is [`DEFAULT_SUB_CAPACITY`] (65,536 events). Use a smaller
4312    /// value in tests to exercise the [`DbEvent::Lagged`] path without
4313    /// generating tens of thousands of events.
4314    ///
4315    /// This is a test-support escape hatch. Calling it in production reduces
4316    /// subscriber reliability (more Lagged events). It is hidden from rustdoc
4317    /// to discourage accidental production use.
4318    #[doc(hidden)]
4319    pub fn set_sub_capacity(&mut self, capacity: usize) {
4320        self.sub_capacity = capacity;
4321    }
4322
4323    // -----------------------------------------------------------------------
4324
4325    /// Start an atomic batch.
4326    ///
4327    /// The returned [`BatchBuilder`] borrows `self` mutably until
4328    /// [`BatchBuilder::commit`]. Builder methods queue ops only — no
4329    /// validation, no WAL I/O. `commit` validates every queued op against
4330    /// live state plus preceding ops in this batch (duplicate key inside
4331    /// the batch is `Err`; an edge between two nodes created earlier in
4332    /// the batch is valid; `delete_node` then insert of the same key is a
4333    /// fresh identity). Validation never mutates the database. Any failure
4334    /// leaves WAL bytes and in-memory state identical to before `commit`.
4335    /// On success, one `WalRecord::Batch` frame is appended (one fsync)
4336    /// and each inner record is applied in order so rules fire per record.
4337    /// An empty batch, or a batch of only no-ops, writes zero WAL bytes.
4338    ///
4339    /// **Rule-window limitation:** batch validation cannot see edges that a
4340    /// rule created earlier in the *same* batch will derive at apply time, so
4341    /// a `delete_edge` / `insert_edge` in that window is silently no-oped
4342    /// where sequential calls would return `Err(RuleOwned)`. State integrity
4343    /// is unaffected (idempotent apply, provenance intact). Create rules in
4344    /// their own batch, or sequentially, when later ops may touch derived
4345    /// edges.
4346    pub fn batch(&mut self) -> BatchBuilder<'_, F> {
4347        BatchBuilder {
4348            db: self,
4349            ops: Vec::new(),
4350        }
4351    }
4352
4353    /// Closure-style atomic write batch.
4354    ///
4355    /// Equivalent to calling [`GraphDb::batch`], invoking `build` to queue ops,
4356    /// then committing. All ops queued inside `build` are validated in order and
4357    /// committed as a single `WalRecord::Batch` frame (one fsync). Rules fire
4358    /// once per inner record, in order, after commit — semantically identical to
4359    /// sequential single-op writes.
4360    ///
4361    /// **Error semantics — validate-then-apply.** `build` queues ops without
4362    /// touching the database. [`BatchBuilder::commit`] validates every op against
4363    /// live state plus earlier ops in this batch before writing anything. If op N
4364    /// fails validation (duplicate key, unknown key, rule-owned edge, …) the
4365    /// entire batch is rejected: no WAL bytes are written and no in-memory state
4366    /// changes. The database is identical to its state before `write_batch` was
4367    /// called.
4368    ///
4369    /// **Atomicity is crash-level, NOT isolation-level.** On replay after a crash,
4370    /// a partial (torn) `Batch` frame applies NONE of its ops — the frame is
4371    /// either fully applied or not at all. However, while applying a committed
4372    /// batch, concurrent readers may observe intermediate states as ops are applied
4373    /// sequentially in memory. There is no interactive transaction isolation in v1.
4374    /// This is documented as "crash-atomic write batches; no interactive
4375    /// transactions or read isolation."
4376    ///
4377    /// **Returns** `(nodes_inserted, edges_inserted)`. An empty or all-noop batch
4378    /// writes zero WAL bytes and returns `(0, 0)`.
4379    ///
4380    /// # Example
4381    ///
4382    /// ```rust,ignore
4383    /// let (nodes, edges) = db.write_batch(|b| {
4384    ///     b.insert_node("Person", "alice", vec![("age".into(), Value::Int(30))]);
4385    ///     b.insert_node("Person", "bob", vec![]);
4386    ///     b.insert_edge("KNOWS", "alice", "bob");
4387    ///     b.set_prop("alice", "role", Value::Str("admin".into()));
4388    ///     b.delete_node("old_key");
4389    /// })?;
4390    /// // One fsync; on crash replay: all five ops land or none do.
4391    /// ```
4392    pub fn write_batch<C>(&mut self, build: C) -> Result<(usize, usize)>
4393    where
4394        C: FnOnce(&mut BatchBuilder<'_, F>),
4395    {
4396        let mut b = self.batch();
4397        build(&mut b);
4398        b.commit()
4399    }
4400
4401    /// Insert `rows` as nodes of `label`. One call is one atomic batch:
4402    /// auto-declared KeyMatch rules (if any) first, then the accepted node
4403    /// inserts, so incremental fire sees the new rules. Per-row key problems
4404    /// are collected in [`IngestReport::row_errors`] and skipped; a commit
4405    /// `Err` means nothing was applied.
4406    ///
4407    /// Auto-FK rule names are `auto_fk_<src_label_lowercase>_<field>` so
4408    /// distinct source labels sharing an FK field each get their own rule.
4409    pub fn ingest(
4410        &mut self,
4411        label: &str,
4412        rows: Vec<BTreeMap<String, Value>>,
4413        opts: &IngestOptions,
4414    ) -> Result<IngestReport> {
4415        self.ingest_with_edges(label, rows, opts, &[])
4416    }
4417
4418    /// [`ingest`] plus user edges in the **same** previewed WAL batch.
4419    /// A failing edge rejects the whole request; nothing is applied.
4420    pub fn ingest_with_edges(
4421        &mut self,
4422        label: &str,
4423        rows: Vec<BTreeMap<String, Value>>,
4424        opts: &IngestOptions,
4425        edges: &[(String, String, String)],
4426    ) -> Result<IngestReport> {
4427        crate::ingest::run(self, label, rows, opts, edges)
4428    }
4429
4430    /// Parse `json` as an array of objects and ingest via [`GraphDb::ingest`].
4431    ///
4432    /// JSON `null` fields are silently omitted (not stored, not a row error).
4433    /// Nested objects and arrays-of-objects are a per-row error (row skipped).
4434    /// Parse failures and a top-level value that is not an array of objects
4435    /// return [`GraphError::IngestError`].
4436    pub fn ingest_json(
4437        &mut self,
4438        label: &str,
4439        json: &str,
4440        opts: &IngestOptions,
4441    ) -> Result<IngestReport> {
4442        crate::ingest::run_json(self, label, json, opts)
4443    }
4444
4445    fn commit_logged_batch(
4446        &mut self,
4447        ops: Vec<BatchOp>,
4448        ingest: Option<(String, usize)>,
4449        // Two-source rule: write_batch_authz threads authz here directly (never
4450        // touches pending_write_authz); query_write_authz sets the field instead
4451        // and passes None.  Only one source is non-None per call.
4452        param_authz: Option<WriteAuthz>,
4453    ) -> Result<(usize, usize)> {
4454        // Read-only guard: catches empty-batch calls before the early-return
4455        // that skips log_then_apply_with, ensuring all mutation entry points fail.
4456        if self.read_only {
4457            return Err(GraphError::ReadOnly);
4458        }
4459        // Ensure provenance is decoded before MutPreview accesses it
4460        // (note_delete_rule / is_rule_owned may call engine.provenance()).
4461        self.engine.ensure_provenance_loaded_mut();
4462
4463        // ── Authz pre-check ──────────────────────────────────────────────────
4464        // Evaluate the decision table per-op BEFORE MutPreview so that a denial
4465        // produces no WAL frame (all-or-nothing at the authz boundary extends
4466        // the existing validate-then-apply contract to role-scope checks).
4467        //
4468        // `batch_created` tracks key→label for nodes created by earlier ops in
4469        // THIS batch, so InsertEdgeUpsert can count same-batch placeholder nodes
4470        // as visible without needing to call `self.ids.get` on not-yet-committed
4471        // keys (they won't be there yet).
4472        //
4473        // Two-source rule: param_authz (write_batch_authz path) takes precedence;
4474        // fall back to self.pending_write_authz (query_write_authz/Cypher path).
4475        // Cloning the field copy avoids a simultaneous borrow of self.ids below.
4476        let authz_opt = param_authz.or_else(|| self.pending_write_authz.clone());
4477        if let Some(ref authz) = authz_opt {
4478            let mut batch_created: BTreeMap<String, String> = BTreeMap::new();
4479            for op in &ops {
4480                self.check_single_op_authz(authz, op, &batch_created)?;
4481                // Update batch_created after a passing authz check so that
4482                // subsequent ops in this batch see the nodes as "about to exist".
4483                match op {
4484                    BatchOp::InsertNode { label, key, .. } => {
4485                        // Only track genuinely new nodes (absent from the
4486                        // snapshot at authz-check time). A pre-existing visible
4487                        // key would be a DuplicateKey — not a real creation —
4488                        // so MutPreview handles it. Letting it into batch_created
4489                        // would allow a later SetProp to bypass update_labels
4490                        // via the "batch-created → always updatable" ruling
4491                        // (delete+recreate exploit, fix for I1 review round 2).
4492                        //
4493                        // Accepted edge: for a delete+recreate-with-different-
4494                        // label batch, node_status resolves the pre-delete
4495                        // (store) label for any subsequent update checks. This
4496                        // grants no net-new capability — a role that can delete+
4497                        // create can already place arbitrary props via
4498                        // InsertNode's own props field.
4499                        if self.ids.get(key.as_str()).is_none() {
4500                            batch_created.insert(key.clone(), label.clone());
4501                        }
4502                    }
4503                    BatchOp::InsertEdgeUpsert {
4504                        placeholder_label,
4505                        src_key,
4506                        dst_key,
4507                        ..
4508                    } => {
4509                        // Both endpoints will be created if not already in store.
4510                        for ep_key in [src_key, dst_key] {
4511                            if self.ids.get(ep_key.as_str()).is_none()
4512                                && !batch_created.contains_key(ep_key.as_str())
4513                            {
4514                                batch_created.insert(ep_key.clone(), placeholder_label.clone());
4515                            }
4516                        }
4517                    }
4518                    _ => {}
4519                }
4520            }
4521        }
4522
4523        let recs = {
4524            let mut preview = MutPreview::new(self);
4525            let mut recs = Vec::with_capacity(ops.len());
4526            for op in ops {
4527                match op {
4528                    BatchOp::InsertNode { label, key, props } => {
4529                        preview.check_insert_node(&key)?;
4530                        preview.note_insert_node(&key, &props);
4531                        recs.push(WalRecord::InsertNode { label, key, props });
4532                    }
4533                    BatchOp::InsertEdge {
4534                        edge_type,
4535                        src_key,
4536                        dst_key,
4537                    } => {
4538                        if preview.prepare_insert_edge(&edge_type, &src_key, &dst_key)? {
4539                            preview.note_insert_edge(&edge_type, &src_key, &dst_key);
4540                            recs.push(WalRecord::InsertEdge {
4541                                edge_type,
4542                                src_key,
4543                                dst_key,
4544                            });
4545                        }
4546                    }
4547                    BatchOp::SetProp { key, field, value } => {
4548                        preview.check_live_key(&key)?;
4549                        preview.note_set_prop(&key, &field, &value);
4550                        recs.push(WalRecord::SetProp { key, field, value });
4551                    }
4552                    BatchOp::RemoveProp { key, field } => {
4553                        if preview.prepare_remove_prop(&key, &field)? {
4554                            preview.note_remove_prop(&key, &field);
4555                            recs.push(WalRecord::RemoveProp { key, field });
4556                        }
4557                    }
4558                    BatchOp::DeleteEdge {
4559                        edge_type,
4560                        src_key,
4561                        dst_key,
4562                    } => {
4563                        if preview.prepare_delete_edge(&edge_type, &src_key, &dst_key)? {
4564                            preview.note_delete_edge(&edge_type, &src_key, &dst_key);
4565                            recs.push(WalRecord::DeleteEdge {
4566                                edge_type,
4567                                src_key,
4568                                dst_key,
4569                            });
4570                        }
4571                    }
4572                    BatchOp::DeleteNode { key } => {
4573                        preview.check_live_key(&key)?;
4574                        preview.note_delete_node(&key);
4575                        recs.push(WalRecord::DeleteNode { key });
4576                    }
4577                    BatchOp::CreateRule(def) => {
4578                        preview.check_create_rule(&def)?;
4579                        let def_bytes =
4580                            bincode::serialize(&def).map_err(|e| GraphError::Corrupt {
4581                                detail: format!("serialize rule: {e}"),
4582                            })?;
4583                        preview.note_create_rule(&def.name);
4584                        recs.push(WalRecord::CreateRule { def_bytes });
4585                    }
4586                    BatchOp::DeleteRule { name } => {
4587                        preview.check_delete_rule(&name)?;
4588                        preview.note_delete_rule(&name);
4589                        recs.push(WalRecord::DeleteRule { name });
4590                    }
4591                    BatchOp::RenameNode { old_key, new_key } => {
4592                        preview.check_rename_node(&old_key, &new_key)?;
4593                        preview.note_rename_node(&old_key, &new_key);
4594                        recs.push(WalRecord::RenameNode { old_key, new_key });
4595                    }
4596                    BatchOp::InsertEdgeUpsert {
4597                        edge_type,
4598                        src_key,
4599                        dst_key,
4600                        placeholder_label,
4601                    } => {
4602                        // Auto-create any missing endpoints as plain InsertNode ops.
4603                        // Rules fire and last-change is updated for each created node.
4604                        for key in [&src_key, &dst_key] {
4605                            if !preview.has_key(key) {
4606                                preview.check_insert_node(key)?;
4607                                preview.note_insert_node(key, &[]);
4608                                recs.push(WalRecord::InsertNode {
4609                                    label: placeholder_label.clone(),
4610                                    key: key.clone(),
4611                                    props: vec![],
4612                                });
4613                            }
4614                        }
4615                        if preview.prepare_insert_edge(&edge_type, &src_key, &dst_key)? {
4616                            preview.note_insert_edge(&edge_type, &src_key, &dst_key);
4617                            recs.push(WalRecord::InsertEdge {
4618                                edge_type,
4619                                src_key,
4620                                dst_key,
4621                            });
4622                        }
4623                    }
4624                }
4625            }
4626            recs
4627        };
4628        if recs.is_empty() {
4629            return Ok((0, 0));
4630        }
4631        // rewrite_wal_dense converts every InsertNode/InsertEdge into its
4632        // *Id form, so only the dense variants can appear in `recs` here.
4633        let recs = self.rewrite_wal_dense(recs)?;
4634        let nodes_inserted = recs
4635            .iter()
4636            .filter(|r| matches!(r, WalRecord::InsertNodeId { .. }))
4637            .count();
4638        let edges_inserted = recs
4639            .iter()
4640            .filter(|r| matches!(r, WalRecord::InsertEdgeId { .. }))
4641            .count();
4642        // Ingest / write_batch / query_write: one Batch frame, one fsync per call
4643        // under Strict.  Pass self.fsync directly so Strict stays Strict —
4644        // wal_needs_sync(Strict, _) always returns true regardless of op count.
4645        // Mapping Strict → Batched (the prior bug) caused wal_needs_sync to
4646        // short-circuit on single-op batches and silently skip the fsync.
4647        // Batched fsyncs only for multi-op batches; Relaxed always skips.
4648        self.log_then_apply_with(WalRecord::Batch(recs), ingest, self.fsync)?;
4649        Ok((nodes_inserted, edges_inserted))
4650    }
4651
4652    fn commit_batch(&mut self, ops: Vec<BatchOp>) -> Result<(usize, usize)> {
4653        self.commit_logged_batch(ops, None, None)
4654    }
4655
4656    /// Commit one submission WITHOUT an fsync — for use inside `commit_group`
4657    /// and the group-commit drain thread, which do a single group fsync later.
4658    fn commit_batch_nosync(&mut self, ops: Vec<BatchOp>) -> Result<(usize, usize)> {
4659        // Restore fsync policy even on panic via a raw-pointer drop guard.
4660        // A panic here would poison the RwLock anyway, but the correct policy
4661        // must be in place if the guard is ever unwrapped.
4662        struct RestoreFsync(*mut FsyncPolicy, FsyncPolicy);
4663        impl Drop for RestoreFsync {
4664            fn drop(&mut self) {
4665                // SAFETY: the pointer is valid for the full duration of
4666                // commit_batch_nosync; the guard is dropped before the frame
4667                // returns, and GraphDb outlives this frame.
4668                unsafe {
4669                    *self.0 = self.1;
4670                }
4671            }
4672        }
4673        let saved = self.fsync;
4674        // SAFETY: raw pointer into self; guard dropped within this frame.
4675        let _g = RestoreFsync(&mut self.fsync as *mut FsyncPolicy, saved);
4676        self.fsync = FsyncPolicy::Relaxed;
4677        self.commit_logged_batch(ops, None, None)
4678    }
4679
4680    /// Commit multiple op-batches as a **group**: each submission gets its own
4681    /// WAL `Batch` frame, but there is exactly **one** `Fs::sync` for the whole
4682    /// group (under `Strict` / `Batched` policy; `Relaxed` skips all syncs).
4683    ///
4684    /// # Durability semantics
4685    ///
4686    /// A crash before the group fsync may lose **all** submissions in the group.
4687    /// A crash after the group fsync preserves all of them.  No submission is
4688    /// ever torn: each WAL frame is either fully applied on replay or dropped
4689    /// in its entirety (CRC-protected frame boundaries).
4690    ///
4691    /// Events and subscription notifications fire per-submission immediately
4692    /// after apply, which may be before the group fsync.  From a subscriber's
4693    /// perspective this is equivalent to the `Relaxed` durability window.
4694    /// Submitters using [`SharedDb::submit_batch`] only unblock after the group
4695    /// fsync, so from their perspective durability is fully guaranteed.
4696    ///
4697    /// # MVCC interplay
4698    ///
4699    /// Each submission records its own `CommitDelta`; the fold-every-K counter
4700    /// increments per submission (not per group), preserving existing reader
4701    /// snapshot semantics.
4702    ///
4703    /// # Returns
4704    ///
4705    /// One `Result<(nodes_inserted, edges_inserted)>` per input group element,
4706    /// in order.  Failures are per-submission (validation errors); the group
4707    /// fsync error (if any) is returned as the second tuple element.
4708    pub fn commit_group(
4709        &mut self,
4710        groups: Vec<Vec<BatchOp>>,
4711    ) -> (Vec<Result<(usize, usize)>>, Option<GraphError>) {
4712        let mut results = Vec::with_capacity(groups.len());
4713        for ops in groups {
4714            results.push(self.commit_batch_nosync(ops));
4715        }
4716        let any_ok = results.iter().any(|r| r.is_ok());
4717        let sync_err = if self.fsync != FsyncPolicy::Relaxed && any_ok {
4718            self.fs
4719                .sync(core_storage::fs::FileId::Wal)
4720                .map_err(GraphError::Io)
4721                .err()
4722        } else {
4723            None
4724        };
4725        (results, sync_err)
4726    }
4727
4728    /// Like [`commit_group`] but skips the group fsync entirely.
4729    ///
4730    /// Used by the drain thread to apply submissions under the write lock and
4731    /// then perform the single fsync OUTSIDE the lock (via
4732    /// `core_storage::sync_wal_at`), reducing the write-lock hold time visible
4733    /// to concurrent readers.
4734    pub fn commit_group_nosync(
4735        &mut self,
4736        groups: Vec<Vec<BatchOp>>,
4737    ) -> Vec<Result<(usize, usize)>> {
4738        let mut results = Vec::with_capacity(groups.len());
4739        for ops in groups {
4740            results.push(self.commit_batch_nosync(ops));
4741        }
4742        results
4743    }
4744
4745    pub fn insert_node(
4746        &mut self,
4747        label: &str,
4748        key: &str,
4749        props: Vec<(String, Value)>,
4750    ) -> Result<()> {
4751        if self.read_only {
4752            return Err(GraphError::ReadOnly);
4753        }
4754        MutPreview::new(self).check_insert_node(key)?;
4755        self.log_dense(vec![WalRecord::InsertNode {
4756            label: label.into(),
4757            key: key.into(),
4758            props,
4759        }])
4760    }
4761
4762    pub fn insert_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) -> Result<bool> {
4763        if self.read_only {
4764            return Err(GraphError::ReadOnly);
4765        }
4766        if !MutPreview::new(self).prepare_insert_edge(edge_type, src_key, dst_key)? {
4767            return Ok(false);
4768        }
4769        self.log_dense(vec![WalRecord::InsertEdge {
4770            edge_type: edge_type.into(),
4771            src_key: src_key.into(),
4772            dst_key: dst_key.into(),
4773        }])?;
4774        Ok(true)
4775    }
4776
4777    pub fn set_prop(&mut self, key: &str, field: &str, value: Value) -> Result<()> {
4778        if self.read_only {
4779            return Err(GraphError::ReadOnly);
4780        }
4781        if let Some(view_name) = self.view_store.view_for_prop(field) {
4782            return Err(GraphError::ViewPropReadOnly {
4783                view_name: view_name.to_string(),
4784            });
4785        }
4786        MutPreview::new(self).check_live_key(key)?;
4787        self.log_dense(vec![WalRecord::SetProp {
4788            key: key.into(),
4789            field: field.into(),
4790            value,
4791        }])
4792    }
4793
4794    /// Remove a property. Returns `Ok(false)` (and does not log) if the field
4795    /// is already absent. Unknown or tombstoned keys are `Err(KeyNotFound)`.
4796    pub fn remove_prop(&mut self, key: &str, field: &str) -> Result<bool> {
4797        if self.read_only {
4798            return Err(GraphError::ReadOnly);
4799        }
4800        if let Some(view_name) = self.view_store.view_for_prop(field) {
4801            return Err(GraphError::ViewPropReadOnly {
4802                view_name: view_name.to_string(),
4803            });
4804        }
4805        if !MutPreview::new(self).prepare_remove_prop(key, field)? {
4806            return Ok(false);
4807        }
4808        self.log_then_apply(WalRecord::RemoveProp {
4809            key: key.into(),
4810            field: field.into(),
4811        })?;
4812        Ok(true)
4813    }
4814
4815    /// Delete a user edge. Returns `Ok(false)` (and does not log) if the edge
4816    /// is absent. Unknown keys are `Err(KeyNotFound)`. Rule-owned edges — in
4817    /// provenance, or a pair a live rule would derive — are `Err(RuleOwned)`
4818    /// (the rule would just put the edge back; delete or change the rule).
4819    pub fn delete_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) -> Result<bool> {
4820        if self.read_only {
4821            return Err(GraphError::ReadOnly);
4822        }
4823        if !MutPreview::new(self).prepare_delete_edge(edge_type, src_key, dst_key)? {
4824            return Ok(false);
4825        }
4826        self.log_then_apply(WalRecord::DeleteEdge {
4827            edge_type: edge_type.into(),
4828            src_key: src_key.into(),
4829            dst_key: dst_key.into(),
4830        })?;
4831        Ok(true)
4832    }
4833
4834    /// Delete a live node. Unknown or already-tombstoned keys are
4835    /// `Err(KeyNotFound)` and are not logged. Validation runs before the WAL
4836    /// write; `apply` of a logged `DeleteNode` for an already-tombstoned key
4837    /// (crash window) is a clean no-op.
4838    ///
4839    /// Returns a [`DeleteReport`] with counts of manual and derived edges
4840    /// removed (computed from live state before the deletion is applied).
4841    pub fn delete_node(&mut self, key: &str) -> Result<DeleteReport> {
4842        if self.read_only {
4843            return Err(GraphError::ReadOnly);
4844        }
4845        // Provenance must be loaded before we query provenance_touching.
4846        self.engine.ensure_provenance_loaded_mut();
4847        let id = self
4848            .ids
4849            .get(key)
4850            .ok_or_else(|| GraphError::KeyNotFound { key: key.into() })?;
4851
4852        // Count edges before the delete is applied so we can report counts.
4853        let derived_set: BTreeSet<(u32, u32, u32)> = self
4854            .engine
4855            .provenance_touching(id)
4856            .map(|(_, etype, src, dst)| (etype, src, dst))
4857            .collect();
4858        let derived_edges = derived_set.len() as u64;
4859
4860        let mut total_topo = 0u64;
4861        let tv = self.topo_view();
4862        for et in tv.etypes() {
4863            total_topo += tv.neighbors(et, Direction::Out, id).len() as u64
4864                + tv.neighbors(et, Direction::In, id).len() as u64;
4865        }
4866        // For symmetric rules (e.g. Overlap), a→b and b→a are two separate directed
4867        // triples in both the topo scan (Out and In from id) and in provenance_touching.
4868        // The subtraction remains correct because both counts include both directions.
4869        let manual_edges = total_topo.saturating_sub(derived_edges);
4870
4871        self.log_then_apply(WalRecord::DeleteNode { key: key.into() })?;
4872        Ok(DeleteReport {
4873            manual_edges,
4874            derived_edges,
4875        })
4876    }
4877
4878    /// Rename a live node's key.  The dense id (and therefore all edges,
4879    /// props, history, and last-change tracking) is unaffected.
4880    ///
4881    /// Returns `Err(KeyNotFound)` if `old` is not a live key.
4882    /// Returns `Err(DuplicateKey)` if `new` is already live.
4883    pub fn rename_node(&mut self, old: &str, new: &str) -> Result<()> {
4884        if self.read_only {
4885            return Err(GraphError::ReadOnly);
4886        }
4887        MutPreview::new(self).check_rename_node(old, new)?;
4888        self.log_then_apply(WalRecord::RenameNode {
4889            old_key: old.into(),
4890            new_key: new.into(),
4891        })
4892    }
4893
4894    /// Return the IVF drift counter for the dst-side candidate index of `rule`.
4895    /// `None` if the rule does not exist or is not approximate.
4896    ///
4897    /// The drift counter increments on IVF insert/remove after the last fit.
4898    /// When dst-side drift exceeds [`core_rules::IVF_DRIFT_REBUILD`], apply
4899    /// WAL-logs `RebuildRule` as a second commit (rebuild resets the counter).
4900    pub fn ivf_dst_drift(&self, rule: &str) -> Option<u64> {
4901        // SideIvfExport = (centroids, node→cluster, drift)
4902        self.engine
4903            .export_ivf_state()
4904            .remove(rule)
4905            .map(|(_src, dst)| dst.2)
4906    }
4907
4908    /// Validate and WAL-log a new rule, then backfill derived edges inside apply.
4909    /// Validation and duplicate-name check run before logging so invalid rules
4910    /// never enter the WAL.
4911    pub fn create_rule(&mut self, def: RuleDef) -> Result<()> {
4912        if self.read_only {
4913            return Err(GraphError::ReadOnly);
4914        }
4915        MutPreview::new(self).check_create_rule(&def)?;
4916        let def_bytes = bincode::serialize(&def).map_err(|e| GraphError::Corrupt {
4917            detail: format!("serialize rule: {e}"),
4918        })?;
4919        self.log_then_apply(WalRecord::CreateRule { def_bytes })
4920    }
4921
4922    /// WAL-log rule deletion. Returns RuleNotFound if the rule does not exist.
4923    pub fn delete_rule(&mut self, name: &str) -> Result<()> {
4924        if self.read_only {
4925            return Err(GraphError::ReadOnly);
4926        }
4927        MutPreview::new(self).check_delete_rule(name)?;
4928        self.log_then_apply(WalRecord::DeleteRule { name: name.into() })
4929    }
4930
4931    /// Return a snapshot of all registered rules.
4932    pub fn rules(&self) -> Vec<RuleDef> {
4933        self.engine.rules().cloned().collect()
4934    }
4935
4936    // -----------------------------------------------------------------------
4937    // Rule suggestion API
4938    // -----------------------------------------------------------------------
4939
4940    /// Profile the database and suggest linking rules with previewed edge counts.
4941    ///
4942    /// Uses the default seed ([`core_rules::SUGGEST_DEFAULT_SEED`]) for deterministic
4943    /// sampling. Suggestions are sorted by estimated edge count (descending).
4944    /// **NO auto-accept** — call [`GraphDb::create_rule`] explicitly to apply.
4945    pub fn suggest_rules(&self) -> Vec<core_rules::RuleSuggestion> {
4946        self.suggest_rules_seeded(core_rules::SUGGEST_DEFAULT_SEED)
4947    }
4948
4949    /// Like [`suggest_rules`] but with a caller-supplied RNG seed for
4950    /// reproducibility. Same seed + same data = identical output.
4951    pub fn suggest_rules_seeded(&self, seed: u64) -> Vec<core_rules::RuleSuggestion> {
4952        self.suggest_rules_with_config(&core_rules::suggest::SuggestConfig::default(), seed)
4953            .suggestions
4954    }
4955
4956    /// [`suggest_rules_seeded`] with a fully custom [`SuggestConfig`].
4957    ///
4958    /// Returns a [`core_rules::SuggestReport`] that includes both the candidate list
4959    /// and a `truncated` flag indicating whether the global budget fired before all
4960    /// candidates were evaluated.
4961    pub fn suggest_rules_with_config(
4962        &self,
4963        config: &core_rules::suggest::SuggestConfig,
4964        seed: u64,
4965    ) -> core_rules::SuggestReport {
4966        use std::collections::BTreeMap;
4967
4968        // Collect (node_id, key) pairs per label, skipping tombstoned nodes.
4969        let mut label_nodes: BTreeMap<String, Vec<(u32, String)>> = BTreeMap::new();
4970        for id in 0..self.ids.len() as u32 {
4971            let Some(key) = self.ids.key_of(id) else {
4972                continue;
4973            };
4974            let Some(&sym) = self.labels.get(id as usize) else {
4975                continue;
4976            };
4977            if sym == u32::MAX {
4978                continue; // tombstoned
4979            }
4980            let Some(label) = self.syms.resolve(sym) else {
4981                continue;
4982            };
4983            label_nodes
4984                .entry(label.to_string())
4985                .or_default()
4986                .push((id, key.to_string()));
4987        }
4988
4989        let existing = self.rules();
4990        let pv = build_props_view(&self.props, &self.base);
4991        let all_fields: Vec<String> = pv.field_names();
4992
4993        core_rules::suggest::suggest_rules(
4994            &label_nodes,
4995            &|id, field| pv.get(id, field).map(|vr| vr.into_value()),
4996            &all_fields,
4997            &existing,
4998            config,
4999            seed,
5000        )
5001    }
5002
5003    /// Recompute a rule's derived edges from scratch. WAL-logged so un-trip
5004    /// plus later mutations replay identically (rebuild is a pure function
5005    /// of state).
5006    ///
5007    /// Only exit from the tripped latch: if the full desired set fits the
5008    /// budget, it is applied completely and `tripped` clears; if it still
5009    /// exceeds the budget, provenance is left untouched and `tripped` stays
5010    /// true. Counts as a fire evaluation (see [`RuleStats::fires`]).
5011    /// Unknown rule → `RuleNotFound`, nothing logged.
5012    pub fn rebuild_rule(&mut self, name: &str) -> Result<()> {
5013        if self.read_only {
5014            return Err(GraphError::ReadOnly);
5015        }
5016        if !self.engine.rules().any(|r| r.name == name) {
5017            return Err(GraphError::RuleNotFound { name: name.into() });
5018        }
5019        self.log_then_apply(WalRecord::RebuildRule { name: name.into() })
5020    }
5021
5022    // -----------------------------------------------------------------------
5023    // Materialized view API
5024    // -----------------------------------------------------------------------
5025
5026    /// Register a new materialized property view, backfill its values for all
5027    /// existing nodes, and WAL-log the definition.
5028    ///
5029    /// # Errors
5030    /// - `ReadOnly`: called on an as-of instance.
5031    /// - `RuleInvalid`: name collision, view_prop collision, or invalid def.
5032    pub fn create_view(&mut self, def: ViewDef) -> Result<()> {
5033        if self.read_only {
5034            return Err(GraphError::ReadOnly);
5035        }
5036        // Pre-validate before WAL write.
5037        def.validate()
5038            .map_err(|e| GraphError::RuleInvalid { detail: e })?;
5039        if self.view_store.has_view(&def.name) {
5040            return Err(GraphError::RuleInvalid {
5041                detail: format!("view {:?} already exists", def.name),
5042            });
5043        }
5044        if let Some(existing) = self.view_store.view_for_prop(&def.view_prop) {
5045            return Err(GraphError::RuleInvalid {
5046                detail: format!(
5047                    "view_prop {:?} is already used by view {:?}",
5048                    def.view_prop, existing
5049                ),
5050            });
5051        }
5052        let def_bytes = bincode::serialize(&def).map_err(|e| GraphError::Corrupt {
5053            detail: format!("serialize view: {e}"),
5054        })?;
5055        // Enable delta accumulation before the view is registered so subsequent
5056        // incremental edge events reach view maintenance from this point onward.
5057        // (The backfill inside create_view reads topo directly; it does not rely
5058        // on pending deltas.)
5059        self.engine.set_emit_deltas(true);
5060        self.log_then_apply(WalRecord::CreateView { def_bytes })
5061    }
5062
5063    /// Remove a named view and delete its values from every node.
5064    ///
5065    /// # Errors
5066    /// - `ReadOnly`: called on an as-of instance.
5067    /// - `RuleNotFound`: view does not exist.
5068    pub fn delete_view(&mut self, name: &str) -> Result<()> {
5069        if self.read_only {
5070            return Err(GraphError::ReadOnly);
5071        }
5072        if !self.view_store.has_view(name) {
5073            return Err(GraphError::RuleNotFound { name: name.into() });
5074        }
5075        let result = self.log_then_apply(WalRecord::DeleteView { name: name.into() });
5076        // After deletion, disable accumulation if no listeners remain.
5077        if !self.needs_emit_deltas() {
5078            self.engine.set_emit_deltas(false);
5079        }
5080        result
5081    }
5082
5083    /// Snapshot of all registered view definitions.
5084    pub fn views(&self) -> Vec<ViewDef> {
5085        self.view_store.views().cloned().collect()
5086    }
5087
5088    // -----------------------------------------------------------------------
5089    // Full-text-lite API
5090    // -----------------------------------------------------------------------
5091
5092    /// Enable full-text indexing for all nodes of `label` on property `field`.
5093    ///
5094    /// After this call, every subsequent write to `(label, field)` is reflected
5095    /// in the index incrementally.  Existing nodes are backfilled immediately.
5096    /// The declaration is persisted as a WAL record; the index itself is rebuilt
5097    /// from scratch on re-open (no snapshot format changes).
5098    ///
5099    /// # Errors
5100    /// - [`GraphError::ReadOnly`]: called on an as-of instance.
5101    /// - [`GraphError::RuleInvalid`]: `(label, field)` is already indexed.
5102    pub fn enable_fulltext(&mut self, label: &str, field: &str) -> Result<()> {
5103        if self.read_only {
5104            return Err(GraphError::ReadOnly);
5105        }
5106        if self.fulltext.is_enabled(label, field) {
5107            return Err(GraphError::RuleInvalid {
5108                detail: format!("full-text index for ({label:?}, {field:?}) already enabled"),
5109            });
5110        }
5111        self.log_then_apply(WalRecord::EnableFulltext {
5112            label: label.into(),
5113            field: field.into(),
5114        })
5115    }
5116
5117    /// Disable full-text indexing for `(label, field)` and drop its postings.
5118    ///
5119    /// # Errors
5120    /// - [`GraphError::ReadOnly`]: called on an as-of instance.
5121    /// - [`GraphError::RuleNotFound`]: `(label, field)` is not currently indexed.
5122    pub fn disable_fulltext(&mut self, label: &str, field: &str) -> Result<()> {
5123        if self.read_only {
5124            return Err(GraphError::ReadOnly);
5125        }
5126        if !self.fulltext.is_enabled(label, field) {
5127            return Err(GraphError::RuleNotFound {
5128                name: format!("fulltext({label},{field})"),
5129            });
5130        }
5131        self.log_then_apply(WalRecord::DisableFulltext {
5132            label: label.into(),
5133            field: field.into(),
5134        })
5135    }
5136
5137    /// Whether `(label, field)` is currently indexed for full-text search.
5138    pub fn is_fulltext_enabled(&self, label: &str, field: &str) -> bool {
5139        self.fulltext.is_enabled(label, field)
5140    }
5141
5142    /// Enable an equality index for all nodes of `label` on scalar property
5143    /// `field`. Subsequent `WHERE n.field = value` lookups become O(matches)
5144    /// instead of an O(N_label) scan. Existing nodes are backfilled; the
5145    /// declaration persists via WAL and the postings rebuild on re-open.
5146    ///
5147    /// # Errors
5148    /// - [`GraphError::ReadOnly`]: called on an as-of instance.
5149    /// - [`GraphError::RuleInvalid`]: `(label, field)` is already indexed.
5150    pub fn enable_index(&mut self, label: &str, field: &str) -> Result<()> {
5151        if self.read_only {
5152            return Err(GraphError::ReadOnly);
5153        }
5154        if self.prop_index.is_enabled(label, field) {
5155            return Err(GraphError::RuleInvalid {
5156                detail: format!("property index for ({label:?}, {field:?}) already enabled"),
5157            });
5158        }
5159        self.log_then_apply(WalRecord::EnableIndex {
5160            label: label.into(),
5161            field: field.into(),
5162        })
5163    }
5164
5165    /// Disable the equality index for `(label, field)` and drop its postings.
5166    ///
5167    /// # Errors
5168    /// - [`GraphError::ReadOnly`]: called on an as-of instance.
5169    /// - [`GraphError::RuleNotFound`]: `(label, field)` is not currently indexed.
5170    pub fn disable_index(&mut self, label: &str, field: &str) -> Result<()> {
5171        if self.read_only {
5172            return Err(GraphError::ReadOnly);
5173        }
5174        if !self.prop_index.is_enabled(label, field) {
5175            return Err(GraphError::RuleNotFound {
5176                name: format!("index({label},{field})"),
5177            });
5178        }
5179        self.log_then_apply(WalRecord::DisableIndex {
5180            label: label.into(),
5181            field: field.into(),
5182        })
5183    }
5184
5185    /// Whether `(label, field)` currently has an equality index.
5186    pub fn is_index_enabled(&self, label: &str, field: &str) -> bool {
5187        self.prop_index.is_enabled(label, field)
5188    }
5189
5190    /// Search a full-text-indexed field.
5191    ///
5192    /// Returns `(node_key, match_count)` pairs sorted by match_count descending,
5193    /// ties broken by key (lexicographic).  Tombstoned nodes are excluded.
5194    ///
5195    /// **Query syntax:**
5196    /// - Space-separated terms are AND'd: `"foo bar"` requires both.
5197    /// - `OR` between terms forms disjunction: `"foo OR bar"` matches either.
5198    /// - Trailing `*` on a term is a prefix match: `"rust*"` matches `rustlang`, `rusty`.
5199    /// - `AND` keyword is accepted explicitly and is the default.
5200    /// - Tokenization is unicode-alphanumeric (same as index time); case-insensitive.
5201    ///
5202    /// **Unindexed field:** returns `Ok(vec![])` if `field` is not indexed.
5203    /// Pin: this is the documented, tested, stable behavior for v1.
5204    ///
5205    /// **Memory / performance:** O(postings) lookup; no scan.  The index is
5206    /// in-memory and proportional to total indexed text across all enabled fields.
5207    ///
5208    /// **v2 grammar:** supports `"phrase"`, `-negation`, `prefix*`, `OR`, `AND`.
5209    /// Results are BM25-scored (k1=1.2, b=0.75) and sorted by score descending,
5210    /// key ascending for deterministic tiebreaking.
5211    pub fn search(&self, field: &str, query: &str) -> Vec<(String, f64)> {
5212        // Resolve node_ids to keys (excluding tombstones) then re-sort by
5213        // (score DESC, key ASC) to give a deterministic, key-lexicographic
5214        // tiebreak.  FulltextIndex::search sorts by (score DESC, node_id ASC)
5215        // which diverges from key order when nodes were not inserted in key-lex order.
5216        let mut results: Vec<(String, f64)> = self
5217            .fulltext
5218            .search(field, query, 0)
5219            .into_iter()
5220            .filter_map(|(id, score)| self.ids.key_of(id).map(|key| (key.to_string(), score)))
5221            .collect();
5222        results.sort_by(|a, b| {
5223            b.1.partial_cmp(&a.1)
5224                .unwrap_or(std::cmp::Ordering::Equal)
5225                .then(a.0.cmp(&b.0))
5226        });
5227        results
5228    }
5229
5230    /// Hybrid search: Reciprocal Rank Fusion (RRF) over fulltext + vector results.
5231    ///
5232    /// Takes up to `4*k` fulltext hits for `(text_field, query_text)` and up to
5233    /// `4*k` vector hits for `(vector_field, query_vec, min=0.0)`, then fuses
5234    /// them with RRF using a fixed constant of 60.
5235    ///
5236    /// ```text
5237    /// score(d) = Σ  1 / (60 + rank_i(d))    (rank 1-based per list)
5238    /// ```
5239    ///
5240    /// Returns the top `k` nodes by fused score, ties broken by node key
5241    /// ascending (deterministic).
5242    ///
5243    /// # Vector leg fallback
5244    ///
5245    /// When `query_vec` is empty the vector leg is skipped entirely and
5246    /// results are ranked by the text list alone through the same RRF path
5247    /// (each text result scores `1/(60 + rank)` from that single list).
5248    ///
5249    /// When `label` is `None`, the vector leg **always** returns empty results.
5250    /// Internally `label` is mapped to `""`, which does not match any rule-created
5251    /// HNSW index (all such indexes are keyed to a specific non-empty label), and
5252    /// the brute-force fallback finds no nodes with an empty label.  The fused
5253    /// ranking is therefore text-only in this case.
5254    pub fn search_hybrid(
5255        &self,
5256        text_field: &str,
5257        query_text: &str,
5258        vector_field: &str,
5259        query_vec: &[f64],
5260        label: Option<&str>,
5261        k: usize,
5262    ) -> Vec<(String, f64)> {
5263        use std::collections::HashMap;
5264
5265        const RRF_K: f64 = 60.0;
5266        let pool = 4 * k;
5267
5268        // Accumulate per-node RRF scores.
5269        let mut scores: HashMap<String, f64> = HashMap::new();
5270
5271        // Text leg.
5272        let text_hits = self.search(text_field, query_text);
5273        for (rank0, (key, _count)) in text_hits.into_iter().take(pool).enumerate() {
5274            let rank = (rank0 + 1) as f64;
5275            *scores.entry(key).or_insert(0.0) += 1.0 / (RRF_K + rank);
5276        }
5277
5278        // Vector leg (skipped when query_vec is empty).
5279        if !query_vec.is_empty() {
5280            let vec_hits = self.find_similar_vector(vector_field, label, query_vec, pool, 0.0);
5281            for (rank0, (key, _sim)) in vec_hits.into_iter().enumerate() {
5282                let rank = (rank0 + 1) as f64;
5283                *scores.entry(key).or_insert(0.0) += 1.0 / (RRF_K + rank);
5284            }
5285        }
5286
5287        // Sort: score DESC, then key ASC for deterministic tie-breaking.
5288        let mut ranked: Vec<(String, f64)> = scores.into_iter().collect();
5289        ranked.sort_by(|a, b| {
5290            b.1.partial_cmp(&a.1)
5291                .unwrap_or(std::cmp::Ordering::Equal)
5292                .then(a.0.cmp(&b.0))
5293        });
5294        ranked.truncate(k);
5295        ranked
5296    }
5297
5298    /// For DST/testing: scratch BM25 search over live nodes without the index.
5299    /// Walks every live node, re-stems field tokens, computes corpus stats, and
5300    /// returns BM25-ranked results.
5301    ///
5302    /// The oracle: the ordered key list of `search(field, q)` must equal that of
5303    /// `scratch_search(field, q)` at every quiescent state.
5304    #[doc(hidden)]
5305    pub fn scratch_search(&self, field: &str, query: &str) -> Vec<(String, f64)> {
5306        use core_storage::fulltext::{parse_query, value_tokens_stemmed_with_positions};
5307        use std::collections::BTreeMap;
5308
5309        let groups = parse_query(query);
5310        if groups.is_empty() {
5311            return vec![];
5312        }
5313
5314        // --- Pass 1: collect all live indexed nodes with stemmed token data ---
5315        struct NodeData {
5316            key: String,
5317            /// stemmed_token → positions (sorted)
5318            tokens: BTreeMap<String, Vec<u32>>,
5319            dl: u32,
5320        }
5321
5322        let mut nodes: Vec<NodeData> = Vec::new();
5323        for id in 0..self.ids.len() as u32 {
5324            let Some(key) = self.ids.key_of(id) else {
5325                continue;
5326            };
5327            let Some(&sym) = self.labels.get(id as usize) else {
5328                continue;
5329            };
5330            if sym == u32::MAX {
5331                continue;
5332            }
5333            let label = match self.syms.resolve(sym) {
5334                Some(l) => l,
5335                None => continue,
5336            };
5337            if !self.fulltext.is_enabled(label, field) {
5338                continue;
5339            }
5340            let Some(value) = self.props_view().get(id, field).map(|vr| vr.into_value()) else {
5341                continue;
5342            };
5343            // Use value_tokens_stemmed_with_positions so list elements are
5344            // separated by POSITION_GAP — identical to the index path, which
5345            // prevents phrase queries from matching across element boundaries.
5346            let stemmed_with_pos = match &value {
5347                Value::Str(_) | Value::List(_) => value_tokens_stemmed_with_positions(&value),
5348                _ => continue,
5349            };
5350            let dl = stemmed_with_pos.len() as u32;
5351            let mut tok_map: BTreeMap<String, Vec<u32>> = BTreeMap::new();
5352            for (tok, pos) in stemmed_with_pos {
5353                tok_map.entry(tok).or_default().push(pos);
5354            }
5355            nodes.push(NodeData {
5356                key: key.to_string(),
5357                tokens: tok_map,
5358                dl,
5359            });
5360        }
5361
5362        if nodes.is_empty() {
5363            return vec![];
5364        }
5365
5366        // --- BM25 corpus stats ---
5367        let n = nodes.len() as f64;
5368        let avg_dl: f64 = nodes.iter().map(|nd| nd.dl as f64).sum::<f64>() / n;
5369        // df per stemmed token across all live indexed nodes.
5370        let mut df_map: BTreeMap<&str, f64> = BTreeMap::new();
5371        for nd in &nodes {
5372            for tok in nd.tokens.keys() {
5373                *df_map.entry(tok.as_str()).or_insert(0.0) += 1.0;
5374            }
5375        }
5376
5377        const K1: f64 = 1.2;
5378        const B: f64 = 0.75;
5379
5380        // --- Pass 2: score each node against each OR-group ---
5381        let mut results: Vec<(String, f64)> = Vec::new();
5382        for nd in &nodes {
5383            let dl = nd.dl as f64;
5384            let mut total_score = 0.0f64;
5385
5386            'group: for group in &groups {
5387                let mut group_score = 0.0f64;
5388
5389                for term in group {
5390                    if term.negated {
5391                        // Negated: if doc has this stemmed token → group fails.
5392                        let present = if term.prefix {
5393                            nd.tokens.keys().any(|t| t.starts_with(term.token.as_str()))
5394                        } else {
5395                            nd.tokens.contains_key(term.token.as_str())
5396                        };
5397                        if present {
5398                            continue 'group;
5399                        }
5400                        continue;
5401                    }
5402                    if term.prefix {
5403                        // Prefix: sum BM25 for all matching stemmed tokens.
5404                        let mut prefix_matched = false;
5405                        for (tok, positions) in &nd.tokens {
5406                            if tok.starts_with(term.token.as_str()) {
5407                                let tf = positions.len() as f64;
5408                                let df = df_map.get(tok.as_str()).copied().unwrap_or(1.0);
5409                                let idf = ((n - df + 0.5) / (df + 0.5) + 1.0).ln();
5410                                let tf_norm =
5411                                    tf * (K1 + 1.0) / (tf + K1 * (1.0 - B + B * dl / avg_dl));
5412                                group_score += idf * tf_norm;
5413                                prefix_matched = true;
5414                            }
5415                        }
5416                        if !prefix_matched {
5417                            continue 'group;
5418                        }
5419                    } else {
5420                        // term.token is already stemmed by parse_query; use directly.
5421                        match nd.tokens.get(term.token.as_str()) {
5422                            None => continue 'group,
5423                            Some(positions) => {
5424                                let tf = positions.len() as f64;
5425                                let df = df_map.get(term.token.as_str()).copied().unwrap_or(1.0);
5426                                let idf = ((n - df + 0.5) / (df + 0.5) + 1.0).ln();
5427                                let tf_norm =
5428                                    tf * (K1 + 1.0) / (tf + K1 * (1.0 - B + B * dl / avg_dl));
5429                                group_score += idf * tf_norm;
5430                            }
5431                        }
5432                    }
5433                }
5434
5435                if group_score > 0.0 {
5436                    total_score += group_score;
5437                }
5438            }
5439
5440            if total_score > 0.0 {
5441                results.push((nd.key.clone(), total_score));
5442            }
5443        }
5444
5445        results.sort_by(|a, b| {
5446            b.1.partial_cmp(&a.1)
5447                .unwrap_or(std::cmp::Ordering::Equal)
5448                .then(a.0.cmp(&b.0))
5449        });
5450        results
5451    }
5452
5453    /// Return the current view-maintained value of `view_prop` for node `key`.
5454    /// Equivalent to `get_prop` but documents that it reads a view-managed column.
5455    pub fn get_view_prop(&self, key: &str, view_prop: &str) -> Option<Value> {
5456        let id = self.ids.get(key)?;
5457        self.props_view()
5458            .get(id, view_prop)
5459            .map(|vr| vr.into_value())
5460    }
5461
5462    /// For testing / DST oracle: scratch recompute of a view value for one node.
5463    ///
5464    /// Returns `None` if the node does not exist, the view does not exist, or
5465    /// the view has no result for the node (e.g. Avg with no qualifying neighbors).
5466    #[doc(hidden)]
5467    pub fn scratch_view_value(&self, key: &str, view_name: &str) -> Option<Value> {
5468        let node = self.ids.get(key)?;
5469        let def = self.view_store.views().find(|v| v.name == view_name)?;
5470        // Use TopologyView so that NeighborAgg sees base + overlay edges
5471        // without materialising a temporary Topology (I1).
5472        let topo_view = self.topo_view();
5473        core_rules::views::compute_view_value(
5474            def,
5475            node,
5476            self.props_view(),
5477            &topo_view,
5478            &self.ids,
5479            &self.syms,
5480            &self.labels,
5481        )
5482    }
5483
5484    // -----------------------------------------------------------------------
5485    // Graph algorithm API
5486    // -----------------------------------------------------------------------
5487
5488    /// Run PageRank over the unified topology (manual + derived edges).
5489    ///
5490    /// Returns a [`PageRankReport`] with scores sorted descending (ties: key
5491    /// ascending).  Set `config.edge_type` to restrict to one edge type.
5492    /// `config.converged` is `true` only when the power iteration converged
5493    /// within `config.max_iters` and within any time budget.
5494    pub fn pagerank(&self, config: &crate::algo::PageRankConfig) -> crate::algo::PageRankReport {
5495        let topo = build_topo_view(&self.topo, &self.base);
5496        crate::algo::pagerank(&topo, &self.ids, &self.syms, &self.labels, config)
5497    }
5498
5499    /// Weakly-connected components over the unified topology (treated as
5500    /// undirected regardless of how edges were inserted).
5501    ///
5502    /// Component IDs are the key of the smallest member in the component
5503    /// (deterministic).  Result sorted by (component_id, key).
5504    pub fn connected_components(&self, config: &crate::algo::WccConfig) -> crate::algo::WccReport {
5505        let topo = build_topo_view(&self.topo, &self.base);
5506        crate::algo::wcc(&topo, &self.ids, &self.syms, &self.labels, config)
5507    }
5508
5509    /// Degree centrality for every live node.
5510    ///
5511    /// `direction`: `AlgoDir::Out` = out-degree, `AlgoDir::In` = in-degree,
5512    /// `AlgoDir::Both` = out + in (total directed degree).
5513    ///
5514    /// For one-shot ranking use this; for a live property updated on every
5515    /// write, create a Degree materialized view instead (see `docs/site/algorithms.md`).
5516    pub fn degree_centrality(
5517        &self,
5518        config: &crate::algo::DegreeConfig,
5519    ) -> crate::algo::DegreeReport {
5520        let topo = build_topo_view(&self.topo, &self.base);
5521        crate::algo::degree_centrality(&topo, &self.ids, &self.syms, &self.labels, config)
5522    }
5523
5524    /// Write a vector of `(node_key, score)` pairs as `prop_name` on each node,
5525    /// atomically via a single write-batch (one WAL frame, one fsync).
5526    ///
5527    /// # Errors
5528    /// - [`GraphError::ReadOnly`]: called on an as-of instance.
5529    /// - [`GraphError::RuleInvalid`]: `prop_name` is managed by an existing view
5530    ///   (collision check mirrors `create_view`).
5531    /// - [`GraphError::KeyNotFound`]: a key in `scores` does not exist as a live node.
5532    pub fn write_scores(&mut self, prop_name: &str, scores: &[(String, f64)]) -> Result<()> {
5533        if self.read_only {
5534            return Err(GraphError::ReadOnly);
5535        }
5536        // Collision check: refuse if prop_name is view-managed.
5537        if let Some(view_name) = self.view_store.view_for_prop(prop_name) {
5538            return Err(GraphError::RuleInvalid {
5539                detail: format!(
5540                    "prop {:?} is managed by view {:?} and cannot be written as scores",
5541                    prop_name, view_name
5542                ),
5543            });
5544        }
5545        // Refuse if prop_name is a view name itself (confusing namespace collision).
5546        if self.view_store.has_view(prop_name) {
5547            return Err(GraphError::RuleInvalid {
5548                detail: format!(
5549                    "prop_name {:?} collides with an existing view name",
5550                    prop_name
5551                ),
5552            });
5553        }
5554        // Write all scores in a single crash-atomic batch.
5555        self.write_batch(|b| {
5556            for (key, score) in scores {
5557                b.set_prop(key, prop_name, Value::Float(*score));
5558            }
5559        })?;
5560        Ok(())
5561    }
5562
5563    /// Return the value of `field` for the node with key `key`, or `None` if
5564    /// the node or field is absent.  Reads through the overlay-over-base
5565    /// `ColumnsView`, materialising base values on demand (zero heap cost for
5566    /// overlay hits; one clone per base hit).
5567    pub fn get_prop(&self, key: &str, field: &str) -> Option<Value> {
5568        let id = self.ids.get(key)?;
5569        self.props_view().get(id, field).map(|vr| vr.into_value())
5570    }
5571
5572    pub fn has_node(&self, key: &str) -> bool {
5573        self.ids.get(key).is_some()
5574    }
5575
5576    /// Borrow the raw id map. Used by `NodeMask::from_keys` to resolve keys.
5577    pub(crate) fn ids(&self) -> &IdMap {
5578        &self.ids
5579    }
5580
5581    // -----------------------------------------------------------------------
5582    // RBAC role resolution
5583    // -----------------------------------------------------------------------
5584
5585    /// Parse `roles.json` bytes from `fs`.
5586    ///
5587    /// Return values:
5588    ///   `Ok(Some(roles))` — file absent (returns `vec![]`) **or** file present
5589    ///                       and valid; in both cases `mask_for_role` uses the
5590    ///                       list normally (an absent file means no roles defined).
5591    ///   `Ok(None)`        — file present but corrupt or unrecognised version
5592    ///                       → poisoned state; `mask_for_role` returns `Err` for
5593    ///                       any role name until the file is fixed and the DB
5594    ///                       re-opened (or `apply_schema` is called to repair it).
5595    ///
5596    /// Note: `None` signals corruption, not absence — the opposite of what an
5597    /// optional "file missing" convention would suggest.  The open path stores
5598    /// this result on `db.roles` directly.
5599    fn load_roles_from_fs(fs: &F) -> Result<Option<Vec<RoleDef>>> {
5600        let bytes = fs.read(FileId::Roles).map_err(GraphError::Io)?;
5601        if bytes.is_empty() {
5602            // Empty bytes means either the file is absent or zero-byte — both
5603            // are treated identically as "no roles defined".  A zero-byte
5604            // roles.json does NOT widen access: an absent file and a zero-byte
5605            // file both resolve to an empty role list (sees nothing by default).
5606            return Ok(Some(vec![]));
5607        }
5608        match serde_json::from_slice::<RolesFile>(&bytes) {
5609            Ok(f) if f.version == 1 || f.version == 2 => Ok(Some(f.roles)),
5610            // Corrupt or unrecognised version (>2): poison the roles state.
5611            _ => Ok(None),
5612        }
5613    }
5614
5615    /// Resolve a role to a node-visibility mask against the current graph state.
5616    ///
5617    /// Returns `Err` when:
5618    /// - `roles.json` was present but corrupt at open (poisoned state), or
5619    /// - `role` does not match any defined role name.
5620    ///
5621    /// The mask union is: explicit `keys` (unknown keys silently ignored) plus
5622    /// all live nodes carrying any label in `labels`.  Label resolution is live
5623    /// — new nodes of an allowed label are visible without re-applying the
5624    /// schema.  An empty union = empty mask = sees nothing.
5625    pub fn mask_for_role(&self, role: &str) -> Result<crate::mask::NodeMask> {
5626        let roles = self.roles.as_ref().ok_or_else(|| GraphError::Corrupt {
5627            detail:
5628                "roles.json was corrupt at open; fix the file and re-open to restore role access"
5629                    .into(),
5630        })?;
5631        let def = roles
5632            .iter()
5633            .find(|r| r.name == role)
5634            .ok_or_else(|| GraphError::KeyNotFound {
5635                key: format!("role:{role}"),
5636            })?;
5637
5638        let mut visible = std::collections::HashSet::new();
5639
5640        // Key leg: resolve explicit keys to dense ids (unknown keys ignored).
5641        for key in &def.keys {
5642            if let Some(id) = self.ids.get(key) {
5643                visible.insert(id);
5644            }
5645        }
5646
5647        // Label leg: live scan — iterate labels vec for matching symbol.
5648        for label_name in &def.labels {
5649            if let Some(sym) = self.syms.get(label_name) {
5650                for (i, &s) in self.labels.iter().enumerate() {
5651                    if s == sym {
5652                        visible.insert(i as u32);
5653                    }
5654                }
5655            }
5656        }
5657
5658        Ok(crate::mask::NodeMask::from_ids(visible))
5659    }
5660
5661    /// Return the current list of role definitions.
5662    ///
5663    /// Returns an empty list when no roles are defined or when `roles.json`
5664    /// was corrupt at open (check [`mask_for_role`](Self::mask_for_role) for
5665    /// the fail-loud error in that case).
5666    pub fn roles(&self) -> Vec<RoleDef> {
5667        self.roles.as_deref().unwrap_or(&[]).to_vec()
5668    }
5669
5670    // ── Role-scoped write authz ───────────────────────────────────────────────
5671
5672    /// Execute `ops` with optional role-scoped write authorization.
5673    ///
5674    /// - `None` → full authority, identical to [`write_batch`](Self::write_batch)
5675    ///   (zero-cost bypass of all authz checks).
5676    /// - `Some(authz)` → the decision table is evaluated per-op BEFORE any WAL
5677    ///   record is built.  A denial returns an error with no WAL frame written
5678    ///   (all-or-nothing at the authz boundary, then at the MutPreview boundary).
5679    ///
5680    /// See the plan's "authz decision table" section for the full semantics.
5681    pub fn write_batch_authz(
5682        &mut self,
5683        authz: Option<&WriteAuthz>,
5684        ops: Vec<BatchOp>,
5685    ) -> Result<(usize, usize)> {
5686        // Thread authz as a direct parameter — never touches pending_write_authz.
5687        self.commit_logged_batch(ops, None, authz.cloned())
5688    }
5689
5690    /// Execute a Cypher write statement with role-scoped write authorization.
5691    ///
5692    /// Resolves scope + mask from `self.roles` inside the call (same write-guard
5693    /// lifetime as execution, satisfying §5 lock discipline).  The resolved
5694    /// `WriteAuthz` is stored as `pending_write_authz` for the duration of the
5695    /// call so that all inner `batch.commit()` calls are authz-checked.
5696    ///
5697    /// MERGE is handled specially: the MERGE scope precondition (§3.3) is
5698    /// checked in `exec_merge` BEFORE `has_node` to close the §6.2
5699    /// timing-oracle item (hidden ≡ absent for unscoped roles).
5700    ///
5701    /// Roles with `write: None` (v1 behavior) → `RoleWriteDenied` with
5702    /// "this endpoint is not permitted".
5703    pub fn query_write_authz(
5704        &mut self,
5705        role: &str,
5706        cypher: &str,
5707        params: &BTreeMap<String, Value>,
5708    ) -> Result<ResultSet> {
5709        // Resolve scope (fails fast if role has no write scope).
5710        // write:None → byte-identical v1 blanket-403 body (plan §v1-sidecar mandate).
5711        let scope =
5712            {
5713                let roles = self.roles.as_deref().ok_or_else(|| GraphError::Corrupt {
5714                    detail: "roles.json was corrupt at open; re-open to restore role access".into(),
5715                })?;
5716                let def = roles.iter().find(|r| r.name == role).ok_or_else(|| {
5717                    GraphError::KeyNotFound {
5718                        key: format!("role:{role}"),
5719                    }
5720                })?;
5721                def.write
5722                    .clone()
5723                    .ok_or_else(|| GraphError::RoleWriteDenied {
5724                        reason: "role-bound token: writes are not permitted".into(),
5725                    })?
5726            };
5727        // Resolve mask inside the call (same guard, §5 coherence).
5728        let mask = self.mask_for_role(role)?;
5729        self.pending_write_authz = Some(WriteAuthz {
5730            role: role.into(),
5731            scope,
5732            mask,
5733        });
5734        // RAII guard: always clears pending_write_authz on scope exit, including
5735        // on panic or early-return, mirroring the RestoreEmitDeltas precedent.
5736        struct ClearPendingAuthzOnDrop(*mut Option<WriteAuthz>);
5737        impl Drop for ClearPendingAuthzOnDrop {
5738            fn drop(&mut self) {
5739                // SAFETY: pointer into the owning GraphDb; guard is dropped
5740                // within this function's frame before it returns.
5741                unsafe { *self.0 = None };
5742            }
5743        }
5744        // SAFETY: raw pointer into self; guard dropped before this fn returns.
5745        let _authz_guard = ClearPendingAuthzOnDrop(&mut self.pending_write_authz as *mut _);
5746        let tokens = lex(cypher).map_err(|e| GraphError::QueryError {
5747            detail: format!("lex: {e}"),
5748        })?;
5749        let stmt = parse_write(&tokens).map_err(|e| GraphError::QueryError {
5750            detail: format!("parse: {e}"),
5751        })?;
5752        self.exec_write_stmt(stmt, params)
5753    }
5754
5755    /// Execute `ops` with optional role-scoped write authorization, suppressing
5756    /// fsync (for use inside the group-commit drain thread, which performs one
5757    /// group fsync after releasing the write lock).
5758    ///
5759    /// Identical to [`write_batch_authz`] except the fsync policy is temporarily
5760    /// forced to `Relaxed` for the duration of the call, matching the drain-thread
5761    /// contract established by [`commit_batch_nosync`].
5762    pub(crate) fn write_batch_authz_nosync(
5763        &mut self,
5764        authz: Option<&WriteAuthz>,
5765        ops: Vec<BatchOp>,
5766    ) -> Result<(usize, usize)> {
5767        let saved = self.fsync;
5768        struct RestoreFsync(*mut FsyncPolicy, FsyncPolicy);
5769        impl Drop for RestoreFsync {
5770            fn drop(&mut self) {
5771                // SAFETY: pointer into the owning GraphDb; guard is dropped
5772                // within the enclosing function's frame before it returns.
5773                unsafe { *self.0 = self.1 };
5774            }
5775        }
5776        // SAFETY: raw pointer into self; guard dropped before this fn returns.
5777        let _g = RestoreFsync(&mut self.fsync as *mut FsyncPolicy, saved);
5778        self.fsync = FsyncPolicy::Relaxed;
5779        self.commit_logged_batch(ops, None, authz.cloned())
5780    }
5781
5782    /// Execute a `/ingest` request with role-scoped write authorization.
5783    ///
5784    /// Resolves the role's `WriteScope` and `NodeMask` inside this call (same
5785    /// write-guard lifetime as the mutation, satisfying §5 lock discipline).
5786    /// Sets `pending_write_authz` for the duration of the call so that the
5787    /// `commit_ingest` → `commit_logged_batch` path picks up the authz context
5788    /// and evaluates the decision table per-op before any WAL write.
5789    ///
5790    /// §7.3: roles with empty `create_labels` will see every `InsertNode` op
5791    /// denied by the decision table with the appropriate §4.3 scope reason;
5792    /// no special HTTP-layer check is needed.
5793    ///
5794    /// Roles with `write: None` return `RoleWriteDenied` with
5795    /// "writes are not permitted" (byte-identical to v1 blanket 403).
5796    pub fn ingest_with_edges_authz(
5797        &mut self,
5798        role: &str,
5799        label: &str,
5800        rows: Vec<std::collections::BTreeMap<String, Value>>,
5801        opts: &crate::ingest::IngestOptions,
5802        edges: &[(String, String, String)],
5803    ) -> Result<crate::ingest::IngestReport> {
5804        // Resolve scope (fails fast if role has no write scope).
5805        // write:None → byte-identical v1 blanket-403 body (plan §v1-sidecar mandate).
5806        let scope =
5807            {
5808                let roles = self.roles.as_deref().ok_or_else(|| GraphError::Corrupt {
5809                    detail: "roles.json was corrupt at open; re-open to restore role access".into(),
5810                })?;
5811                let def = roles.iter().find(|r| r.name == role).ok_or_else(|| {
5812                    GraphError::KeyNotFound {
5813                        key: format!("role:{role}"),
5814                    }
5815                })?;
5816                def.write
5817                    .clone()
5818                    .ok_or_else(|| GraphError::RoleWriteDenied {
5819                        reason: "role-bound token: writes are not permitted".into(),
5820                    })?
5821            };
5822        let mask = self.mask_for_role(role)?;
5823        self.pending_write_authz = Some(WriteAuthz {
5824            role: role.into(),
5825            scope,
5826            mask,
5827        });
5828        // RAII guard: always clears pending_write_authz on scope exit, including
5829        // on panic or early-return, mirroring the RestoreEmitDeltas precedent.
5830        struct ClearPendingAuthzOnDrop(*mut Option<WriteAuthz>);
5831        impl Drop for ClearPendingAuthzOnDrop {
5832            fn drop(&mut self) {
5833                // SAFETY: pointer into the owning GraphDb; guard is dropped
5834                // within this function's frame before it returns.
5835                unsafe { *self.0 = None };
5836            }
5837        }
5838        // SAFETY: raw pointer into self; guard dropped before this fn returns.
5839        let _authz_guard = ClearPendingAuthzOnDrop(&mut self.pending_write_authz as *mut _);
5840        self.ingest_with_edges(label, rows, opts, edges)
5841    }
5842
5843    /// Evaluate the write-authz decision table for one `BatchOp`.
5844    ///
5845    /// Called by `commit_logged_batch` for each op when `pending_write_authz`
5846    /// is `Some`, BEFORE MutPreview.  A denial returns an error immediately;
5847    /// the remaining ops are not evaluated and no WAL frame is written.
5848    ///
5849    /// `batch_created` carries the key→label pairs of nodes that earlier ops in
5850    /// THIS batch will create.  Used by `InsertEdgeUpsert` to count same-batch
5851    /// placeholder nodes as visible (spec: "a placeholder endpoint the SAME
5852    /// batch creates counts as visible if its label passed the create-class gate").
5853    fn check_single_op_authz(
5854        &self,
5855        authz: &WriteAuthz,
5856        op: &BatchOp,
5857        batch_created: &BTreeMap<String, String>,
5858    ) -> Result<()> {
5859        // Helper: 3-way node status under the authz mask.
5860        //
5861        // Batch-created nodes (from earlier InsertNode in THIS batch) are treated
5862        // as Visible with their recorded label — their create gate already passed
5863        // and they are not yet in self.ids (not committed).  This fixes the
5864        // MERGE+ON CREATE SET case where InsertNode + SetProp arrive together:
5865        // the SetProp must not see the node as Absent.
5866        let node_status = |key: &str| -> NodeAuthzStatus {
5867            if let Some(label) = batch_created.get(key) {
5868                return NodeAuthzStatus::Visible(label.clone());
5869            }
5870            match self.ids.get(key) {
5871                None => NodeAuthzStatus::Absent,
5872                Some(id) if !authz.mask.contains_id(id) => NodeAuthzStatus::Hidden,
5873                Some(id) => {
5874                    let label = self
5875                        .labels
5876                        .get(id as usize)
5877                        .and_then(|&sym| {
5878                            if sym == u32::MAX {
5879                                None
5880                            } else {
5881                                self.syms.resolve(sym).map(str::to_string)
5882                            }
5883                        })
5884                        .unwrap_or_default();
5885                    NodeAuthzStatus::Visible(label)
5886                }
5887            }
5888        };
5889
5890        // Helper: is an InsertEdgeUpsert endpoint visible?
5891        // A same-batch placeholder counts as visible if its label passed
5892        // the create-class gate (spec "upsert placeholder-counts-as-visible").
5893        let upsert_ep_visible = |ep_key: &str, placeholder_label: &str| -> bool {
5894            // In store and visible?
5895            if let Some(id) = self.ids.get(ep_key) {
5896                return authz.mask.contains_id(id);
5897            }
5898            // Created by an earlier op in this batch?
5899            if let Some(created_label) = batch_created.get(ep_key) {
5900                return authz.scope.create_labels.contains(created_label);
5901            }
5902            // Will be created by THIS InsertEdgeUpsert: placeholder_label
5903            // must pass the create-class gate.
5904            authz
5905                .scope
5906                .create_labels
5907                .contains(&placeholder_label.to_string())
5908        };
5909
5910        match op {
5911            // RenameNode / CreateRule / DeleteRule: defense-in-depth gate.
5912            // These ops are never routed to role-scoped paths by the HTTP layer,
5913            // but we 403 them here to close any future bypass route.
5914            BatchOp::RenameNode { .. } | BatchOp::CreateRule(_) | BatchOp::DeleteRule { .. } => {
5915                return Err(GraphError::RoleWriteDenied {
5916                    reason: "role-bound token: this endpoint is not permitted".into(),
5917                });
5918            }
5919
5920            // ── CREATE-class: InsertNode ─────────────────────────────────────
5921            //
5922            // Decision table row 1 (scope-before-lookup): check label in
5923            // create_labels BEFORE any key lookup.  This is the structural
5924            // closure of the §6.2 timing-oracle item — the denial fires even
5925            // when the store is EMPTY (see test_create_scope_denied_empty_store).
5926            BatchOp::InsertNode { label, key, .. } => {
5927                if !authz.scope.create_labels.contains(label) {
5928                    return Err(GraphError::RoleWriteDenied {
5929                        reason: format!(
5930                            "role-bound token: label '{}' not in write scope (create_labels)",
5931                            label
5932                        ),
5933                    });
5934                }
5935                // Row 2/3: key lookup.
5936                match self.ids.get(key.as_str()) {
5937                    Some(id) if authz.mask.contains_id(id) => {
5938                        // Visible: DuplicateKey — let MutPreview handle this.
5939                    }
5940                    Some(_) => {
5941                        // Hidden: indistinguishable from absent to the role.
5942                        return Err(GraphError::RoleWriteDenied {
5943                            reason: "role-bound token: target node not visible".into(),
5944                        });
5945                    }
5946                    None => {
5947                        // Absent: proceed (create).
5948                    }
5949                }
5950            }
5951
5952            // ── UPDATE-class: SetProp, RemoveProp ────────────────────────────
5953            BatchOp::SetProp { key, .. } | BatchOp::RemoveProp { key, .. } => {
5954                if batch_created.contains_key(key.as_str()) {
5955                    // Batch-created node: create gate already passed this batch.
5956                    // Updating it in the same batch is always allowed, regardless
5957                    // of update_labels (ruling §3.5: "writer just created it").
5958                } else {
5959                    let label = match node_status(key) {
5960                        NodeAuthzStatus::Visible(lbl) => lbl,
5961                        _ => {
5962                            return Err(GraphError::RoleWriteDenied {
5963                                reason: "role-bound token: target node not visible".into(),
5964                            });
5965                        }
5966                    };
5967                    if !authz.scope.update_labels.contains(&label) {
5968                        return Err(GraphError::RoleWriteDenied {
5969                            reason: format!(
5970                                "role-bound token: label '{}' not in write scope (update_labels)",
5971                                label
5972                            ),
5973                        });
5974                    }
5975                }
5976            }
5977
5978            // ── DELETE-class: DeleteNode ─────────────────────────────────────
5979            BatchOp::DeleteNode { key } => {
5980                let label = match node_status(key) {
5981                    NodeAuthzStatus::Visible(lbl) => lbl,
5982                    _ => {
5983                        return Err(GraphError::RoleWriteDenied {
5984                            reason: "role-bound token: target node not visible".into(),
5985                        });
5986                    }
5987                };
5988                if !authz.scope.delete_labels.contains(&label) {
5989                    return Err(GraphError::RoleWriteDenied {
5990                        reason: format!(
5991                            "role-bound token: label '{}' not in write scope (delete_labels)",
5992                            label
5993                        ),
5994                    });
5995                }
5996            }
5997
5998            // ── DELETE-class: DeleteEdge ─────────────────────────────────────
5999            //
6000            // Derived-edge rejection runs BEFORE the delete_edge_types scope
6001            // check (spec §3.5: "existing derived-edge rejection precedes
6002            // delete_edge_types check").
6003            BatchOp::DeleteEdge {
6004                edge_type,
6005                src_key,
6006                dst_key,
6007            } => {
6008                // Check provenance ownership BEFORE scope (spec §3.5 ordering).
6009                if let (Some(src_id), Some(dst_id), Some(et_sym)) = (
6010                    self.ids.get(src_key.as_str()),
6011                    self.ids.get(dst_key.as_str()),
6012                    self.syms.get(edge_type.as_str()),
6013                ) {
6014                    if self.engine.is_owned(et_sym, src_id, dst_id) {
6015                        return Err(GraphError::RuleOwned {
6016                            detail: format!(
6017                                "edge {edge_type} {src_key}→{dst_key} is rule-owned; \
6018                                 delete or change the owning rule"
6019                            ),
6020                        });
6021                    }
6022                    // Also check would_derive via MutPreview (empty overlay, pre-batch).
6023                    let preview = MutPreview::new(self);
6024                    if preview.would_derive(edge_type, src_key, dst_key) {
6025                        return Err(GraphError::RuleOwned {
6026                            detail: format!(
6027                                "edge {edge_type} {src_key}→{dst_key} is rule-owned; \
6028                                 delete or change the owning rule, or a live rule would \
6029                                 re-derive it"
6030                            ),
6031                        });
6032                    }
6033                }
6034                // Scope check (AFTER derived-edge check, BEFORE endpoint visibility).
6035                if !authz.scope.delete_edge_types.contains(edge_type) {
6036                    return Err(GraphError::RoleWriteDenied {
6037                        reason: format!(
6038                            "role-bound token: edge type '{}' not in write scope (delete_edge_types)",
6039                            edge_type
6040                        ),
6041                    });
6042                }
6043                // Both endpoints must be visible.
6044                for ep_key in [src_key.as_str(), dst_key.as_str()] {
6045                    match self.ids.get(ep_key) {
6046                        None => {
6047                            return Err(GraphError::RoleWriteDenied {
6048                                reason: "role-bound token: edge endpoint not visible".into(),
6049                            });
6050                        }
6051                        Some(id) if !authz.mask.contains_id(id) => {
6052                            return Err(GraphError::RoleWriteDenied {
6053                                reason: "role-bound token: edge endpoint not visible".into(),
6054                            });
6055                        }
6056                        _ => {}
6057                    }
6058                }
6059            }
6060
6061            // ── EDGE-CREATE: InsertEdge ──────────────────────────────────────
6062            //
6063            // Scope check BEFORE endpoint lookup (preserves timing symmetry).
6064            BatchOp::InsertEdge {
6065                edge_type,
6066                src_key,
6067                dst_key,
6068            } => {
6069                if !authz.scope.create_edge_types.contains(edge_type) {
6070                    return Err(GraphError::RoleWriteDenied {
6071                        reason: format!(
6072                            "role-bound token: edge type '{}' not in write scope (create_edge_types)",
6073                            edge_type
6074                        ),
6075                    });
6076                }
6077                // Both endpoints must be visible. A node created by an earlier
6078                // InsertNode in the same batch (tracked in batch_created) counts
6079                // as visible if its label passed the create-class gate.
6080                for ep_key in [src_key.as_str(), dst_key.as_str()] {
6081                    if batch_created.contains_key(ep_key) {
6082                        // Created earlier this batch — already scope-checked.
6083                        continue;
6084                    }
6085                    match self.ids.get(ep_key) {
6086                        None => {
6087                            return Err(GraphError::RoleWriteDenied {
6088                                reason: "role-bound token: edge endpoint not visible".into(),
6089                            });
6090                        }
6091                        Some(id) if !authz.mask.contains_id(id) => {
6092                            return Err(GraphError::RoleWriteDenied {
6093                                reason: "role-bound token: edge endpoint not visible".into(),
6094                            });
6095                        }
6096                        _ => {}
6097                    }
6098                }
6099            }
6100
6101            // ── EDGE-CREATE: InsertEdgeUpsert ────────────────────────────────
6102            //
6103            // Scope check first; then endpoint visibility using same-batch
6104            // placeholder awareness (spec: "a placeholder endpoint the SAME
6105            // batch creates counts as visible if its label passed the
6106            // create-class gate").
6107            BatchOp::InsertEdgeUpsert {
6108                edge_type,
6109                src_key,
6110                dst_key,
6111                placeholder_label,
6112            } => {
6113                if !authz.scope.create_edge_types.contains(edge_type) {
6114                    return Err(GraphError::RoleWriteDenied {
6115                        reason: format!(
6116                            "role-bound token: edge type '{}' not in write scope (create_edge_types)",
6117                            edge_type
6118                        ),
6119                    });
6120                }
6121                // Check placeholder label against create_labels (create-class gate).
6122                // This ensures the auto-created endpoints are scope-allowed.
6123                for ep_key in [src_key.as_str(), dst_key.as_str()] {
6124                    if !upsert_ep_visible(ep_key, placeholder_label) {
6125                        return Err(GraphError::RoleWriteDenied {
6126                            reason: "role-bound token: edge endpoint not visible".into(),
6127                        });
6128                    }
6129                }
6130            }
6131        }
6132        Ok(())
6133    }
6134
6135    /// Write `roles` to `roles.json` atomically and update the in-memory list.
6136    ///
6137    /// Called by `apply_schema` when roles change. Never called on unchanged
6138    /// re-apply — this preserves byte-identical idempotency.
6139    pub(crate) fn commit_roles(&mut self, roles: Vec<RoleDef>) -> Result<()> {
6140        let file = RolesFile::new_versioned(roles.clone());
6141        let bytes = serde_json::to_vec(&file).map_err(|e| GraphError::Corrupt {
6142            detail: format!("roles serialization: {e}"),
6143        })?;
6144        self.fs
6145            .write_atomic(FileId::Roles, &bytes)
6146            .map_err(GraphError::Io)?;
6147        self.roles = Some(roles);
6148        // Refresh the MVCC frozen overlay so that reader() immediately sees the
6149        // updated role definitions without waiting for the next K-commit fold.
6150        self.fold_now();
6151        Ok(())
6152    }
6153
6154    fn view(&self) -> GraphView<'_> {
6155        GraphView {
6156            ids: &self.ids,
6157            syms: &self.syms,
6158            labels: &self.labels,
6159            props: self.props_view(),
6160            topo: self.topo_view(),
6161            edge_props: self.edge_props_view(),
6162            mask: None,
6163            prop_index: Some(&self.prop_index),
6164        }
6165    }
6166
6167    fn view_masked<'a>(&'a self, mask: &'a crate::mask::NodeMask) -> GraphView<'a> {
6168        GraphView {
6169            ids: &self.ids,
6170            syms: &self.syms,
6171            labels: &self.labels,
6172            props: self.props_view(),
6173            topo: self.topo_view(),
6174            edge_props: self.edge_props_view(),
6175            mask: Some(&mask.visible),
6176            prop_index: Some(&self.prop_index),
6177        }
6178    }
6179
6180    /// Execute a read-only Cypher query with a node visibility mask.
6181    ///
6182    /// Only nodes whose key is in `mask` are accessible: label scans, key
6183    /// lookups, and neighbor expansions all respect the mask. Edges where
6184    /// either endpoint is hidden are silently dropped.
6185    ///
6186    /// Returns `Err` with a "masked queries are read-only" message when
6187    /// `cypher` is a write statement (CREATE / MERGE / MATCH…SET / DELETE).
6188    pub fn query_masked(
6189        &self,
6190        cypher: &str,
6191        params: &std::collections::BTreeMap<String, Value>,
6192        mask: &crate::mask::NodeMask,
6193    ) -> Result<ResultSet> {
6194        // Reject write statements up front.
6195        let tokens = lex(cypher).map_err(|e| GraphError::QueryError {
6196            detail: format!("lex: {e}"),
6197        })?;
6198        if is_write_tokens(&tokens) {
6199            return Err(GraphError::MaskedReadOnly);
6200        }
6201        let union = parse_read(&tokens).map_err(|e| GraphError::QueryError {
6202            detail: format!("parse: {e}"),
6203        })?;
6204        // Each UNION part executes against the same masked view, so the mask
6205        // applies uniformly across the chain.
6206        execute_union(&self.view_masked(mask), &union, &Params(params)).map_err(|e| {
6207            GraphError::QueryError {
6208                detail: format!("execute: {e}"),
6209            }
6210        })
6211    }
6212
6213    pub fn node_ref(&self, key: &str) -> Option<NodeRef<'_, F>> {
6214        let id = self.ids.get(key)?;
6215        Some(NodeRef { db: self, id })
6216    }
6217
6218    /// BFS neighborhood expansion restricted to visible nodes in `mask`.
6219    ///
6220    /// Hidden nodes are never used as traversal intermediaries in either
6221    /// [`MaskMode::Omit`] or [`MaskMode::Stub`] — a visible node reachable
6222    /// only through a hidden node will not appear in results.
6223    ///
6224    /// In [`MaskMode::Stub`] mode, hidden nodes that are direct neighbours of
6225    /// a visited visible node are appended to the result as stub rows
6226    /// (`label` column is `null`, same key+depth columns as visible rows).
6227    /// They are NOT added to the BFS frontier.
6228    ///
6229    /// Returns `None` when `key` does not exist (caller should 404).
6230    ///
6231    /// **SECURITY**: role-token callers always pass an Omit-mode mask, so
6232    /// stub rows are never produced on the role path.
6233    pub fn neighborhood_masked(
6234        &self,
6235        key: &str,
6236        depth: u32,
6237        edge_types: Option<&[&str]>,
6238        dir: Dir,
6239        mask: &crate::mask::NodeMask,
6240    ) -> Option<ResultSet> {
6241        let start_id = self.ids.get(key)?;
6242        let view = self.view_masked(mask);
6243        let resolved: Option<Vec<u32>> = edge_types.map(|names| {
6244            names
6245                .iter()
6246                .filter_map(|name| view.syms.get(name))
6247                .collect()
6248        });
6249        let nb = neighborhood(&view, start_id, depth, resolved.as_deref(), dir);
6250        let mut rs = ResultSet::new(vec!["key".into(), "label".into(), "depth".into()]);
6251        // Collect visible BFS results (start_id at depth 0, BFS nodes after).
6252        let mut visited: Vec<(u32, u32)> = Vec::with_capacity(nb.nodes.len() + 1);
6253        visited.push((start_id, 0));
6254        for (nid, d) in &nb.nodes {
6255            let k = view.key_of(*nid);
6256            let label = view
6257                .label_of(*nid)
6258                .expect("real nodes always have a label; u32::MAX sentinel cannot occur");
6259            rs.push_row(vec![
6260                Some(Value::Str(k.to_string())),
6261                Some(Value::Str(label.to_string())),
6262                Some(Value::Int(*d as i64)),
6263            ]);
6264            visited.push((*nid, *d));
6265        }
6266        // Stub mode: add hidden direct neighbours of each visited node as stubs.
6267        // Hidden nodes are edge-endpoints only — they are not added to the BFS
6268        // frontier, so the BFS never expands through them.
6269        if mask.mode() == crate::mask::MaskMode::Stub {
6270            let raw_view = self.view();
6271            let mut seen: std::collections::HashSet<u32> =
6272                visited.iter().map(|(id, _)| *id).collect();
6273            for (node_id, node_depth) in &visited {
6274                if *node_depth >= depth {
6275                    continue;
6276                }
6277                for e in expand(&raw_view, *node_id, resolved.as_deref(), dir) {
6278                    let nbr = if e.src == *node_id { e.dst } else { e.src };
6279                    if !mask.contains_id(nbr) && seen.insert(nbr) {
6280                        if let Some(k) = self.ids.key_of(nbr) {
6281                            rs.push_row(vec![
6282                                Some(Value::Str(k.to_string())),
6283                                None,
6284                                Some(Value::Int((*node_depth + 1) as i64)),
6285                            ]);
6286                        }
6287                    }
6288                }
6289            }
6290        }
6291        Some(rs)
6292    }
6293
6294    /// Live node's key, label, and columnar props. Unknown or tombstoned → `None`.
6295    pub fn node_info(&self, key: &str) -> Option<NodeInfo> {
6296        let n = self.node_ref(key)?;
6297        Some(NodeInfo {
6298            key: n.key().to_string(),
6299            label: n.label().to_string(),
6300            props: n.props(),
6301        })
6302    }
6303
6304    /// Look up a node with mask awareness.
6305    ///
6306    /// | Key state         | Omit mode       | Stub mode              |
6307    /// |-------------------|-----------------|------------------------|
6308    /// | does not exist    | `None` (→ 404)  | `None` (→ 404)         |
6309    /// | exists, visible   | `Some(Visible)` | `Some(Visible)`        |
6310    /// | exists, hidden    | `None` (→ 404)  | `Some(Restricted)`     |
6311    ///
6312    /// **SECURITY**: only call from client-mask (full-token) paths.
6313    /// Role-token paths must use [`node_info`] after an explicit visibility check.
6314    pub fn node_info_masked(
6315        &self,
6316        key: &str,
6317        mask: &crate::mask::NodeMask,
6318    ) -> Option<MaskedNodeResult> {
6319        let id = self.ids.get(key)?;
6320        if mask.contains_id(id) {
6321            Some(MaskedNodeResult::Visible(self.node_info(key)?))
6322        } else {
6323            match mask.mode() {
6324                crate::mask::MaskMode::Stub => Some(MaskedNodeResult::Restricted),
6325                crate::mask::MaskMode::Omit => None,
6326            }
6327        }
6328    }
6329
6330    /// Get edges for `key` with mask-aware hidden-endpoint handling.
6331    ///
6332    /// - Omit mode: edges to hidden endpoints are excluded (same as role-path filtering).
6333    /// - Stub mode: edges to hidden endpoints are included; `src_restricted`/`dst_restricted`
6334    ///   is `true` for each hidden endpoint.
6335    ///
6336    /// Unknown key → [`GraphError::KeyNotFound`].
6337    ///
6338    /// **SECURITY**: only call from client-mask (full-token) paths.
6339    pub fn node_edges_masked(
6340        &self,
6341        key: &str,
6342        mask: &crate::mask::NodeMask,
6343    ) -> Result<Vec<MaskedEdge>> {
6344        self.ensure_v8_base_sections_loaded();
6345        let id = self
6346            .ids
6347            .get(key)
6348            .ok_or_else(|| GraphError::KeyNotFound { key: key.into() })?;
6349        let derived: BTreeSet<(u32, u32, u32)> = self
6350            .engine
6351            .provenance_touching(id)
6352            .map(|(_rule, etype, src, dst)| (etype, src, dst))
6353            .collect();
6354        let mut edges = Vec::new();
6355        let tv = self.topo_view();
6356        for etype in tv.etypes() {
6357            // etype comes from the archived CSR (access_unchecked, no eager CRC).
6358            // A bit-flip in the large TOPOLOGY section can produce an etype id
6359            // that is not in the interner.  Return Corrupt rather than panic.
6360            let edge_type = self
6361                .syms
6362                .resolve(etype)
6363                .ok_or_else(|| GraphError::Corrupt {
6364                    detail: format!("v8: topology etype {etype} not in interner"),
6365                })?
6366                .to_string();
6367            for dir in [Direction::Out, Direction::In] {
6368                for &nbr in tv.neighbors(etype, dir, id).as_ref() {
6369                    let nbr_restricted = !mask.contains_id(nbr);
6370                    if nbr_restricted && mask.mode() == crate::mask::MaskMode::Omit {
6371                        continue;
6372                    }
6373                    let nbr_key = self
6374                        .ids
6375                        .key_of(nbr)
6376                        .ok_or_else(|| GraphError::Corrupt {
6377                            detail: format!("topology id {nbr} has no key"),
6378                        })?
6379                        .to_string();
6380                    let (src_id, dst_id, src_key, dst_key, src_restricted, dst_restricted) =
6381                        match dir {
6382                            Direction::Out => {
6383                                (id, nbr, key.to_string(), nbr_key, false, nbr_restricted)
6384                            }
6385                            Direction::In => {
6386                                (nbr, id, nbr_key, key.to_string(), nbr_restricted, false)
6387                            }
6388                        };
6389                    edges.push(MaskedEdge {
6390                        edge_type: edge_type.clone(),
6391                        src_key,
6392                        src_restricted,
6393                        dst_key,
6394                        dst_restricted,
6395                        derived: derived.contains(&(etype, src_id, dst_id)),
6396                    });
6397                }
6398            }
6399        }
6400        edges.sort_by(|a, b| {
6401            a.edge_type
6402                .cmp(&b.edge_type)
6403                .then(a.src_key.cmp(&b.src_key))
6404                .then(a.dst_key.cmp(&b.dst_key))
6405        });
6406        edges.dedup_by(|a, b| {
6407            a.edge_type == b.edge_type && a.src_key == b.src_key && a.dst_key == b.dst_key
6408        });
6409        Ok(edges)
6410    }
6411
6412    /// Every directed edge incident on `key`, both directions, every etype.
6413    ///
6414    /// Walk is `topology.etypes()` × `{Out, In}` × `neighbors()`. `derived` is
6415    /// membership in [`RuleEngine::provenance_touching`] (O(degree) via the
6416    /// Plan-8 `by_node` index). Sorted by `(edge_type, src_key, dst_key)`.
6417    /// Unknown key → [`GraphError::KeyNotFound`].
6418    pub fn node_edges(&self, key: &str) -> Result<Vec<EdgeInfo>> {
6419        self.ensure_v8_base_sections_loaded();
6420        let id = self
6421            .ids
6422            .get(key)
6423            .ok_or_else(|| GraphError::KeyNotFound { key: key.into() })?;
6424        let derived: BTreeSet<(u32, u32, u32)> = self
6425            .engine
6426            .provenance_touching(id)
6427            .map(|(_rule, etype, src, dst)| (etype, src, dst))
6428            .collect();
6429        let mut edges = Vec::new();
6430        let tv = self.topo_view();
6431        for etype in tv.etypes() {
6432            // Same guard as node_edges_masked: etype from unchecked-CRC CSR.
6433            let edge_type = self
6434                .syms
6435                .resolve(etype)
6436                .ok_or_else(|| GraphError::Corrupt {
6437                    detail: format!("v8: topology etype {etype} not in interner"),
6438                })?
6439                .to_string();
6440            for dir in [Direction::Out, Direction::In] {
6441                for &nbr in tv.neighbors(etype, dir, id).as_ref() {
6442                    let (src, dst, src_key, dst_key) = match dir {
6443                        Direction::Out => (
6444                            id,
6445                            nbr,
6446                            key.to_string(),
6447                            self.ids
6448                                .key_of(nbr)
6449                                .ok_or_else(|| GraphError::Corrupt {
6450                                    detail: format!("topology id {nbr} has no key"),
6451                                })?
6452                                .to_string(),
6453                        ),
6454                        Direction::In => (
6455                            nbr,
6456                            id,
6457                            self.ids
6458                                .key_of(nbr)
6459                                .ok_or_else(|| GraphError::Corrupt {
6460                                    detail: format!("topology id {nbr} has no key"),
6461                                })?
6462                                .to_string(),
6463                            key.to_string(),
6464                        ),
6465                    };
6466                    edges.push(EdgeInfo {
6467                        edge_type: edge_type.clone(),
6468                        src_key,
6469                        dst_key,
6470                        derived: derived.contains(&(etype, src, dst)),
6471                    });
6472                }
6473            }
6474        }
6475        edges.sort_by(|a, b| {
6476            a.edge_type
6477                .cmp(&b.edge_type)
6478                .then(a.src_key.cmp(&b.src_key))
6479                .then(a.dst_key.cmp(&b.dst_key))
6480        });
6481        // Self-loops appear in both Out and In; sort makes the pair adjacent
6482        // (sort key matches PartialEq for this case) so one pass drops the dup.
6483        edges.dedup();
6484        Ok(edges)
6485    }
6486
6487    // ── Backup ────────────────────────────────────────────────────────────────
6488
6489    /// Copy this store to `dest` as a consistent, verified snapshot.
6490    ///
6491    /// Copies every durable file in the database directory — `snapshot.bin`,
6492    /// `wal.bin`, all `wal.<N>.archive` files, `wal.floor`, `wal.genesis`, and
6493    /// `roles.json` — into a freshly created `dest` directory using OS-level
6494    /// `copy` calls (no large in-process buffers).
6495    ///
6496    /// # Consistency guarantee
6497    ///
6498    /// The guarantee is **process-local**: the caller holds `&self`, which
6499    /// prevents any concurrent writer in the **same process** from modifying
6500    /// the files during the copy.  Running `mushroomdb backup` against a
6501    /// directory that is **concurrently being written by another process** (e.g.
6502    /// `mushroomdb serve`) is **unsafe** — the copy can be torn.  The post-copy
6503    /// `verified: true` result reduces but does not eliminate the risk of a
6504    /// silent corrupt backup (CRC catches many bit-flips; it cannot catch a
6505    /// consistent mid-write snapshot).
6506    ///
6507    /// **The safe path for a live-served store is `POST /backup` on the HTTP
6508    /// server.** That handler acquires the read lock on the shared database
6509    /// before calling this method, which is the correct cross-process
6510    /// synchronisation point because the server is the single process writing
6511    /// the files.
6512    ///
6513    /// After copying, opens the destination read-only and runs the CRC section
6514    /// verifier (`verify_snapshot`) to confirm byte-for-byte integrity.
6515    /// `BackupReport::verified` reflects whether both checks passed.
6516    ///
6517    /// Returns `Err` when `self` is not backed by a `RealFs` (e.g. `SimFs`).
6518    pub fn backup_to(&self, dest: &std::path::Path) -> Result<BackupReport> {
6519        // Derive source directory from snapshot_path (RealFs only).
6520        let src_dir = match self.fs.snapshot_path() {
6521            Some(p) => p.parent().map(|d| d.to_path_buf()).ok_or_else(|| {
6522                GraphError::Io(std::io::Error::other("snapshot has no parent dir"))
6523            })?,
6524            None => {
6525                return Err(GraphError::Io(std::io::Error::other(
6526                    "backup_to requires a real filesystem (RealFs)",
6527                )))
6528            }
6529        };
6530
6531        std::fs::create_dir_all(dest)?;
6532
6533        let mut files: Vec<String> = Vec::new();
6534        let mut bytes: u64 = 0;
6535
6536        // Helper: copy src_dir/name → dest/name if the file exists.
6537        let mut try_copy = |name: &str| -> std::io::Result<()> {
6538            let src_path = src_dir.join(name);
6539            if src_path.exists() {
6540                let n = std::fs::copy(&src_path, dest.join(name))?;
6541                bytes += n;
6542                files.push(name.to_string());
6543            }
6544            Ok(())
6545        };
6546
6547        try_copy("snapshot.bin")?;
6548        try_copy("snapshot.bin.bak")?;
6549        try_copy("wal.bin")?;
6550        try_copy("wal.floor")?;
6551        try_copy("wal.genesis")?;
6552        try_copy("roles.json")?;
6553
6554        // Copy WAL archives.
6555        let archives = self.fs.list_archives()?;
6556        for n in &archives {
6557            let name = format!("wal.{n}.archive");
6558            let n_bytes = std::fs::copy(src_dir.join(&name), dest.join(&name))?;
6559            bytes += n_bytes;
6560            files.push(name);
6561        }
6562
6563        files.sort();
6564
6565        // Post-copy verification: open dest and run CRC checks.
6566        let snap_in_dest = dest.join("snapshot.bin").exists();
6567        let crc_ok = if snap_in_dest {
6568            crate::verify_snapshot(dest)
6569                .map(|results| results.iter().all(|(_, _, _, r)| r.is_ok()))
6570                .unwrap_or(false)
6571        } else {
6572            true // WAL-only store: nothing to CRC-check in snapshot
6573        };
6574        let opens_ok = GraphDb::<core_storage::fs::RealFs>::open(dest).is_ok();
6575        let verified = crc_ok && opens_ok;
6576
6577        Ok(BackupReport {
6578            files,
6579            bytes,
6580            verified,
6581        })
6582    }
6583
6584    // ── Export helpers ────────────────────────────────────────────────────────
6585
6586    /// All live nodes, sorted by key (deterministic).
6587    ///
6588    /// Reads base + WAL overlay. Tombstoned nodes are excluded.
6589    pub fn all_nodes_for_export(&self) -> Vec<NodeInfo> {
6590        self.ensure_v8_base_sections_loaded();
6591        let pv = self.props_view();
6592        let mut nodes = Vec::new();
6593        for id in 0..self.ids.len() as u32 {
6594            let Some(key) = self.ids.key_of(id) else {
6595                continue;
6596            };
6597            let Some(&sym) = self.labels.get(id as usize) else {
6598                continue;
6599            };
6600            if sym == u32::MAX {
6601                continue; // tombstoned
6602            }
6603            let Some(label) = self.syms.resolve(sym) else {
6604                continue;
6605            };
6606            let mut props = BTreeMap::new();
6607            for field in pv.field_names() {
6608                if let Some(vr) = pv.get(id, &field) {
6609                    props.insert(field, vr.into_value());
6610                }
6611            }
6612            nodes.push(NodeInfo {
6613                key: key.to_string(),
6614                label: label.to_string(),
6615                props,
6616            });
6617        }
6618        nodes.sort_by(|a, b| a.key.cmp(&b.key));
6619        nodes
6620    }
6621
6622    /// All directed edges, sorted by `(edge_type, src, dst)`. Each edge appears once.
6623    ///
6624    /// Derived edges carry `derived: true` and the creating rule's name in `rule`.
6625    /// Manual edges carry `derived: false` and `rule: None`.
6626    /// Deterministic across runs on the same store state.
6627    pub fn all_edges_for_export(&self) -> Vec<ExportEdge> {
6628        self.ensure_v8_base_sections_loaded();
6629
6630        // Build (etype_sym, src_id, dst_id) → rule_name for O(1) derivation lookup.
6631        let mut prov: HashMap<(u32, u32, u32), String> = HashMap::new();
6632        for (rule_name, triples) in self.engine.provenance() {
6633            for &(etype, src, dst) in triples {
6634                prov.insert((etype, src, dst), rule_name.clone());
6635            }
6636        }
6637
6638        let tv = self.topo_view();
6639        let mut edges = Vec::new();
6640
6641        for id in 0..self.ids.len() as u32 {
6642            let Some(key) = self.ids.key_of(id) else {
6643                continue;
6644            };
6645            let Some(&lsym) = self.labels.get(id as usize) else {
6646                continue;
6647            };
6648            if lsym == u32::MAX {
6649                continue; // tombstoned
6650            }
6651
6652            for etype_sym in tv.etypes() {
6653                // etype from archived CSR (access_unchecked, no eager CRC).
6654                // Skip edges whose etype is not in the interner; this can only
6655                // occur with a corrupt large TOPOLOGY section (bit-flip on an
6656                // etype field in the archived data).  The function returns Vec,
6657                // not Result, so we continue rather than propagate.
6658                let Some(edge_type) = self.syms.resolve(etype_sym) else {
6659                    continue;
6660                };
6661                let edge_type = edge_type.to_string();
6662                for &nbr in tv.neighbors(etype_sym, Direction::Out, id).as_ref() {
6663                    let Some(dst_key) = self.ids.key_of(nbr) else {
6664                        continue; // skip corrupt entries
6665                    };
6666                    let prov_key = (etype_sym, id, nbr);
6667                    let rule = prov.get(&prov_key).cloned();
6668                    let derived = rule.is_some();
6669                    edges.push(ExportEdge {
6670                        edge_type: edge_type.clone(),
6671                        src: key.to_string(),
6672                        dst: dst_key.to_string(),
6673                        derived,
6674                        rule,
6675                    });
6676                }
6677            }
6678        }
6679
6680        edges.sort_by(|a, b| {
6681            a.edge_type
6682                .cmp(&b.edge_type)
6683                .then(a.src.cmp(&b.src))
6684                .then(a.dst.cmp(&b.dst))
6685        });
6686        edges
6687    }
6688
6689    pub fn nodes_with_label(&self, label: &str) -> Vec<NodeRef<'_, F>> {
6690        self.view()
6691            .nodes_with_label(label)
6692            .into_iter()
6693            .map(|id| NodeRef { db: self, id })
6694            .collect()
6695    }
6696
6697    pub fn find_nodes(&self, label: &str, filter: &Filter) -> Vec<NodeRef<'_, F>> {
6698        let view = self.view();
6699        view.nodes_with_label(label)
6700            .into_iter()
6701            .filter(|&id| {
6702                eval_filter(filter, &|field| {
6703                    view.prop(id, field).map(|vr| vr.into_value())
6704                })
6705            })
6706            .map(|id| NodeRef { db: self, id })
6707            .collect()
6708    }
6709
6710    /// Returns `true` if any approximate (HNSW) VectorSimilar rule covers
6711    /// `field`.  Use as a capability probe: when `true`, `find_similar_vector`
6712    /// with `label = None` will use the native ANN path rather than the O(n)
6713    /// brute-force scan.
6714    pub fn has_vector_rule(&self, field: &str) -> bool {
6715        self.engine.hnsw_has_rule(field)
6716    }
6717
6718    /// Find nodes whose `field` vector is most similar to `q` (cosine
6719    /// similarity), returning up to `k` results with similarity ≥ `min`,
6720    /// sorted descending.
6721    ///
6722    /// When `label` is `None` the search spans all labels (via
6723    /// `hnsw_search_any_dst` or a full brute-force scan); when `label` is
6724    /// `Some(lbl)` it restricts to nodes with that label.
6725    ///
6726    /// Uses the HNSW index when one is available (fast path); otherwise falls
6727    /// back to an O(n) brute-force scan.
6728    pub fn find_similar_vector(
6729        &self,
6730        field: &str,
6731        label: Option<&str>,
6732        q: &[f64],
6733        k: usize,
6734        min: f64,
6735    ) -> Vec<(String, f64)> {
6736        // Ensure any HNSW blobs retained from the snapshot are deserialized
6737        // before the first ANN query on a clean-open (no-WAL) path.
6738        self.engine.ensure_hnsw_loaded();
6739        // L2-normalise query for cosine via dot product.
6740        let norm: f64 = q.iter().map(|x| x * x).sum::<f64>().sqrt();
6741        if norm == 0.0 {
6742            return vec![];
6743        }
6744        let q_unit: Vec<f64> = q.iter().map(|x| x / norm).collect();
6745
6746        // Try HNSW fast path.
6747        // `None` label searches across all VectorSimilar rules covering `field`
6748        // (merging their results); `Some(lbl)` restricts to rules whose
6749        // dst_label matches.  Returns `None` when no populated HNSW index
6750        // covers the request — the O(n) brute-force fallback handles that case.
6751        let hnsw_hits = match label {
6752            Some(lbl) => self.engine.hnsw_search_dst(field, lbl, &q_unit, k),
6753            None => self.engine.hnsw_search_any_dst(field, &q_unit, k),
6754        };
6755        if let Some(hits) = hnsw_hits {
6756            let mut out: Vec<(String, f64)> = hits
6757                .into_iter()
6758                .filter(|&(_, sim)| sim >= min)
6759                .filter_map(|(id, sim)| self.ids.key_of(id).map(|key| (key.to_string(), sim)))
6760                .collect();
6761            out.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
6762            out.truncate(k);
6763            return out;
6764        }
6765
6766        // Brute-force fallback: O(n) scan (only reached when no HNSW index
6767        // covers the request).
6768        let view = self.view();
6769        let candidate_ids: Vec<u32> = match label {
6770            Some(lbl) => view.nodes_with_label(lbl),
6771            None => view.nodes_all(),
6772        };
6773        let mut scored: Vec<(String, f64)> = candidate_ids
6774            .into_iter()
6775            .filter_map(|id| {
6776                let v = view.prop(id, field)?;
6777                let v_owned = v.into_value();
6778                let xs = value_as_float_list(&v_owned)?;
6779                let v_norm: f64 = xs.iter().map(|x| x * x).sum::<f64>().sqrt();
6780                if v_norm == 0.0 {
6781                    return None;
6782                }
6783                let dot: f64 = q_unit
6784                    .iter()
6785                    .zip(xs.iter())
6786                    .map(|(a, b)| a * (b / v_norm))
6787                    .sum();
6788                if dot < min {
6789                    return None;
6790                }
6791                let key = self.ids.key_of(id)?.to_string();
6792                Some((key, dot))
6793            })
6794            .collect();
6795        scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
6796        scored.truncate(k);
6797        scored
6798    }
6799
6800    /// Like [`find_similar_vector`] but restricts results to nodes visible in
6801    /// `mask`. Hidden nodes never appear in results; the mask is applied
6802    /// **before** k-truncation so a caller still receives up to `k` visible
6803    /// hits.
6804    ///
6805    /// # HNSW path (over-fetch policy)
6806    ///
6807    /// When an HNSW index covers the request, this function fetches `4 * k`
6808    /// candidates from the index and discards hidden nodes in the post-filter
6809    /// step.  If fewer than `k` visible nodes remain after filtering the caller
6810    /// receives whatever is available — we do not re-query the index.  The 4×
6811    /// multiplier is a heuristic suited for sparsely masked graphs; callers
6812    /// operating under a very selective mask should register a VectorSimilar
6813    /// rule with a non-approximate index, or use the brute-force path (no HNSW
6814    /// rule) which exhaustively filters through the masked [`GraphView`].
6815    ///
6816    /// # Brute-force path
6817    ///
6818    /// When no HNSW index covers the request the function builds a masked
6819    /// [`GraphView`] so that `nodes_all` / `nodes_with_label` return only
6820    /// visible nodes, guaranteeing exact `k` results (or all visible nodes if
6821    /// fewer than `k` exist).
6822    pub fn find_similar_vector_masked(
6823        &self,
6824        field: &str,
6825        label: Option<&str>,
6826        q: &[f64],
6827        k: usize,
6828        min: f64,
6829        mask: &crate::mask::NodeMask,
6830    ) -> Vec<(String, f64)> {
6831        self.engine.ensure_hnsw_loaded();
6832        let norm: f64 = q.iter().map(|x| x * x).sum::<f64>().sqrt();
6833        if norm == 0.0 {
6834            return vec![];
6835        }
6836        let q_unit: Vec<f64> = q.iter().map(|x| x / norm).collect();
6837
6838        // HNSW fast path — over-fetch 4×k so post-masking still yields up to k
6839        // visible hits.  See doc comment above for the policy rationale.
6840        let over_k = k.saturating_mul(4).max(k + 1);
6841        let hnsw_hits = match label {
6842            Some(lbl) => self.engine.hnsw_search_dst(field, lbl, &q_unit, over_k),
6843            None => self.engine.hnsw_search_any_dst(field, &q_unit, over_k),
6844        };
6845        if let Some(hits) = hnsw_hits {
6846            let mut out: Vec<(String, f64)> = hits
6847                .into_iter()
6848                .filter(|&(id, sim)| sim >= min && mask.visible.contains(&id))
6849                .filter_map(|(id, sim)| self.ids.key_of(id).map(|key| (key.to_string(), sim)))
6850                .collect();
6851            out.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
6852            out.truncate(k);
6853            return out;
6854        }
6855
6856        // Brute-force fallback — masked view ensures only visible nodes are
6857        // enumerated by nodes_all(); nodes_with_label() does not filter by
6858        // mask so we apply view.visible() explicitly for the labeled case.
6859        let view = self.view_masked(mask);
6860        let candidate_ids: Vec<u32> = match label {
6861            Some(lbl) => view
6862                .nodes_with_label(lbl)
6863                .into_iter()
6864                .filter(|&id| view.visible(id))
6865                .collect(),
6866            None => view.nodes_all(),
6867        };
6868        let mut scored: Vec<(String, f64)> = candidate_ids
6869            .into_iter()
6870            .filter_map(|id| {
6871                let v = view.prop(id, field)?;
6872                let v_owned = v.into_value();
6873                let xs = value_as_float_list(&v_owned)?;
6874                let v_norm: f64 = xs.iter().map(|x| x * x).sum::<f64>().sqrt();
6875                if v_norm == 0.0 {
6876                    return None;
6877                }
6878                let dot: f64 = q_unit
6879                    .iter()
6880                    .zip(xs.iter())
6881                    .map(|(a, b)| a * (b / v_norm))
6882                    .sum();
6883                if dot < min {
6884                    return None;
6885                }
6886                let key = self.ids.key_of(id)?.to_string();
6887                Some((key, dot))
6888            })
6889            .collect();
6890        scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
6891        scored.truncate(k);
6892        scored
6893    }
6894
6895    /// Read a single property from an edge.
6896    ///
6897    /// Returns `None` when the edge does not exist, the field is absent, or any
6898    /// of the string keys cannot be resolved to interned ids.  Only edge props
6899    /// written by rules (weight fields) are accessible without a `set_edge_prop`
6900    /// binding; topology-only edges (no props set) return `None` for every field.
6901    pub fn get_edge_prop(
6902        &self,
6903        edge_type: &str,
6904        src_key: &str,
6905        dst_key: &str,
6906        field: &str,
6907    ) -> Option<Value> {
6908        let etype = self.syms.get(edge_type)?;
6909        let src = self.ids.get(src_key)?;
6910        let dst = self.ids.get(dst_key)?;
6911        self.edge_props_view().get(etype, src, dst, field)
6912    }
6913
6914    /// Lex → parse → plan → execute `cypher` over a read-only view.
6915    /// Every pipeline `Err(String)` becomes `GraphError::QueryError` with a
6916    /// stage prefix (`lex:` / `parse:` / `plan:` / `execute:`).
6917    pub fn query(&self, cypher: &str, params: &BTreeMap<String, Value>) -> Result<ResultSet> {
6918        let tokens = lex(cypher).map_err(|e| GraphError::QueryError {
6919            detail: format!("lex: {e}"),
6920        })?;
6921        let union = parse_read(&tokens).map_err(|e| GraphError::QueryError {
6922            detail: format!("parse: {e}"),
6923        })?;
6924        let t0 = std::time::Instant::now();
6925        let result = execute_union(&self.view(), &union, &Params(params)).map_err(|e| {
6926            GraphError::QueryError {
6927                detail: format!("execute: {e}"),
6928            }
6929        });
6930        let elapsed_ms = t0.elapsed().as_millis() as u64;
6931        let threshold = self.slow_query_threshold_ms;
6932        if threshold > 0 && elapsed_ms >= threshold {
6933            eprintln!("[mushroomdb] slow query ({elapsed_ms}ms): {cypher}");
6934            let entry = SlowQueryEntry {
6935                ms: elapsed_ms,
6936                query: cypher.to_string(),
6937                at_commit: self.commit_seq,
6938            };
6939            if let Ok(mut log) = self.slow_queries.lock() {
6940                if log.entries.len() == SLOW_QUERY_RING_CAP {
6941                    log.entries.pop_front();
6942                }
6943                log.entries.push_back(entry);
6944                log.total += 1;
6945            }
6946        }
6947        result
6948    }
6949
6950    /// Convenience entry-point that accepts a slice of `(name, value)` pairs
6951    /// instead of a pre-built `BTreeMap`.  Equivalent to building the map and
6952    /// calling [`GraphDb::query`].
6953    pub fn query_with_params(&self, cypher: &str, params: &[(&str, Value)]) -> Result<ResultSet> {
6954        let map: BTreeMap<String, Value> = params
6955            .iter()
6956            .map(|(k, v)| (k.to_string(), v.clone()))
6957            .collect();
6958        self.query(cypher, &map)
6959    }
6960
6961    /// Execute a Cypher write statement (CREATE / MATCH…SET / MATCH…DELETE / MERGE).
6962    ///
6963    /// All mutations flow through the same `insert_node` / `set_prop` /
6964    /// `delete_edge` / `insert_edge` path as the Rust API so the rule engine
6965    /// fires and the WAL captures everything with one fsync per statement.
6966    ///
6967    /// Returns a one-row [`ResultSet`] with columns `created`, `properties_set`,
6968    /// and `deleted` matching the write-result contract.
6969    ///
6970    /// **Mutation routing**: mutations are collected into a single
6971    /// [`BatchBuilder`] and committed atomically (one WAL `Batch` frame, one
6972    /// fsync). The MATCH phase for SET/DELETE uses a read-only `execute` call
6973    /// over `self.view()` — the borrow is dropped before the batch is opened.
6974    ///
6975    /// **Limitations (v1)**:
6976    /// - SET RHS must be a literal, `$param`, or arithmetic; bare property copy → named error.
6977    /// - `DETACH DELETE n` → calls `delete_node` for each matched node (removes all edges).
6978    /// - Bare `DELETE n` → error if n has any incident edges; succeeds for isolated nodes.
6979    /// - MERGE supports `ON CREATE SET` / `ON MATCH SET` in the same write batch.
6980    /// - Deleting a derived edge → named error "cannot delete derived edge".
6981    pub fn query_write(
6982        &mut self,
6983        cypher: &str,
6984        params: &BTreeMap<String, Value>,
6985    ) -> Result<ResultSet> {
6986        let tokens = lex(cypher).map_err(|e| GraphError::QueryError {
6987            detail: format!("lex: {e}"),
6988        })?;
6989        let stmt = parse_write(&tokens).map_err(|e| GraphError::QueryError {
6990            detail: format!("parse: {e}"),
6991        })?;
6992        self.exec_write_stmt(stmt, params)
6993    }
6994
6995    fn exec_write_stmt(
6996        &mut self,
6997        stmt: WriteStatement,
6998        params: &BTreeMap<String, Value>,
6999    ) -> Result<ResultSet> {
7000        match stmt {
7001            WriteStatement::Create(s) => self.exec_create(s, params),
7002            WriteStatement::MatchSet(s) => self.exec_match_set(s, params),
7003            WriteStatement::MatchDelete(s) => self.exec_match_delete(s, params),
7004            WriteStatement::MatchDeleteNode(s) => self.exec_match_delete_node(s, params),
7005            WriteStatement::Merge(s) => self.exec_merge(s, params),
7006        }
7007    }
7008
7009    fn exec_create(
7010        &mut self,
7011        stmt: core_query::cypher::CreateStmt,
7012        params: &BTreeMap<String, Value>,
7013    ) -> Result<ResultSet> {
7014        // Extract the node key from props: require a string-valued `id` field.
7015        let mut var_to_key: BTreeMap<String, String> = BTreeMap::new();
7016        for node in &stmt.nodes {
7017            let var = node.var.as_deref().unwrap_or("_cn0");
7018            let key = node
7019                .props
7020                .iter()
7021                .find(|(f, _)| f == "id")
7022                .and_then(|(_, v)| {
7023                    if let Value::Str(s) = v {
7024                        Some(s.clone())
7025                    } else {
7026                        None
7027                    }
7028                })
7029                .ok_or_else(|| GraphError::QueryError {
7030                    detail: format!(
7031                        "CREATE node ({}:{}) requires a string 'id' property",
7032                        var, node.label
7033                    ),
7034                })?;
7035            var_to_key.insert(var.to_string(), key);
7036        }
7037
7038        let mut batch = self.batch();
7039        let mut created: usize = 0;
7040        for node in &stmt.nodes {
7041            let var = node.var.as_deref().unwrap_or("_cn0");
7042            let key = &var_to_key[var];
7043            batch.insert_node(&node.label, key, node.props.clone());
7044            created += 1;
7045        }
7046        for edge in &stmt.edges {
7047            let src_key = var_to_key
7048                .get(&edge.src_var)
7049                .ok_or_else(|| GraphError::QueryError {
7050                    detail: format!("CREATE edge src variable '{}' is not bound", edge.src_var),
7051                })?;
7052            let dst_key = var_to_key
7053                .get(&edge.dst_var)
7054                .ok_or_else(|| GraphError::QueryError {
7055                    detail: format!("CREATE edge dst variable '{}' is not bound", edge.dst_var),
7056                })?;
7057            batch.insert_edge(&edge.etype, src_key, dst_key);
7058        }
7059        batch.commit()?;
7060
7061        // Optional RETURN clause: project created bindings as a read result.
7062        if let Some(returns) = stmt.returns {
7063            // Each created node is looked up by its key via a separate MATCH pattern.
7064            // Multiple single-node patterns cross-join to produce 1 output row with
7065            // all variables bound (each pattern returns exactly 1 row).
7066            let patterns: Vec<Pattern> = stmt
7067                .nodes
7068                .iter()
7069                .map(|node| {
7070                    let var = node.var.as_deref().unwrap_or("_cn0");
7071                    let key = var_to_key[var].clone();
7072                    Pattern {
7073                        start: NodePat {
7074                            var: Some(var.to_string()),
7075                            label: Some(node.label.clone()),
7076                            props: vec![("id".to_string(), Operand::Lit(Value::Str(key)))],
7077                        },
7078                        chain: vec![],
7079                        shortest: false,
7080                    }
7081                })
7082                .collect();
7083            let q = Query {
7084                matches: patterns,
7085                optional_clauses: vec![],
7086                where_expr: None,
7087                unwinds: vec![],
7088                post_unwind_where: None,
7089                stages: vec![],
7090                returns,
7091                distinct: false,
7092                order_by: vec![],
7093                skip: None,
7094                limit: None,
7095            };
7096            let ops = plan(&q).map_err(|e| GraphError::QueryError {
7097                detail: format!("plan: {e}"),
7098            })?;
7099            return execute(&self.view(), &ops, &Params(params)).map_err(|e| {
7100                GraphError::QueryError {
7101                    detail: format!("execute: {e}"),
7102                }
7103            });
7104        }
7105
7106        let mut rs = write_result_set();
7107        rs.push_row(vec![
7108            Some(Value::Int(created as i64)),
7109            Some(Value::Int(0)),
7110            Some(Value::Int(0)),
7111        ]);
7112        Ok(rs)
7113    }
7114
7115    fn exec_match_set(
7116        &mut self,
7117        stmt: core_query::cypher::MatchSetStmt,
7118        params: &BTreeMap<String, Value>,
7119    ) -> Result<ResultSet> {
7120        let project_returns = stmt.returns.clone();
7121        // Collect unique node vars targeted by SET clauses, plus RETURN bindings
7122        // so the post-write projection can look them up by key.
7123        let mut set_vars: Vec<String> = Vec::new();
7124        for s in &stmt.sets {
7125            if !set_vars.contains(&s.var) {
7126                set_vars.push(s.var.clone());
7127            }
7128        }
7129        let rel_vars = pattern_rel_vars(&stmt.matches);
7130        let mut lookup_vars = set_vars.clone();
7131        for v in pattern_node_vars(&stmt.matches) {
7132            add_var(&mut lookup_vars, &v);
7133        }
7134        if let Some(ref returns) = project_returns {
7135            for v in ret_node_vars(returns) {
7136                if !rel_vars.iter().any(|r| r == &v) {
7137                    add_var(&mut lookup_vars, &v);
7138                }
7139            }
7140        }
7141
7142        // Synthesize a read query: MATCH … WHERE … RETURN <lookup_vars>, <set_values…>
7143        // SET values are projected as ScalarExpr items so that arithmetic expressions
7144        // (e.g. `SET n.score = n.score * 1.5`) are evaluated in the matched-row context.
7145        let mut set_returns: Vec<RetItem> = lookup_vars
7146            .iter()
7147            .map(|v| RetItem {
7148                value: RetVal::Var(v.clone()),
7149                alias: None,
7150            })
7151            .collect();
7152        // One computed column per SET clause; alias is `__sv_<i>`.
7153        let set_val_cols: Vec<String> = stmt
7154            .sets
7155            .iter()
7156            .enumerate()
7157            .map(|(i, _)| format!("__sv_{i}"))
7158            .collect();
7159        for (sc, col) in stmt.sets.iter().zip(&set_val_cols) {
7160            set_returns.push(RetItem {
7161                value: RetVal::ScalarExpr(sc.value.clone()),
7162                alias: Some(col.clone()),
7163            });
7164        }
7165        // Capture relationship types while r is bound; SET does not change them.
7166        for r in &rel_vars {
7167            set_returns.push(RetItem {
7168                value: RetVal::FuncCall {
7169                    name: "type".into(),
7170                    args: vec![Operand::Var(r.clone())],
7171                },
7172                alias: Some(rel_type_alias(r)),
7173            });
7174        }
7175
7176        let read_q = Query {
7177            matches: stmt.matches.clone(),
7178            optional_clauses: vec![],
7179            where_expr: stmt.where_expr.clone(),
7180            unwinds: vec![],
7181            post_unwind_where: None,
7182            stages: vec![],
7183            returns: set_returns,
7184            distinct: false,
7185            order_by: vec![],
7186            skip: None,
7187            limit: None,
7188        };
7189        let ops = plan(&read_q).map_err(|e| GraphError::QueryError {
7190            detail: format!("plan: {e}"),
7191        })?;
7192        // MATCH phase is read-only; borrow ends before batch opens.
7193        //
7194        // When a role-scoped write is in flight, run the MATCH read through
7195        // view_masked so hidden nodes are invisible → hidden ≡ absent ≡
7196        // zero-rows (no SetProp ops generated, no existence-oracle 403).
7197        // Full-authority writes (pending_write_authz=None) keep view().
7198        let match_rs = {
7199            let mask_opt = self.pending_write_authz.as_ref().map(|a| a.mask.clone());
7200            if let Some(ref mask) = mask_opt {
7201                execute(&self.view_masked(mask), &ops, &Params(params))
7202            } else {
7203                execute(&self.view(), &ops, &Params(params))
7204            }
7205        }
7206        .map_err(|e| GraphError::QueryError {
7207            detail: format!("execute: {e}"),
7208        })?;
7209
7210        // Collect (key, field, value) for each matched row × each SET clause.
7211        let mut set_ops: Vec<(String, String, Value)> = Vec::new();
7212        for row_i in 0..match_rs.len() {
7213            for (sc, col) in stmt.sets.iter().zip(&set_val_cols) {
7214                let key = match match_rs.get(row_i, &sc.var) {
7215                    Some(Value::Str(k)) => k.clone(),
7216                    _ => {
7217                        return Err(GraphError::QueryError {
7218                            detail: format!(
7219                                "SET variable '{}' did not resolve to a node key",
7220                                sc.var
7221                            ),
7222                        })
7223                    }
7224                };
7225                // The SET value was already evaluated by the executor.
7226                let value = match match_rs.get(row_i, col) {
7227                    Some(v) => v.clone(),
7228                    None => {
7229                        return Err(GraphError::QueryError {
7230                            detail: format!(
7231                                "SET value for {}.{} evaluated to null",
7232                                sc.var, sc.field
7233                            ),
7234                        })
7235                    }
7236                };
7237                set_ops.push((key, sc.field.clone(), value));
7238            }
7239        }
7240
7241        // Apply as one atomic batch.
7242        let props_set = set_ops.len();
7243        let mut batch = self.batch();
7244        for (key, field, value) in set_ops {
7245            batch.set_prop(&key, &field, value);
7246        }
7247        batch.commit()?;
7248
7249        if let Some(returns) = project_returns {
7250            return project_set_return_rows(self, &rel_vars, &match_rs, &returns, params);
7251        }
7252
7253        let mut rs = write_result_set();
7254        rs.push_row(vec![
7255            Some(Value::Int(0)),
7256            Some(Value::Int(props_set as i64)),
7257            Some(Value::Int(0)),
7258        ]);
7259        Ok(rs)
7260    }
7261
7262    fn exec_match_delete(
7263        &mut self,
7264        stmt: core_query::cypher::MatchDeleteStmt,
7265        params: &BTreeMap<String, Value>,
7266    ) -> Result<ResultSet> {
7267        // Collect unique node vars needed to identify edge endpoints.
7268        let mut node_vars: Vec<String> = Vec::new();
7269        for ed in &stmt.deletes {
7270            if !node_vars.contains(&ed.src_var) {
7271                node_vars.push(ed.src_var.clone());
7272            }
7273            if !node_vars.contains(&ed.dst_var) {
7274                node_vars.push(ed.dst_var.clone());
7275            }
7276        }
7277
7278        // Synthesize read query.
7279        let returns: Vec<RetItem> = node_vars
7280            .iter()
7281            .map(|v| RetItem {
7282                value: RetVal::Var(v.clone()),
7283                alias: None,
7284            })
7285            .collect();
7286        let read_q = Query {
7287            matches: stmt.matches,
7288            optional_clauses: vec![],
7289            where_expr: stmt.where_expr,
7290            unwinds: vec![],
7291            post_unwind_where: None,
7292            stages: vec![],
7293            returns,
7294            distinct: false,
7295            order_by: vec![],
7296            skip: None,
7297            limit: None,
7298        };
7299        let ops = plan(&read_q).map_err(|e| GraphError::QueryError {
7300            detail: format!("plan: {e}"),
7301        })?;
7302        // Role-scoped writes: mask the MATCH read phase so hidden nodes are
7303        // invisible → hidden ≡ absent ≡ zero-rows (spec §3.1, hidden ≡ absent).
7304        let match_rs = {
7305            let mask_opt = self.pending_write_authz.as_ref().map(|a| a.mask.clone());
7306            if let Some(ref mask) = mask_opt {
7307                execute(&self.view_masked(mask), &ops, &Params(params))
7308            } else {
7309                execute(&self.view(), &ops, &Params(params))
7310            }
7311        }
7312        .map_err(|e| GraphError::QueryError {
7313            detail: format!("execute: {e}"),
7314        })?;
7315
7316        // Collect (etype, src_key, dst_key) for each row × each delete target.
7317        let mut del_ops: Vec<(String, String, String)> = Vec::new();
7318        for row_i in 0..match_rs.len() {
7319            for ed in &stmt.deletes {
7320                let src_key = match match_rs.get(row_i, &ed.src_var) {
7321                    Some(Value::Str(k)) => k.clone(),
7322                    _ => {
7323                        return Err(GraphError::QueryError {
7324                            detail: format!(
7325                                "DELETE src variable '{}' did not resolve to a node key",
7326                                ed.src_var
7327                            ),
7328                        })
7329                    }
7330                };
7331                let dst_key = match match_rs.get(row_i, &ed.dst_var) {
7332                    Some(Value::Str(k)) => k.clone(),
7333                    _ => {
7334                        return Err(GraphError::QueryError {
7335                            detail: format!(
7336                                "DELETE dst variable '{}' did not resolve to a node key",
7337                                ed.dst_var
7338                            ),
7339                        })
7340                    }
7341                };
7342                del_ops.push((ed.etype.clone(), src_key, dst_key));
7343            }
7344        }
7345
7346        // Apply as one atomic batch.
7347        let deleted = del_ops.len();
7348        let mut batch = self.batch();
7349        for (etype, src_key, dst_key) in del_ops {
7350            batch.delete_edge(&etype, &src_key, &dst_key);
7351        }
7352        batch.commit().map_err(|e| match e {
7353            GraphError::RuleOwned { .. } => GraphError::QueryError {
7354                detail: "cannot delete derived edge; retract via the rule or change the property"
7355                    .to_string(),
7356            },
7357            other => other,
7358        })?;
7359
7360        let mut rs = write_result_set();
7361        rs.push_row(vec![
7362            Some(Value::Int(0)),
7363            Some(Value::Int(0)),
7364            Some(Value::Int(deleted as i64)),
7365        ]);
7366        Ok(rs)
7367    }
7368
7369    /// Execute `MATCH … [DETACH] DELETE <node_var> [, …]`.
7370    ///
7371    /// Collects the matching node keys via an ephemeral read query, then calls
7372    /// `delete_node` on each one.  When `stmt.detach` is `false` (bare DELETE)
7373    /// the executor first checks that the node has no incident edges; if any
7374    /// remain it returns a named error matching openCypher semantics.
7375    fn exec_match_delete_node(
7376        &mut self,
7377        stmt: MatchDeleteNodeStmt,
7378        params: &BTreeMap<String, Value>,
7379    ) -> Result<ResultSet> {
7380        // Build a read query returning only the node keys we need.
7381        let returns: Vec<RetItem> = stmt
7382            .node_vars
7383            .iter()
7384            .map(|v| RetItem {
7385                value: RetVal::Var(v.clone()),
7386                alias: None,
7387            })
7388            .collect();
7389        let read_q = Query {
7390            matches: stmt.matches,
7391            optional_clauses: vec![],
7392            where_expr: stmt.where_expr,
7393            unwinds: vec![],
7394            post_unwind_where: None,
7395            stages: vec![],
7396            returns,
7397            distinct: false,
7398            order_by: vec![],
7399            skip: None,
7400            limit: None,
7401        };
7402        let ops = plan(&read_q).map_err(|e| GraphError::QueryError {
7403            detail: format!("plan: {e}"),
7404        })?;
7405        // Role-scoped writes: mask the MATCH read phase so hidden nodes are
7406        // invisible → hidden ≡ absent ≡ zero-rows (spec §3.1, hidden ≡ absent).
7407        let match_rs = {
7408            let mask_opt = self.pending_write_authz.as_ref().map(|a| a.mask.clone());
7409            if let Some(ref mask) = mask_opt {
7410                execute(&self.view_masked(mask), &ops, &Params(params))
7411            } else {
7412                execute(&self.view(), &ops, &Params(params))
7413            }
7414        }
7415        .map_err(|e| GraphError::QueryError {
7416            detail: format!("execute: {e}"),
7417        })?;
7418
7419        // Collect unique node keys to delete (deduplicate across rows × vars).
7420        let mut keys: Vec<String> = Vec::new();
7421        for row_i in 0..match_rs.len() {
7422            for var in &stmt.node_vars {
7423                if let Some(Value::Str(k)) = match_rs.get(row_i, var) {
7424                    if !keys.contains(k) {
7425                        keys.push(k.clone());
7426                    }
7427                }
7428            }
7429        }
7430
7431        if !stmt.detach {
7432            // openCypher bare DELETE: error if any matched node has incident edges.
7433            for key in &keys {
7434                if let Some(id) = self.ids.get(key) {
7435                    let tv = self.topo_view();
7436                    let has_edges = tv.etypes().any(|et| {
7437                        !tv.neighbors(et, Direction::Out, id).is_empty()
7438                            || !tv.neighbors(et, Direction::In, id).is_empty()
7439                    });
7440                    if has_edges {
7441                        return Err(GraphError::QueryError {
7442                            detail: format!(
7443                                "Cannot delete node `{key}` because it still has incident edges. \
7444                                 Use DETACH DELETE to remove the node and all its edges."
7445                            ),
7446                        });
7447                    }
7448                }
7449            }
7450        }
7451
7452        let mut nodes_deleted = 0i64;
7453        let mut edges_deleted = 0i64;
7454        for key in keys {
7455            match self.delete_node(&key) {
7456                Ok(report) => {
7457                    nodes_deleted += 1;
7458                    edges_deleted += (report.manual_edges + report.derived_edges) as i64;
7459                }
7460                Err(GraphError::KeyNotFound { .. }) => {
7461                    // Node may have been deleted by an earlier iteration (e.g., via
7462                    // multiple MATCH rows for the same node).  Safe to skip.
7463                }
7464                Err(e) => return Err(e),
7465            }
7466        }
7467
7468        let mut rs = write_result_set();
7469        rs.push_row(vec![
7470            Some(Value::Int(0)),
7471            Some(Value::Int(0)),
7472            Some(Value::Int(nodes_deleted + edges_deleted)),
7473        ]);
7474        Ok(rs)
7475    }
7476
7477    fn exec_merge(
7478        &mut self,
7479        stmt: core_query::cypher::MergeStmt,
7480        params: &BTreeMap<String, Value>,
7481    ) -> Result<ResultSet> {
7482        // MERGE: check if a node with the given key already exists.
7483        let key = match &stmt.key_value {
7484            Value::Str(s) => s.clone(),
7485            _ => {
7486                return Err(GraphError::QueryError {
7487                    detail: format!(
7488                        "MERGE key value must be a string (got {:?})",
7489                        stmt.key_value
7490                    ),
7491                })
7492            }
7493        };
7494
7495        if let Some(var) = stmt.var.as_deref() {
7496            for sc in stmt.on_create.iter().chain(&stmt.on_match) {
7497                if sc.var != var {
7498                    return Err(GraphError::QueryError {
7499                        detail: format!(
7500                            "SET variable '{}' does not match MERGE variable '{var}'",
7501                            sc.var
7502                        ),
7503                    });
7504                }
7505            }
7506        }
7507
7508        // ── MERGE authz pre-check (when role-scoped) ─────────────────────────
7509        //
7510        // MERGE scope precondition: check create OR update scope for the
7511        // declared label BEFORE calling `has_node` (timing-oracle closure,
7512        // spec §6.2 "MERGE visibility oracle" item: hidden ≡ absent for
7513        // unscoped roles — the scope denial fires without touching the key store).
7514        //
7515        // Clone to avoid holding a borrow on `self.pending_write_authz` while
7516        // also calling `self.ids.get(key)`.
7517        let merge_existed: bool = if let Some(authz) = self.pending_write_authz.clone() {
7518            let has_create = authz.scope.create_labels.contains(&stmt.label);
7519            let has_update = authz.scope.update_labels.contains(&stmt.label);
7520            if !has_create && !has_update {
7521                // Scope-before-lookup: 403 without has_node call (timing oracle
7522                // closure — see test_merge_unscoped_no_key_lookup).
7523                return Err(GraphError::RoleWriteDenied {
7524                    reason: format!(
7525                        "role-bound token: label '{}' not in write scope (create_labels)",
7526                        stmt.label
7527                    ),
7528                });
7529            }
7530            // Key lookup under mask.
7531            match self.ids.get(key.as_str()) {
7532                Some(id) if authz.mask.contains_id(id) => {
7533                    // Visible: must have update scope to proceed to match arm.
7534                    if !has_update {
7535                        return Err(GraphError::RoleWriteDenied {
7536                            reason: format!(
7537                                "role-bound token: label '{}' not in write scope (update_labels)",
7538                                stmt.label
7539                            ),
7540                        });
7541                    }
7542                    true // existed = true → match arm
7543                }
7544                Some(_) => {
7545                    // Hidden: same error as absent to the role (spec §3.1/§3.3).
7546                    return Err(GraphError::RoleWriteDenied {
7547                        reason: "role-bound token: target node not visible".into(),
7548                    });
7549                }
7550                None => {
7551                    // Absent: must have create scope to proceed to the create arm.
7552                    //
7553                    // Update-only roles (create_labels empty, update_labels set):
7554                    // return the SAME "not visible" error as the hidden-key branch
7555                    // so hidden ≡ absent — no distinguishing oracle (spec §6.1
7556                    // "confirm existence of hidden nodes: No").
7557                    //
7558                    // Create-scoped roles (has_create=true): absent → create arm
7559                    // as before.  The accepted structural key-existence disclosure
7560                    // (§THREAT-MODEL) applies only when the role holds create scope.
7561                    if !has_create {
7562                        return Err(GraphError::RoleWriteDenied {
7563                            reason: "role-bound token: target node not visible".into(),
7564                        });
7565                    }
7566                    false // existed = false → create arm
7567                }
7568            }
7569        } else {
7570            // Full authority: use the existing non-masked has_node check.
7571            self.has_node(&key)
7572        };
7573
7574        let existed = merge_existed;
7575        let mut created = 0i64;
7576        if !existed || !stmt.on_match.is_empty() {
7577            let mut batch = self.batch();
7578            if !existed {
7579                let props = vec![(stmt.key_field.clone(), stmt.key_value.clone())];
7580                batch.insert_node(&stmt.label, &key, props);
7581                for sc in &stmt.on_create {
7582                    let value = resolve_merge_set_value(&sc.value, params)?;
7583                    batch.set_prop(&key, &sc.field, value);
7584                }
7585                created = 1;
7586            } else {
7587                for sc in &stmt.on_match {
7588                    let value = resolve_merge_set_value(&sc.value, params)?;
7589                    batch.set_prop(&key, &sc.field, value);
7590                }
7591            }
7592            batch.commit()?;
7593        }
7594
7595        // Refresh the role mask so the just-created node is visible to this
7596        // statement's RETURN (read-after-write). Safe: create_labels ⊆ read labels
7597        // (apply_schema subset rule), so the new node's label is already in the
7598        // role's read scope — this never widens beyond the role's declared labels.
7599        if !existed {
7600            if let Some(role) = self.pending_write_authz.as_ref().map(|a| a.role.clone()) {
7601                let new_mask = self.mask_for_role(&role)?;
7602                if let Some(a) = self.pending_write_authz.as_mut() {
7603                    a.mask = new_mask;
7604                }
7605            }
7606        }
7607
7608        // Optional RETURN clause: project the node (created or matched) as a read result.
7609        if let Some(returns) = stmt.returns {
7610            let var = stmt.var.as_deref().unwrap_or("_mn0");
7611            let q = Query {
7612                matches: vec![Pattern {
7613                    start: NodePat {
7614                        var: Some(var.to_string()),
7615                        label: Some(stmt.label.clone()),
7616                        props: vec![("id".to_string(), Operand::Lit(stmt.key_value.clone()))],
7617                    },
7618                    chain: vec![],
7619                    shortest: false,
7620                }],
7621                optional_clauses: vec![],
7622                where_expr: None,
7623                unwinds: vec![],
7624                post_unwind_where: None,
7625                stages: vec![],
7626                returns,
7627                distinct: false,
7628                order_by: vec![],
7629                skip: None,
7630                limit: None,
7631            };
7632            let ops = plan(&q).map_err(|e| GraphError::QueryError {
7633                detail: format!("plan: {e}"),
7634            })?;
7635            // Use view_masked when a role-scoped write is in flight so the
7636            // post-merge projection is consistent with the masked read phase.
7637            let mask_opt = self.pending_write_authz.as_ref().map(|a| a.mask.clone());
7638            return (if let Some(ref mask) = mask_opt {
7639                execute(&self.view_masked(mask), &ops, &Params(params))
7640            } else {
7641                execute(&self.view(), &ops, &Params(params))
7642            })
7643            .map_err(|e| GraphError::QueryError {
7644                detail: format!("execute: {e}"),
7645            });
7646        }
7647
7648        let mut rs = write_result_set();
7649        rs.push_row(vec![
7650            Some(Value::Int(created)),
7651            Some(Value::Int(0)),
7652            Some(Value::Int(0)),
7653        ]);
7654        Ok(rs)
7655    }
7656
7657    /// Return all rule-owned edges between `key_a` and `key_b` (either direction),
7658    /// annotated with rule name, edge type, direction, and weight.
7659    /// Results are sorted by (rule, edge_type).
7660    /// Returns `Err(KeyNotFound)` if either key is unknown.
7661    pub fn explain(&self, key_a: &str, key_b: &str) -> Result<Vec<Explanation>> {
7662        self.ensure_v8_base_sections_loaded();
7663        let id_a = self
7664            .ids
7665            .get(key_a)
7666            .ok_or_else(|| GraphError::KeyNotFound { key: key_a.into() })?;
7667        let id_b = self
7668            .ids
7669            .get(key_b)
7670            .ok_or_else(|| GraphError::KeyNotFound { key: key_b.into() })?;
7671
7672        let mut results = Vec::new();
7673
7674        // Walk the smaller incident set so explain is O(min(deg(a), deg(b)))
7675        // rather than O(total provenance).
7676        let scan = if self.engine.provenance_touching_len(id_a)
7677            <= self.engine.provenance_touching_len(id_b)
7678        {
7679            id_a
7680        } else {
7681            id_b
7682        };
7683        for (rule_name, etype, src, dst) in self.engine.provenance_touching(scan) {
7684            if !((src == id_a && dst == id_b) || (src == id_b && dst == id_a)) {
7685                continue;
7686            }
7687            let Some(rule_def) = self.engine.rules().find(|r| r.name == rule_name) else {
7688                continue;
7689            };
7690            let edge_type = match self.syms.resolve(etype) {
7691                Some(s) => s.to_string(),
7692                None => continue,
7693            };
7694            // Provenance (src, dst) ids come from the archived PROVENANCE section
7695            // (large, no eager CRC).  A corrupt section can produce ids that are
7696            // out of range; return Corrupt rather than panic.
7697            let src_key = self
7698                .ids
7699                .key_of(src)
7700                .ok_or_else(|| GraphError::Corrupt {
7701                    detail: format!("v8: provenance src id {src} not in id table"),
7702                })?
7703                .to_string();
7704            let dst_key = self
7705                .ids
7706                .key_of(dst)
7707                .ok_or_else(|| GraphError::Corrupt {
7708                    detail: format!("v8: provenance dst id {dst} not in id table"),
7709                })?
7710                .to_string();
7711            let weight = rule_def.weight_prop.as_deref().and_then(|prop| {
7712                self.edge_props_view()
7713                    .get(etype, src, dst, prop)
7714                    .and_then(|v| {
7715                        if let Value::Float(f) = v {
7716                            Some(f)
7717                        } else {
7718                            None
7719                        }
7720                    })
7721            });
7722            results.push(Explanation {
7723                rule: rule_name.to_string(),
7724                edge_type,
7725                src_key,
7726                dst_key,
7727                weight,
7728                predicate: PredicateSummary {
7729                    approximate: rule_def.approximate,
7730                    ..PredicateSummary::from(&rule_def.predicate)
7731                },
7732            });
7733        }
7734
7735        results.sort_by(|a, b| a.rule.cmp(&b.rule).then(a.edge_type.cmp(&b.edge_type)));
7736        Ok(results)
7737    }
7738
7739    pub fn neighbors(&self, key: &str, edge_type: &str, dir: Direction) -> Result<Vec<String>> {
7740        let id = self
7741            .ids
7742            .get(key)
7743            .ok_or_else(|| GraphError::KeyNotFound { key: key.into() })?;
7744        let Some(sym) = self.syms.get(edge_type) else {
7745            return Ok(Vec::new());
7746        };
7747        self.topo_view()
7748            .neighbors(sym, dir, id)
7749            .iter()
7750            .map(|&n| {
7751                self.ids
7752                    .key_of(n)
7753                    .map(|k| k.to_string())
7754                    .ok_or_else(|| GraphError::Corrupt {
7755                        detail: format!("topology id {n} has no key"),
7756                    })
7757            })
7758            .collect::<Result<Vec<_>>>()
7759    }
7760
7761    /// Return the last-change commit sequence for `key`, or `None` if the node
7762    /// does not exist or has never been mutated since the last V5-V7 snapshot
7763    /// (horizon-bounded for legacy stores).
7764    ///
7765    /// The returned sequence is a monotonically increasing counter that starts
7766    /// at 1 for the first commit after `open` and increments with every
7767    /// successful write.  WAL replay at open also assigns sequences (1..N for N
7768    /// replayed frames), so sequences are consistent across snapshot+WAL cycles.
7769    ///
7770    /// For V5-V7 stores opened without a V8 snapshot, nodes that were present
7771    /// in the snapshot but not touched by any WAL frame will return `None`
7772    /// (horizon-bounded: CAS against such nodes is only safe after the first
7773    /// V8 snapshot or after the node is next mutated).
7774    pub fn last_changed(&self, key: &str) -> Option<u64> {
7775        let id = self.ids.get(key)?;
7776        self.last_change.get(&id).copied()
7777    }
7778
7779    /// The current commit sequence (number of successful commits since open,
7780    /// including WAL replay frames).  Useful for recording a baseline before
7781    /// a read-modify-write cycle.
7782    pub fn commit_seq(&self) -> u64 {
7783        self.commit_seq
7784    }
7785
7786    /// Check that all `preconds` are satisfied against the current db state.
7787    /// Returns `Err(GraphError::CasConflict)` on the first failing precondition.
7788    pub(crate) fn check_preconditions(&self, preconds: &[Precondition]) -> Result<()> {
7789        for precond in preconds {
7790            match precond {
7791                Precondition::NodeUnchangedSince { key, expected } => {
7792                    // Missing entry means the node predates the WAL window or
7793                    // does not exist; treat as 0 (before any commit).
7794                    let actual = self.last_changed(key).unwrap_or_default();
7795                    if actual != *expected {
7796                        return Err(GraphError::CasConflict {
7797                            key: key.clone(),
7798                            expected: *expected,
7799                            actual,
7800                        });
7801                    }
7802                }
7803                Precondition::NodeAbsent { key } => {
7804                    // Node must not exist (not live).
7805                    if self.ids.get(key).is_some() {
7806                        let actual = self.last_changed(key).unwrap_or(0);
7807                        return Err(GraphError::CasConflict {
7808                            key: key.clone(),
7809                            expected: u64::MAX,
7810                            actual,
7811                        });
7812                    }
7813                }
7814            }
7815        }
7816        Ok(())
7817    }
7818
7819    /// Apply a batch of mutations with compare-and-set preconditions.
7820    ///
7821    /// All preconditions are checked atomically before any operation is applied.
7822    /// If any precondition fails, the entire batch is rejected with
7823    /// [`GraphError::CasConflict`] and no WAL frame is written.
7824    ///
7825    /// # Returns
7826    /// `(nodes_inserted, edges_inserted)` on success, same as [`write_batch`].
7827    ///
7828    /// # Errors
7829    /// - [`GraphError::CasConflict`] if any precondition is not satisfied.
7830    /// - Any error that [`write_batch`] would return for the ops themselves.
7831    pub fn write_batch_cas(
7832        &mut self,
7833        preconds: Vec<Precondition>,
7834        ops: Vec<BatchOp>,
7835    ) -> Result<(usize, usize)> {
7836        self.check_preconditions(&preconds)?;
7837        self.commit_logged_batch(ops, None, None)
7838    }
7839
7840    /// Update the per-node last-change map for a WAL record at commit `seq`.
7841    ///
7842    /// Called after a successful apply to record which nodes were touched.
7843    /// For replay, called with the WAL-frame's replayed seq.
7844    ///
7845    /// Touch definition (see [`Precondition`] doc):
7846    /// - InsertNode / InsertNodeId / SetProp / SetPropId / RemoveProp → the node.
7847    /// - InsertEdge / InsertEdgeId / DeleteEdge → both src and dst.
7848    /// - DeleteNode → node tombstoned; last_changed() returns None so no update needed.
7849    /// - DerivedEdge markers, Intern, rule/view records → no-ops.
7850    /// - Batch → recurse into inner records.
7851    fn update_last_change_from_rec(&mut self, rec: &WalRecord, seq: u64) {
7852        match rec {
7853            WalRecord::InsertNode { key, .. }
7854            | WalRecord::SetProp { key, .. }
7855            | WalRecord::RemoveProp { key, .. } => {
7856                if let Some(id) = self.ids.get(key) {
7857                    self.last_change.insert(id, seq);
7858                }
7859            }
7860            WalRecord::InsertNodeId { key, .. } => {
7861                if let Some(id) = self.ids.get(key) {
7862                    self.last_change.insert(id, seq);
7863                }
7864            }
7865            WalRecord::SetPropId { id, .. } => {
7866                self.last_change.insert(*id, seq);
7867            }
7868            WalRecord::InsertEdge {
7869                src_key, dst_key, ..
7870            }
7871            | WalRecord::DeleteEdge {
7872                src_key, dst_key, ..
7873            } => {
7874                if let Some(src_id) = self.ids.get(src_key) {
7875                    self.last_change.insert(src_id, seq);
7876                }
7877                if let Some(dst_id) = self.ids.get(dst_key) {
7878                    self.last_change.insert(dst_id, seq);
7879                }
7880            }
7881            WalRecord::InsertEdgeId { src, dst, .. } => {
7882                self.last_change.insert(*src, seq);
7883                self.last_change.insert(*dst, seq);
7884            }
7885            // DeleteNode: node is tombstoned; last_changed(key) returns None for
7886            // deleted keys (ids.get() returns None post-tombstone), so no update needed.
7887            // History markers: state no-ops; the underlying mutation already
7888            // touched the relevant nodes' last_change entries.
7889            WalRecord::DeleteNode { .. }
7890            | WalRecord::DerivedEdgeAdded { .. }
7891            | WalRecord::DerivedEdgeRetracted { .. }
7892            | WalRecord::Intern { .. }
7893            | WalRecord::CreateRule { .. }
7894            | WalRecord::DeleteRule { .. }
7895            | WalRecord::RebuildRule { .. }
7896            | WalRecord::CreateView { .. }
7897            | WalRecord::DeleteView { .. }
7898            | WalRecord::EnableFulltext { .. }
7899            | WalRecord::DisableFulltext { .. }
7900            | WalRecord::EnableIndex { .. }
7901            | WalRecord::DisableIndex { .. } => {}
7902            // RenameNode: node id is stable; update last_change via the new key.
7903            // Called after apply(), so ids already reflects new_key.
7904            WalRecord::RenameNode { new_key, .. } => {
7905                if let Some(id) = self.ids.get(new_key) {
7906                    self.last_change.insert(id, seq);
7907                }
7908            }
7909            WalRecord::Batch(inner) => {
7910                for inner_rec in inner {
7911                    self.update_last_change_from_rec(inner_rec, seq);
7912                }
7913            }
7914        }
7915    }
7916
7917    pub fn node_count(&self) -> usize {
7918        self.ids.len()
7919    }
7920
7921    /// Configure archive retention: keep the `N` newest WAL archives at each
7922    /// [`snapshot_with`] call when `archive_wal: true`.
7923    ///
7924    /// `Some(N)` where N > 0 → prune oldest archives keeping the newest N.
7925    /// `Some(0)` or `None` → unlimited (no pruning).
7926    ///
7927    /// Pruning only ever happens inside [`snapshot_with`]; this method only
7928    /// stores the policy.  Archives below the retention limit are deleted
7929    /// oldest-first.  The horizon floor is updated so that
7930    /// [`was_linked`] / history APIs return `CommitOutOfRange` for commits
7931    /// in pruned archives rather than silently returning wrong data.
7932    pub fn set_wal_archive_retention(&mut self, keep: Option<u32>) {
7933        self.wal_archive_retention = keep;
7934    }
7935
7936    /// Delete any WAL archives that are fully below the current horizon floor.
7937    ///
7938    /// Orphaned archives arise when the floor is written first during retention
7939    /// pruning and then a crash interrupts the archive-delete sequence.  The
7940    /// opening cleanup ensures no subsequent read path sees stale data.
7941    ///
7942    /// Under the monotonic naming scheme, the archive name N equals the
7943    /// cumulative end-frame index of the archive in global commit space (i.e.
7944    /// the archive covers global frames `[prev_n, N)`).  An archive is
7945    /// fully orphaned when `N <= wal_horizon_floor`: all of its frames fall
7946    /// below the floor and have already been counted in it.
7947    fn cleanup_orphaned_archives(&mut self) -> Result<()> {
7948        if self.wal_horizon_floor == 0 {
7949            // Floor at 0 means no pruning has ever occurred; nothing to clean.
7950            return Ok(());
7951        }
7952        let archive_ns = self.fs.list_archives()?;
7953        for n in archive_ns {
7954            if n <= self.wal_horizon_floor {
7955                // Archive N ends at global frame N; all its frames are below
7956                // the floor (floor already accounts for them) → orphaned.
7957                self.fs.delete_archive(n).map_err(GraphError::Io)?;
7958            } else {
7959                // Archives are sorted ascending; first one above floor stops scan.
7960                break;
7961            }
7962        }
7963        Ok(())
7964    }
7965
7966    /// Collect all WAL frames from surviving archives (oldest-first) then the
7967    /// live WAL into one flat list, and return the total along with the number
7968    /// of archive frames at the front of the list.
7969    ///
7970    /// Commit indices into the returned list are LOCAL (0 = first frame of
7971    /// oldest surviving archive).  To obtain the GLOBAL index add
7972    /// `self.wal_horizon_floor`.
7973    fn all_frames(&self) -> Result<(Vec<WalRecord>, u64)> {
7974        let archive_ns = self.fs.list_archives()?;
7975        let mut all: Vec<WalRecord> = Vec::new();
7976        for n in archive_ns {
7977            let bytes = self.fs.read_archive(n)?;
7978            let (frames, _) = decode_all(&bytes);
7979            all.extend(frames);
7980        }
7981        let archive_count = all.len() as u64;
7982        let live_bytes = self.fs.read(FileId::Wal)?;
7983        let (live_frames, _) = decode_all(&live_bytes);
7984        all.extend(live_frames);
7985        Ok((all, archive_count))
7986    }
7987
7988    /// Return the total number of committed WAL frames visible in the current
7989    /// horizon window, including frames in surviving WAL archives.
7990    ///
7991    /// This is the exclusive upper bound for valid `at_commit` indices in
7992    /// `was_linked`. Valid indices are `wal_horizon_floor()..wal_total_commits()`.
7993    ///
7994    /// Returns the horizon floor when all surviving history is empty.
7995    pub fn wal_total_commits(&self) -> Result<u64> {
7996        let (frames, _) = self.all_frames()?;
7997        Ok(self.wal_horizon_floor + frames.len() as u64)
7998    }
7999
8000    /// The global frame index of the first commit reachable through surviving
8001    /// archives (0 when no archives have been pruned).
8002    pub fn wal_horizon_floor(&self) -> u64 {
8003        self.wal_horizon_floor
8004    }
8005
8006    /// Return the per-node change history for `key` by scanning the on-disk WAL.
8007    ///
8008    /// ## Horizon
8009    ///
8010    /// History reaches back only to the last WAL-truncating snapshot, exactly like `open_at`.
8011    /// Snapshots written with `keep_wal: true` preserve deeper history. This is the honest,
8012    /// zero-cost contract; a durable history log is out of scope.
8013    ///
8014    /// ## Derived edges
8015    ///
8016    /// Rule-created (derived) edges are **not** in the WAL and therefore do not appear in
8017    /// history. Only edges written directly by the application are recorded.
8018    ///
8019    /// ## Deleted nodes
8020    ///
8021    /// For nodes that have been deleted, dense-id records (SetPropId, InsertEdgeId) that
8022    /// predate the deletion may not resolve (the id is tombstoned in the live map). The
8023    /// string-keyed `DeleteNode` record still matches and produces a `NodeDeleted` entry.
8024    /// Prop/edge history of a deleted node may therefore be partially unresolvable.
8025    ///
8026    /// ## Dense-id edge entries and tombstoned partners
8027    ///
8028    /// Edge entries from dense-id WAL records (`InsertEdgeId`) are omitted when the partner
8029    /// endpoint's dense id is tombstoned. As a result, a live node's history can contain an
8030    /// `EdgeRemoved` (string-keyed, always resolves) without a corresponding `EdgeAdded`.
8031    /// Build commit-bounded alias intervals for `queried_key`.
8032    ///
8033    /// Returns a list of `(key, valid_from_inclusive, valid_until_exclusive)` tuples.
8034    /// A record written under `key` at commit `c` matches the queried identity iff
8035    /// `c >= valid_from && (valid_until.is_none() || c < valid_until)`.
8036    ///
8037    /// Each alias entry carries both a lower and an upper bound so that key-reuse
8038    /// after a rename is handled correctly: if "a" is renamed to "b" at commit 5,
8039    /// then a NEW node is created as "a" at commit 7 and renamed to "c" at commit 10,
8040    /// querying "c" must NOT surface identity-1's events (commits 0–4 under "a");
8041    /// only identity-2's events (commits 7–9 under "a") are in scope.
8042    ///
8043    /// Only **forward aliasing**: querying the *new* key surfaces events written
8044    /// under the *old* key.  The reverse direction is not supported.
8045    fn build_key_alias_intervals(
8046        &self,
8047        frames: &[core_storage::wal::WalRecord],
8048        queried_key: &str,
8049    ) -> Vec<(String, u64, Option<u64>)> {
8050        use core_storage::wal::WalRecord;
8051
8052        // Pre-pass: build reverse_rename and key_starts maps.
8053        let mut reverse_rename: HashMap<String, (String, u64)> = HashMap::new();
8054        let mut key_starts: HashMap<String, Vec<u64>> = HashMap::new();
8055
8056        for (local_i, frame) in frames.iter().enumerate() {
8057            let commit = self.wal_horizon_floor + local_i as u64;
8058            let records: &[WalRecord] = match frame {
8059                WalRecord::Batch(inner) => inner.as_slice(),
8060                single => std::slice::from_ref(single),
8061            };
8062            for rec in records {
8063                match rec {
8064                    WalRecord::InsertNode { key, .. } | WalRecord::InsertNodeId { key, .. } => {
8065                        key_starts.entry(key.clone()).or_default().push(commit);
8066                    }
8067                    WalRecord::RenameNode { old_key, new_key } => {
8068                        // new_key came into existence at this commit.
8069                        key_starts.entry(new_key.clone()).or_default().push(commit);
8070                        // Record the reverse rename: new_key was introduced by renaming old_key.
8071                        reverse_rename.insert(new_key.clone(), (old_key.clone(), commit));
8072                    }
8073                    _ => {}
8074                }
8075            }
8076        }
8077
8078        // Build alias intervals by following the reverse rename chain.
8079        let mut result: Vec<(String, u64, Option<u64>)> = Vec::new();
8080        let mut current_key = queried_key.to_string();
8081        let mut current_valid_until: Option<u64> = None;
8082
8083        loop {
8084            // valid_from: the most recent commit where current_key was assigned to this
8085            // identity.  For aliases (valid_until = Some(vu)), find the last start event
8086            // for the key strictly before vu — this is where the alias's occupancy by
8087            // this identity began, correctly excluding prior identities that reused the key.
8088            let valid_from = if let Some(vu) = current_valid_until {
8089                key_starts
8090                    .get(&current_key)
8091                    .and_then(|starts| starts.iter().rev().find(|&&s| s < vu).copied())
8092                    .unwrap_or(self.wal_horizon_floor)
8093            } else {
8094                // Queried key — no upper bound; may have been introduced at any commit.
8095                self.wal_horizon_floor
8096            };
8097
8098            result.push((current_key.clone(), valid_from, current_valid_until));
8099
8100            match reverse_rename.get(&current_key) {
8101                Some((old_key, rename_commit)) => {
8102                    current_valid_until = Some(*rename_commit);
8103                    current_key = old_key.clone();
8104                }
8105                None => break,
8106            }
8107        }
8108
8109        result
8110    }
8111
8112    /// Returns true if `record_key` matches any alias interval that covers `commit`.
8113    fn aliases_match(
8114        intervals: &[(String, u64, Option<u64>)],
8115        record_key: &str,
8116        commit: u64,
8117    ) -> bool {
8118        intervals
8119            .iter()
8120            .any(|(k, vf, vu)| k == record_key && commit >= *vf && vu.is_none_or(|u| commit < u))
8121    }
8122
8123    pub fn node_history(&self, key: &str) -> Result<Vec<crate::history::HistoryEntry>> {
8124        use crate::history::{HistoryChange, HistoryEntry};
8125        use core_storage::wal::WalRecord;
8126
8127        let (frames, _) = self.all_frames()?;
8128
8129        // Resolve commit-bounded alias intervals for `key` (handles renames in the WAL).
8130        let alias_intervals = self.build_key_alias_intervals(&frames, key);
8131
8132        let mut out: Vec<HistoryEntry> = Vec::new();
8133
8134        for (local_i, frame) in frames.iter().enumerate() {
8135            let commit = self.wal_horizon_floor + local_i as u64;
8136            // Collect the inner records to process — Batch is one commit, single records are one commit.
8137            let records: &[WalRecord] = match frame {
8138                WalRecord::Batch(inner) => inner.as_slice(),
8139                single => std::slice::from_ref(single),
8140            };
8141
8142            for rec in records {
8143                let change = match rec {
8144                    WalRecord::InsertNode { label, key: k, .. }
8145                        if Self::aliases_match(&alias_intervals, k, commit) =>
8146                    {
8147                        Some(HistoryChange::NodeInserted {
8148                            label: label.clone(),
8149                        })
8150                    }
8151                    WalRecord::InsertNodeId { label, key: k, .. }
8152                        if Self::aliases_match(&alias_intervals, k, commit) =>
8153                    {
8154                        let label_str = match self.syms.resolve(*label) {
8155                            Some(s) => s.to_string(),
8156                            None => continue,
8157                        };
8158                        Some(HistoryChange::NodeInserted { label: label_str })
8159                    }
8160                    WalRecord::SetProp {
8161                        key: k,
8162                        field,
8163                        value,
8164                    } if Self::aliases_match(&alias_intervals, k, commit) => {
8165                        Some(HistoryChange::PropSet {
8166                            field: field.clone(),
8167                            value: value.clone(),
8168                        })
8169                    }
8170                    WalRecord::SetPropId { id, field, value } => match self.ids.key_of(*id) {
8171                        // key_of returns the current (post-rename) key; compare to queried key.
8172                        Some(resolved) if resolved == key => {
8173                            let field_str = match self.syms.resolve(*field) {
8174                                Some(s) => s.to_string(),
8175                                None => continue,
8176                            };
8177                            Some(HistoryChange::PropSet {
8178                                field: field_str,
8179                                value: value.clone(),
8180                            })
8181                        }
8182                        _ => None,
8183                    },
8184                    WalRecord::RemoveProp { key: k, field }
8185                        if Self::aliases_match(&alias_intervals, k, commit) =>
8186                    {
8187                        Some(HistoryChange::PropRemoved {
8188                            field: field.clone(),
8189                        })
8190                    }
8191                    WalRecord::InsertEdge {
8192                        edge_type,
8193                        src_key,
8194                        dst_key,
8195                    } => {
8196                        if Self::aliases_match(&alias_intervals, src_key, commit) {
8197                            Some(HistoryChange::EdgeAdded {
8198                                edge_type: edge_type.clone(),
8199                                other: dst_key.clone(),
8200                                outgoing: true,
8201                            })
8202                        } else if Self::aliases_match(&alias_intervals, dst_key, commit) {
8203                            Some(HistoryChange::EdgeAdded {
8204                                edge_type: edge_type.clone(),
8205                                other: src_key.clone(),
8206                                outgoing: false,
8207                            })
8208                        } else {
8209                            None
8210                        }
8211                    }
8212                    WalRecord::InsertEdgeId { etype, src, dst } => {
8213                        let etype_str = match self.syms.resolve(*etype) {
8214                            Some(s) => s.to_string(),
8215                            None => continue,
8216                        };
8217                        let src_key = self.ids.key_of(*src);
8218                        let dst_key = self.ids.key_of(*dst);
8219                        if src_key == Some(key) {
8220                            let other = match dst_key {
8221                                Some(s) => s.to_string(),
8222                                None => continue,
8223                            };
8224                            Some(HistoryChange::EdgeAdded {
8225                                edge_type: etype_str,
8226                                other,
8227                                outgoing: true,
8228                            })
8229                        } else if dst_key == Some(key) {
8230                            let other = match src_key {
8231                                Some(s) => s.to_string(),
8232                                None => continue,
8233                            };
8234                            Some(HistoryChange::EdgeAdded {
8235                                edge_type: etype_str,
8236                                other,
8237                                outgoing: false,
8238                            })
8239                        } else {
8240                            None
8241                        }
8242                    }
8243                    WalRecord::DeleteEdge {
8244                        edge_type,
8245                        src_key,
8246                        dst_key,
8247                    } => {
8248                        if Self::aliases_match(&alias_intervals, src_key, commit) {
8249                            Some(HistoryChange::EdgeRemoved {
8250                                edge_type: edge_type.clone(),
8251                                other: dst_key.clone(),
8252                                outgoing: true,
8253                            })
8254                        } else if Self::aliases_match(&alias_intervals, dst_key, commit) {
8255                            Some(HistoryChange::EdgeRemoved {
8256                                edge_type: edge_type.clone(),
8257                                other: src_key.clone(),
8258                                outgoing: false,
8259                            })
8260                        } else {
8261                            None
8262                        }
8263                    }
8264                    WalRecord::DeleteNode { key: k }
8265                        if Self::aliases_match(&alias_intervals, k, commit) =>
8266                    {
8267                        Some(HistoryChange::NodeDeleted)
8268                    }
8269                    // Skip: rule/view/fulltext/intern metadata; Batch wrapper handled above.
8270                    _ => None,
8271                };
8272
8273                if let Some(change) = change {
8274                    out.push(HistoryEntry { commit, change });
8275                }
8276            }
8277        }
8278
8279        Ok(out)
8280    }
8281
8282    /// Return the per-edge change history between nodes `a` and `b` by scanning
8283    /// the on-disk WAL.
8284    ///
8285    /// ## Horizon
8286    ///
8287    /// History reaches back only to the last WAL-truncating snapshot, exactly
8288    /// like `node_history` and `open_at`. The returned [`HistoryResult`] carries
8289    /// `total_commits` (= number of WAL frames), which is the exclusive upper
8290    /// bound for valid commit indices.
8291    ///
8292    /// ## Derived edges
8293    ///
8294    /// Rule-derived edges appear via `DerivedEdgeAdded` / `DerivedEdgeRetracted`
8295    /// WAL markers written by `log_then_apply_with` after each rule-firing
8296    /// mutation. The `rule` field of those events carries the rule name.
8297    ///
8298    /// ## DeleteNode
8299    ///
8300    /// When a node is deleted, its manual incident edges are swept inline without
8301    /// individual `DeleteEdge` WAL records. `edge_history` detects `DeleteNode`
8302    /// events for either endpoint and synthesises `Retracted(rule:None)` events
8303    /// for each manual edge that was active at that point. Derived edges active at
8304    /// the time of deletion are handled by the `DerivedEdgeRetracted` marker that
8305    /// the engine appends immediately after the `DeleteNode` record; those events
8306    /// carry correct rule attribution and are emitted by the marker arm, not the
8307    /// synthetic sweep.
8308    ///
8309    /// ## Masks
8310    ///
8311    /// Like `node_history`, this method has no mask parameter and returns WAL
8312    /// history regardless of any role mask. For masked history semantics, apply
8313    /// the mask at the caller level.
8314    pub fn edge_history(
8315        &self,
8316        a: &str,
8317        b: &str,
8318    ) -> Result<crate::history::HistoryResult<crate::history::EdgeHistoryEvent>> {
8319        use crate::history::{EdgeEvent, EdgeHistoryEvent, HistoryResult};
8320        use core_storage::wal::WalRecord;
8321
8322        let (frames, _) = self.all_frames()?;
8323        let total_commits = self.wal_horizon_floor + frames.len() as u64;
8324
8325        // Resolve all historical names for a and b (handles RenameNode in the WAL).
8326        // Intervals are commit-bounded so recycled keys don't contaminate histories.
8327        let alias_a = self.build_key_alias_intervals(&frames, a);
8328        let alias_b = self.build_key_alias_intervals(&frames, b);
8329
8330        // Active edges between a and b tracked as (edge_type, src_key, dst_key, is_derived).
8331        // The is_derived flag is used by the DeleteNode sweep: manual edges are
8332        // swept with a synthetic Retracted(rule:None); derived edges are skipped
8333        // because the engine writes a DerivedEdgeRetracted marker immediately after
8334        // the DeleteNode record, which carries the correct rule attribution.
8335        let mut active: Vec<(String, String, String, bool)> = Vec::new();
8336        let mut out: Vec<EdgeHistoryEvent> = Vec::new();
8337
8338        for (local_i, frame) in frames.iter().enumerate() {
8339            let commit = self.wal_horizon_floor + local_i as u64;
8340            let records: &[WalRecord] = match frame {
8341                WalRecord::Batch(inner) => inner.as_slice(),
8342                single => std::slice::from_ref(single),
8343            };
8344
8345            for rec in records {
8346                match rec {
8347                    WalRecord::InsertEdge {
8348                        edge_type,
8349                        src_key,
8350                        dst_key,
8351                    } => {
8352                        let is_ab = Self::aliases_match(&alias_a, src_key, commit)
8353                            && Self::aliases_match(&alias_b, dst_key, commit);
8354                        let is_ba = Self::aliases_match(&alias_b, src_key, commit)
8355                            && Self::aliases_match(&alias_a, dst_key, commit);
8356                        if is_ab || is_ba {
8357                            active.push((
8358                                edge_type.clone(),
8359                                src_key.clone(),
8360                                dst_key.clone(),
8361                                false,
8362                            ));
8363                            out.push(EdgeHistoryEvent {
8364                                edge_type: edge_type.clone(),
8365                                commit,
8366                                event: EdgeEvent::Added,
8367                                rule: None,
8368                            });
8369                        }
8370                    }
8371                    WalRecord::InsertEdgeId { etype, src, dst } => {
8372                        let etype_str = match self.syms.resolve(*etype) {
8373                            Some(s) => s.to_string(),
8374                            None => continue,
8375                        };
8376                        // Use key_of_historical so tombstoned nodes (deleted
8377                        // later in the WAL) still resolve during the scan.
8378                        let src_key = self.ids.key_of_historical(*src);
8379                        let dst_key = self.ids.key_of_historical(*dst);
8380                        let is_ab = src_key == Some(a) && dst_key == Some(b);
8381                        let is_ba = src_key == Some(b) && dst_key == Some(a);
8382                        if is_ab || is_ba {
8383                            let src_str = src_key.unwrap().to_string();
8384                            let dst_str = dst_key.unwrap().to_string();
8385                            active.push((etype_str.clone(), src_str, dst_str, false));
8386                            out.push(EdgeHistoryEvent {
8387                                edge_type: etype_str,
8388                                commit,
8389                                event: EdgeEvent::Added,
8390                                rule: None,
8391                            });
8392                        }
8393                    }
8394                    WalRecord::DeleteEdge {
8395                        edge_type,
8396                        src_key,
8397                        dst_key,
8398                    } => {
8399                        let is_ab = Self::aliases_match(&alias_a, src_key, commit)
8400                            && Self::aliases_match(&alias_b, dst_key, commit);
8401                        let is_ba = Self::aliases_match(&alias_b, src_key, commit)
8402                            && Self::aliases_match(&alias_a, dst_key, commit);
8403                        if is_ab || is_ba {
8404                            // Remove the first matching active entry (flag ignored).
8405                            if let Some(pos) = active.iter().position(|(et, s, d, _)| {
8406                                et == edge_type && s == src_key && d == dst_key
8407                            }) {
8408                                active.remove(pos);
8409                            }
8410                            out.push(EdgeHistoryEvent {
8411                                edge_type: edge_type.clone(),
8412                                commit,
8413                                event: EdgeEvent::Retracted,
8414                                rule: None,
8415                            });
8416                        }
8417                    }
8418                    WalRecord::DeleteNode { key: k }
8419                        if Self::aliases_match(&alias_a, k, commit)
8420                            || Self::aliases_match(&alias_b, k, commit) =>
8421                    {
8422                        // Sweep: implicitly retract only MANUAL active edges.
8423                        // Derived active edges are skipped here because the rule
8424                        // engine appends a DerivedEdgeRetracted marker immediately
8425                        // after this DeleteNode record; that marker produces the
8426                        // single correctly-attributed Retracted event.  Derived
8427                        // entries are dropped from `active` (the marker arm's
8428                        // idempotent retain finds nothing to remove).
8429                        for (et, _, _, is_derived) in active.drain(..) {
8430                            if !is_derived {
8431                                out.push(EdgeHistoryEvent {
8432                                    edge_type: et,
8433                                    commit,
8434                                    event: EdgeEvent::Retracted,
8435                                    rule: None,
8436                                });
8437                            }
8438                            // Derived: drop silently; marker carries the Retracted event.
8439                        }
8440                    }
8441                    WalRecord::DerivedEdgeAdded {
8442                        rule,
8443                        edge_type: et,
8444                        src_key,
8445                        dst_key,
8446                    } => {
8447                        let is_ab = Self::aliases_match(&alias_a, src_key, commit)
8448                            && Self::aliases_match(&alias_b, dst_key, commit);
8449                        let is_ba = Self::aliases_match(&alias_b, src_key, commit)
8450                            && Self::aliases_match(&alias_a, dst_key, commit);
8451                        if is_ab || is_ba {
8452                            active.push((et.clone(), src_key.clone(), dst_key.clone(), true));
8453                            out.push(EdgeHistoryEvent {
8454                                edge_type: et.clone(),
8455                                commit,
8456                                event: EdgeEvent::Added,
8457                                rule: Some(rule.clone()),
8458                            });
8459                        }
8460                    }
8461                    WalRecord::DerivedEdgeRetracted {
8462                        rule,
8463                        edge_type: et,
8464                        src_key,
8465                        dst_key,
8466                    } => {
8467                        let is_ab = Self::aliases_match(&alias_a, src_key, commit)
8468                            && Self::aliases_match(&alias_b, dst_key, commit);
8469                        let is_ba = Self::aliases_match(&alias_b, src_key, commit)
8470                            && Self::aliases_match(&alias_a, dst_key, commit);
8471                        if is_ab || is_ba {
8472                            // Push unconditionally: a derived edge whose Added marker
8473                            // predates the history horizon has no `active` entry, but
8474                            // the retraction is still a real in-window event.
8475                            // Remove from active idempotently if present.
8476                            active.retain(|(aet, s, d, _)| {
8477                                !(aet == et && s == src_key && d == dst_key)
8478                            });
8479                            out.push(EdgeHistoryEvent {
8480                                edge_type: et.clone(),
8481                                commit,
8482                                event: EdgeEvent::Retracted,
8483                                rule: Some(rule.clone()),
8484                            });
8485                        }
8486                    }
8487                    // All other records (InsertNode, SetProp, CreateRule, etc.)
8488                    // do not affect edges between a and b.
8489                    _ => {}
8490                }
8491            }
8492        }
8493
8494        Ok(HistoryResult {
8495            items: out,
8496            total_commits,
8497        })
8498    }
8499
8500    /// Return `true` iff an edge of `edge_type` existed between `a` and `b`
8501    /// (in either direction) at the WAL commit `at_commit`.
8502    ///
8503    /// ## Horizon
8504    ///
8505    /// Valid commit indices are `0..total_commits` where `total_commits` is the
8506    /// number of WAL frames. An `at_commit >= total_commits` is outside the
8507    /// visible horizon and returns [`GraphError::CommitOutOfRange`].
8508    ///
8509    /// ## Derived edges
8510    ///
8511    /// Rule-derived edges are tracked via `DerivedEdgeAdded` / `DerivedEdgeRetracted`
8512    /// WAL markers appended at firing time (Task 1). `was_linked` reads these markers
8513    /// and therefore includes derived edges in its point-in-time evaluation,
8514    /// matching `edge_history`'s fidelity.
8515    pub fn was_linked(&self, a: &str, b: &str, edge_type: &str, at_commit: u64) -> Result<bool> {
8516        use core_storage::wal::WalRecord;
8517
8518        let (frames, _) = self.all_frames()?;
8519        let total_commits = self.wal_horizon_floor + frames.len() as u64;
8520
8521        // Horizon floor: commits in pruned archives are unreachable.
8522        if at_commit < self.wal_horizon_floor {
8523            return Err(GraphError::CommitOutOfRange {
8524                commit: at_commit,
8525                total: total_commits,
8526            });
8527        }
8528        if at_commit >= total_commits {
8529            return Err(GraphError::CommitOutOfRange {
8530                commit: at_commit,
8531                total: total_commits,
8532            });
8533        }
8534
8535        // Resolve all historical names for a and b (handles RenameNode in the WAL).
8536        // Intervals are commit-bounded so recycled keys don't contaminate point-in-time reads.
8537        let alias_a = self.build_key_alias_intervals(&frames, a);
8538        let alias_b = self.build_key_alias_intervals(&frames, b);
8539
8540        // Local index into surviving frames (0 = first frame of oldest archive).
8541        let local_commit = at_commit - self.wal_horizon_floor;
8542
8543        // Replay local frames 0..=local_commit, tracking active edges.
8544        let mut active: BTreeSet<(String, String, String)> = BTreeSet::new();
8545
8546        for (local_i, frame) in frames.iter().enumerate().take((local_commit + 1) as usize) {
8547            let commit = self.wal_horizon_floor + local_i as u64;
8548            let records: &[WalRecord] = match frame {
8549                WalRecord::Batch(inner) => inner.as_slice(),
8550                single => std::slice::from_ref(single),
8551            };
8552
8553            for rec in records {
8554                match rec {
8555                    WalRecord::InsertEdge {
8556                        edge_type: et,
8557                        src_key,
8558                        dst_key,
8559                    } => {
8560                        let is_ab = Self::aliases_match(&alias_a, src_key, commit)
8561                            && Self::aliases_match(&alias_b, dst_key, commit);
8562                        let is_ba = Self::aliases_match(&alias_b, src_key, commit)
8563                            && Self::aliases_match(&alias_a, dst_key, commit);
8564                        if is_ab || is_ba {
8565                            active.insert((et.clone(), src_key.clone(), dst_key.clone()));
8566                        }
8567                    }
8568                    WalRecord::InsertEdgeId { etype, src, dst } => {
8569                        let etype_str = match self.syms.resolve(*etype) {
8570                            Some(s) => s.to_string(),
8571                            None => continue,
8572                        };
8573                        // Use key_of_historical so tombstoned nodes resolve.
8574                        let src_key = self.ids.key_of_historical(*src);
8575                        let dst_key = self.ids.key_of_historical(*dst);
8576                        let is_ab = src_key == Some(a) && dst_key == Some(b);
8577                        let is_ba = src_key == Some(b) && dst_key == Some(a);
8578                        if is_ab || is_ba {
8579                            active.insert((
8580                                etype_str,
8581                                src_key.unwrap().to_string(),
8582                                dst_key.unwrap().to_string(),
8583                            ));
8584                        }
8585                    }
8586                    WalRecord::DeleteEdge {
8587                        edge_type: et,
8588                        src_key,
8589                        dst_key,
8590                    } => {
8591                        let is_ab = Self::aliases_match(&alias_a, src_key, commit)
8592                            && Self::aliases_match(&alias_b, dst_key, commit);
8593                        let is_ba = Self::aliases_match(&alias_b, src_key, commit)
8594                            && Self::aliases_match(&alias_a, dst_key, commit);
8595                        if is_ab || is_ba {
8596                            active.remove(&(et.clone(), src_key.clone(), dst_key.clone()));
8597                        }
8598                    }
8599                    WalRecord::DeleteNode { key: k }
8600                        if Self::aliases_match(&alias_a, k, commit)
8601                            || Self::aliases_match(&alias_b, k, commit) =>
8602                    {
8603                        // All edges touching the deleted node are gone.
8604                        active.retain(|(_, s, d)| s != k && d != k);
8605                    }
8606                    WalRecord::DerivedEdgeAdded {
8607                        edge_type: et,
8608                        src_key,
8609                        dst_key,
8610                        ..
8611                    } => {
8612                        let is_ab = Self::aliases_match(&alias_a, src_key, commit)
8613                            && Self::aliases_match(&alias_b, dst_key, commit);
8614                        let is_ba = Self::aliases_match(&alias_b, src_key, commit)
8615                            && Self::aliases_match(&alias_a, dst_key, commit);
8616                        if is_ab || is_ba {
8617                            active.insert((et.clone(), src_key.clone(), dst_key.clone()));
8618                        }
8619                    }
8620                    WalRecord::DerivedEdgeRetracted {
8621                        edge_type: et,
8622                        src_key,
8623                        dst_key,
8624                        ..
8625                    } => {
8626                        let is_ab = Self::aliases_match(&alias_a, src_key, commit)
8627                            && Self::aliases_match(&alias_b, dst_key, commit);
8628                        let is_ba = Self::aliases_match(&alias_b, src_key, commit)
8629                            && Self::aliases_match(&alias_a, dst_key, commit);
8630                        if is_ab || is_ba {
8631                            active.remove(&(et.clone(), src_key.clone(), dst_key.clone()));
8632                        }
8633                    }
8634                    _ => {}
8635                }
8636            }
8637        }
8638
8639        Ok(active.iter().any(|(et, _, _)| et == edge_type))
8640    }
8641
8642    pub fn edge_count(&self) -> u64 {
8643        self.topo_view().edge_count()
8644    }
8645
8646    /// Live/tombstone/edge counts plus per-rule provenance size, trip latch,
8647    /// and fire counter (includes rebuild evaluations). Rules are sorted by name.
8648    pub fn stats(&self) -> Stats {
8649        self.ensure_v8_base_sections_loaded();
8650        let rules: Vec<RuleStats> = self
8651            .engine
8652            .rules()
8653            .map(|r| RuleStats {
8654                name: r.name.clone(),
8655                edges: self
8656                    .engine
8657                    .provenance()
8658                    .get(&r.name)
8659                    .map(|s| s.len() as u64)
8660                    .unwrap_or(0),
8661                tripped: self.engine.is_tripped(&r.name),
8662                fires: self.engine.fire_count(&r.name),
8663                approximate: r.approximate,
8664            })
8665            .collect();
8666        Stats {
8667            nodes_live: self.ids.live_len(),
8668            nodes_tombstoned: self.ids.len() - self.ids.live_len(),
8669            edges: self.topo_view().edge_count(),
8670            rules,
8671        }
8672    }
8673
8674    /// On-disk size of the WAL file in bytes.
8675    ///
8676    /// Reads file metadata without loading WAL contents.  Returns `Err` for
8677    /// in-memory (`SimFs`) databases where no WAL file exists on disk.
8678    pub fn wal_size_bytes(&self) -> std::io::Result<u64> {
8679        let path = self.fs.wal_path().ok_or_else(|| {
8680            std::io::Error::new(
8681                std::io::ErrorKind::Unsupported,
8682                "wal_path not available for this Fs implementation",
8683            )
8684        })?;
8685        Ok(std::fs::metadata(path)?.len())
8686    }
8687
8688    /// Set the slow-query threshold.  Queries whose execution time equals or
8689    /// exceeds `ms` milliseconds are logged.  Pass `0` to disable.
8690    ///
8691    /// Use this setter in tests — the environment variable
8692    /// `MUSHROOMDB_SLOW_QUERY_MS` is process-global and races parallel test
8693    /// threads.
8694    pub fn set_slow_query_threshold_ms(&mut self, ms: u64) {
8695        self.slow_query_threshold_ms = ms;
8696    }
8697
8698    /// Snapshot of the slow-query ring buffer and lifetime counter.
8699    pub fn slow_query_snapshot(&self) -> SlowQuerySnapshot {
8700        let log = self.slow_queries.lock().unwrap_or_else(|e| e.into_inner());
8701        SlowQuerySnapshot {
8702            threshold_ms: self.slow_query_threshold_ms,
8703            count: log.total,
8704            last: log.entries.iter().cloned().collect(),
8705        }
8706    }
8707
8708    /// Instant the database was opened.  Used by consumers (e.g. `/metrics`)
8709    /// to compute uptime.
8710    pub fn started_at(&self) -> std::time::Instant {
8711        self.started_at
8712    }
8713
8714    /// On-disk snapshot format version this binary writes and reads.
8715    pub fn format_version() -> u16 {
8716        core_storage::snapshot::VERSION
8717    }
8718
8719    /// Test-support: total bytes appended (SimFs only usage).
8720    pub fn fs_total_appended(&self) -> usize
8721    where
8722        F: FsIntrospect,
8723    {
8724        self.fs.total_appended()
8725    }
8726
8727    /// Test-support: successful `Fs::sync` calls (SimFs / counting fs).
8728    pub fn fs_sync_count(&self) -> usize
8729    where
8730        F: FsIntrospect,
8731    {
8732        self.fs.sync_count()
8733    }
8734
8735    /// Consume the db, returning its fs (for crash simulation).
8736    pub fn into_fs(self) -> F {
8737        self.fs
8738    }
8739
8740    pub fn snapshot(&mut self) -> Result<()> {
8741        self.snapshot_with(SnapshotOptions::default())
8742    }
8743
8744    /// Snapshot with explicit options.
8745    ///
8746    /// # `keep_wal`
8747    ///
8748    /// When `keep_wal` is `false` (the default, same as [`snapshot`]):
8749    ///   - The WAL is replaced with a minimal baseline containing one
8750    ///     `EnableFulltext` record per active declaration.  All pre-snapshot
8751    ///     history is discarded; `open_at` can only reach post-snapshot commits.
8752    ///
8753    /// When `keep_wal` is `true`:
8754    ///   - The WAL is left intact.  All pre-snapshot commits remain reachable
8755    ///     via `open_at`.  The existing WAL already contains the original
8756    ///     `EnableFulltext` records, so no baseline re-write is needed; the
8757    ///     recovery guards in `apply()` silently skip any duplicate records on
8758    ///     replay.
8759    ///   - Crash window: a crash after the snapshot write but before the next
8760    ///     WAL write leaves the full pre-snapshot WAL intact.  On reopen the
8761    ///     snapshot is loaded and the WAL replayed idempotently over it — safe
8762    ///     because every `apply()` arm is idempotent when replayed over an
8763    ///     already-current snapshot.
8764    pub fn snapshot_with(&mut self, opts: SnapshotOptions) -> Result<()> {
8765        if self.read_only {
8766            return Err(GraphError::ReadOnly);
8767        }
8768        // Capture whether snapshot.bin already existed BEFORE this snapshot write.
8769        // Used by the archive path's conservative genesis-chain check: if a prior
8770        // snapshot exists but wal.truncated does not, we cannot distinguish a
8771        // legacy store (may have been truncated in an older code version) from a
8772        // new store that only used keep_wal=true.  Conservative: refuse genesis in
8773        // both cases.  Must be sampled here, before the snapshot write below.
8774        let had_prior_snapshot = self.fs.snapshot_path().map(|p| p.exists()).unwrap_or(false);
8775        self.ensure_v8_base_sections_loaded();
8776        // Ensure provenance is decoded before to_persist() clones it.
8777        self.engine.ensure_provenance_loaded_mut();
8778        let (rule_defs_typed, provenance, rule_tripped, rule_fires) = self.engine.to_persist();
8779        let rule_defs = rule_defs_typed
8780            .iter()
8781            .map(|r| bincode::serialize(r).expect("RuleDef serialize cannot fail"))
8782            .collect();
8783        // Collect HNSW state and IVF state.  When indexes are not yet
8784        // populated (clean open, no mutation since open), pass the retained
8785        // raw bytes through directly so that migrate/snapshot does not
8786        // silently discard fitted approximate-rule indexes.
8787        let hnsw_state = self.engine.export_hnsw_state_passthrough();
8788        let ivf_bytes = if !self.engine.indexes_populated() {
8789            // Pass retained IVF bytes through unchanged (no re-encode).
8790            self.engine.retained_ivf_bytes_clone().unwrap_or_default()
8791        } else {
8792            // Indexes live: encode from current state.
8793            let raw_ivf = self.engine.export_ivf_state();
8794            let ivf_state_map: BTreeMap<String, core_storage::snapshot::PerRuleIvfState> = raw_ivf
8795                .into_iter()
8796                .map(|(name, ((sc, sa, sd), (dc, da, dd)))| {
8797                    (
8798                        name,
8799                        core_storage::snapshot::PerRuleIvfState {
8800                            src: core_storage::snapshot::SideIvfState {
8801                                centroids: sc,
8802                                clusters: sa,
8803                                drift: sd,
8804                            },
8805                            dst: core_storage::snapshot::SideIvfState {
8806                                centroids: dc,
8807                                clusters: da,
8808                                drift: dd,
8809                            },
8810                        },
8811                    )
8812                })
8813                .collect();
8814            if ivf_state_map.is_empty() {
8815                Vec::new()
8816            } else {
8817                bincode::serialize(&ivf_state_map).expect("IVF state serialize cannot fail")
8818            }
8819        };
8820        let view_defs: Vec<Vec<u8>> = self
8821            .view_store
8822            .views()
8823            .map(|v| bincode::serialize(v).expect("ViewDef serialize cannot fail"))
8824            .collect();
8825        if self.base.is_some() {
8826            // V8 merge-snapshot path: encode base+overlay into a new V8 snapshot,
8827            // write it atomically, remap it as the new base, then clear the overlay.
8828            let meta = V8Meta {
8829                labels: self.labels.clone(),
8830                edge_props: self.edge_props.clone(),
8831                rule_defs,
8832                provenance,
8833                rule_tripped,
8834                rule_fires,
8835                ivf_bytes,
8836                view_defs,
8837                wal_truncated: !opts.keep_wal,
8838                hnsw: hnsw_state,
8839                last_change: self.last_change.clone(),
8840            };
8841            let mut buf: Vec<u8> = Vec::new();
8842            {
8843                // Clone the Arc so the old base stays alive while we encode.
8844                // The borrow of archived_csr (into old_base's mmap) is released
8845                // at the end of this block, before we replace self.base.
8846                let old_base = self.base.clone().expect("is_some checked above");
8847                let archived_csr = old_base.topology().map_err(|e| GraphError::Corrupt {
8848                    detail: format!("v8 snapshot: topology section: {e:?}"),
8849                })?;
8850                let archived_cols = old_base.columns().map_err(|e| GraphError::Corrupt {
8851                    detail: format!("v8 snapshot: columns section: {e:?}"),
8852                })?;
8853                let archived_edge_props =
8854                    old_base
8855                        .edge_props_section()
8856                        .map_err(|e| GraphError::Corrupt {
8857                            detail: format!("v8 snapshot: edge_props section: {e:?}"),
8858                        })?;
8859                let edge_props_raw =
8860                    old_base
8861                        .edge_props_raw_bytes()
8862                        .map_err(|e| GraphError::Corrupt {
8863                            detail: format!("v8 snapshot: edge_props raw bytes: {e:?}"),
8864                        })?;
8865                let prov_raw =
8866                    old_base
8867                        .provenance_raw_bytes()
8868                        .map_err(|e| GraphError::Corrupt {
8869                            detail: format!("v8 snapshot: provenance raw bytes: {e:?}"),
8870                        })?;
8871                encode_v8(
8872                    Some(archived_csr),
8873                    Some(archived_cols),
8874                    Some((archived_edge_props, edge_props_raw)),
8875                    Some(prov_raw),
8876                    &self.topo,
8877                    &self.props,
8878                    &self.ids,
8879                    &self.syms,
8880                    &meta,
8881                    &mut buf,
8882                )?;
8883            }
8884            self.fs.write_atomic(FileId::Snapshot, &buf)?;
8885            // Remap the freshly-written snapshot as the new base.
8886            // C2: use file mmap on RealFs; fall back to from_bytes on SimFs.
8887            let new_base = if let Some(snap_path) = self.fs.snapshot_path() {
8888                core_storage::v8::MappedBase::map(&snap_path)
8889            } else {
8890                core_storage::v8::MappedBase::from_bytes(buf)
8891            }
8892            .map_err(|e| GraphError::Corrupt {
8893                detail: format!("v8 snapshot: remap new base: {e:?}"),
8894            })?;
8895            self.base = Some(Arc::new(new_base));
8896            // Clear the overlay and prop tombstones — all data is now in the new base.
8897            self.topo = Topology::new();
8898            self.props = core_storage::columns::ColumnStore::new();
8899        } else {
8900            // Legacy path (V5–V7 stores without a V8 base).
8901            //
8902            // Memory-diet path: build V8Meta directly from &self — no SnapshotState
8903            // clone and no encode_v8_from_state intermediate clones.  The big
8904            // structures (self.topo, self.props) are borrowed, not cloned.
8905            // self.edge_props is moved (not cloned) because we immediately clear it
8906            // when we remap the new V8 snapshot as self.base (see below).
8907            //
8908            // Eliminates from peak RSS vs. the old SnapshotState path:
8909            //   • self.topo.clone()      (~topology HashMap footprint)
8910            //   • self.props.clone()     (~column-store footprint)
8911            //   • encode_v8_from_state V8Meta secondary clones (labels, edge_props, …)
8912            let meta = V8Meta {
8913                labels: self.labels.clone(),
8914                wal_truncated: !opts.keep_wal,
8915                // Move edge_props out so the large overlay is freed when meta
8916                // drops at end of this block (self.edge_props is now empty; reads
8917                // after base assignment go through the mmap'd base section).
8918                edge_props: std::mem::take(&mut self.edge_props),
8919                rule_defs,
8920                provenance,
8921                rule_tripped,
8922                rule_fires,
8923                ivf_bytes,
8924                view_defs,
8925                hnsw: hnsw_state,
8926                last_change: self.last_change.clone(),
8927            };
8928            let mut buf = Vec::new();
8929            encode_v8(
8930                None,
8931                None,
8932                None,
8933                None,
8934                &self.topo,
8935                &self.props,
8936                &self.ids,
8937                &self.syms,
8938                &meta,
8939                &mut buf,
8940            )?;
8941            // meta (and the moved edge_props inside it) is no longer needed;
8942            // drop it before the write to keep the peak window narrow.
8943            drop(meta);
8944            self.fs.write_atomic(FileId::Snapshot, &buf)?;
8945            // Remap the freshly-written V8 snapshot as self.base.
8946            // On RealFs: drop the encode buffer before mmap to recover ~1.9 GiB.
8947            // On SimFs (tests): pass buf to from_bytes.
8948            let new_base = if let Some(snap_path) = self.fs.snapshot_path() {
8949                drop(buf);
8950                core_storage::v8::MappedBase::map(&snap_path)
8951            } else {
8952                core_storage::v8::MappedBase::from_bytes(buf)
8953            }
8954            .map_err(|e| GraphError::Corrupt {
8955                detail: format!("v8 snapshot: remap new base (legacy path): {e:?}"),
8956            })?;
8957            self.base = Some(Arc::new(new_base));
8958            // Free the large heap-allocated decoded state — all data is now in the
8959            // mmap'd base.  Mirrors the V8 merge-snapshot path (see above).
8960            // self.edge_props was already moved into meta and is effectively empty.
8961            self.topo = Topology::new();
8962            self.props = core_storage::columns::ColumnStore::new();
8963        }
8964
8965        if opts.archive_wal {
8966            // History-preserving snapshot (Task 4):
8967            //   1. Snapshot already written above (write_atomic → fsynced).
8968            //   2. Rename WAL → wal.<commit_seq>.archive  (atomic, same fs).
8969            //      Crash window B: crash here leaves archive present, WAL
8970            //      absent.  Reopen: snapshot loaded (full state), no WAL
8971            //      replay.  Archive is NOT replayed into live state — it is
8972            //      pre-snapshot by construction.  Safe.
8973            //   3. Optionally write genesis marker (first archive only, no
8974            //      prior WAL truncation).
8975            //   4. Prune old archives (retention), update horizon floor.
8976            //      Pruning invalidates the genesis chain; delete marker.
8977            //   5. Write new minimal baseline WAL (write_atomic).
8978            //      Crash window C: crash here leaves new archive plus no live
8979            //      WAL.  Same as window B — handled above.
8980            //
8981            // Sample existing archives BEFORE the rename so we can detect
8982            // whether this is the first archive.
8983            let existing_archives = self.fs.list_archives()?;
8984            let is_first_archive = existing_archives.is_empty();
8985
8986            // Compute a globally-monotonic archive name: the name equals the
8987            // cumulative end-frame index of the archive in global commit space.
8988            //
8989            // Using `commit_seq` directly is UNSOUND across sessions: on reopen
8990            // commit_seq is seeded from max(last_change), which underestimates
8991            // the WAL depth when trailing commits (e.g. insert_edge) do not
8992            // update last_change.  A session-2 archive could then receive a name
8993            // ≤ the session-1 archive, causing incorrect sort order or collision.
8994            //
8995            // Instead: read and decode the live WAL here (before the rename) to
8996            // get its exact frame count, then add it to the last known global
8997            // end-frame index (the name of the most recent existing archive, or
8998            // wal_horizon_floor if no archives exist).  This is O(WAL size) but
8999            // snapshot is already serialising the full graph state, so the cost
9000            // is dominated.
9001            let live_wal_bytes_for_name = self.fs.read(FileId::Wal)?;
9002            let (live_frames_for_name, _) = decode_all(&live_wal_bytes_for_name);
9003            let archive_n = existing_archives
9004                .last()
9005                .copied()
9006                .unwrap_or(self.wal_horizon_floor)
9007                + live_frames_for_name.len() as u64;
9008            self.fs.archive_wal(archive_n)?;
9009
9010            // Genesis marker: written once when the first archive is taken
9011            // from a store that has never undergone a WAL-truncating snapshot.
9012            // When present, `open_at` may replay archive-resident commits from
9013            // empty state (the archive chain covers from global index 0).
9014            //
9015            // Two conditions must ALL hold:
9016            //   1. This is the first archive (existing_archives was empty).
9017            //   2. No snapshot.bin existed before this operation (had_prior_snapshot=false).
9018            //      A WAL-truncating snapshot (keep_wal=false) always writes snapshot.bin
9019            //      before truncating the WAL, so if any prior truncating snapshot was taken
9020            //      — even in a previous session — snapshot.bin is present and this condition
9021            //      is false.  This subsumes the cross-session truncation case without
9022            //      requiring a separate wal.truncated sidecar file.
9023            //      For legacy stores (snapshot.bin written by an older code version that
9024            //      may have truncated the WAL), the same conservative refusal applies:
9025            //      we cannot prove the chain is complete, so we refuse genesis (cost =
9026            //      no as-of-through-archives; never silent wrong data).
9027            //      On SimFs (snapshot_path() == None) had_prior_snapshot is always false,
9028            //      so SimFs always passes this check.
9029            if is_first_archive && !had_prior_snapshot {
9030                self.fs.write_genesis_marker()?;
9031                self.archive_genesis_chain = true;
9032            }
9033
9034            // Retention pruning: keep newest `keep` archives; delete oldest.
9035            // Pruning is the ONLY deletion site for archives.
9036            //
9037            // Crash-safety ordering (C1 fix):
9038            //   1. Count frames in surplus archives (reads only — no mutation).
9039            //   2. Advance and PERSIST the horizon floor FIRST via write-then-
9040            //      rename (atomic).  A crash after this point leaves orphaned
9041            //      archives on disk, but the floor is correct.  The opening
9042            //      cleanup sweep (`cleanup_orphaned_archives`) removes them on
9043            //      the next open, so the store is always safe to reopen.
9044            //   3. Delete the genesis marker (floor > 0 already blocks open_at
9045            //      via the conjunctive gate; marker cleanup is belt-and-suspenders).
9046            //   4. Delete surplus archives.  A crash between any two deletes
9047            //      leaves the floor committed and orphaned archives cleaned at
9048            //      next open — never a stale floor with a missing archive prefix.
9049            if let Some(keep) = self.wal_archive_retention {
9050                if keep > 0 {
9051                    let archives = self.fs.list_archives()?;
9052                    // archives is sorted ascending (oldest first)
9053                    if archives.len() as u32 > keep {
9054                        let surplus = archives.len() - keep as usize;
9055                        // Step 1: count pruned frames (reads, no mutation).
9056                        let mut pruned_frames = 0u64;
9057                        for &n in &archives[..surplus] {
9058                            let bytes = self.fs.read_archive(n)?;
9059                            let (frames, _) = decode_all(&bytes);
9060                            pruned_frames += frames.len() as u64;
9061                        }
9062                        // Step 2: advance and persist floor FIRST.
9063                        self.wal_horizon_floor += pruned_frames;
9064                        self.fs.write_horizon_floor(self.wal_horizon_floor)?;
9065                        // Step 3: delete genesis marker (floor > 0 already
9066                        // blocks open_at; this is belt-and-suspenders cleanup).
9067                        if pruned_frames > 0 && self.archive_genesis_chain {
9068                            self.fs.delete_genesis_marker()?;
9069                            self.archive_genesis_chain = false;
9070                        }
9071                        // Step 4: delete surplus archives.  Crash here →
9072                        // orphaned archives; cleaned at next open.
9073                        for &n in &archives[..surplus] {
9074                            self.fs.delete_archive(n)?;
9075                        }
9076                    }
9077                }
9078            }
9079
9080            // Write new minimal baseline WAL (mirrors the keep_wal=false path).
9081            let mut baseline_wal: Vec<u8> = Vec::new();
9082            for (label, field) in self.fulltext.enabled_pairs() {
9083                let rec = WalRecord::EnableFulltext {
9084                    label: label.clone(),
9085                    field: field.clone(),
9086                };
9087                baseline_wal.extend_from_slice(&encode_record(&rec));
9088            }
9089            for (label, field) in self.prop_index.enabled_pairs() {
9090                let rec = WalRecord::EnableIndex {
9091                    label: label.clone(),
9092                    field: field.clone(),
9093                };
9094                baseline_wal.extend_from_slice(&encode_record(&rec));
9095            }
9096            self.fs.write_atomic(FileId::Wal, &baseline_wal)?;
9097        } else if opts.keep_wal {
9098            // keep_wal=true: WAL is left untouched.  The existing WAL already
9099            // contains the EnableFulltext records from the original enable calls;
9100            // replay is idempotent (guards in apply() skip already-live entries).
9101            // No baseline re-write is needed or safe here — the full WAL history
9102            // must remain intact for open_at to reach pre-snapshot commits.
9103        } else {
9104            // keep_wal=false (default): truncate by replacing the WAL with a
9105            // minimal baseline of one EnableFulltext record per active pair.
9106            //
9107            // Crash-ordering: write_atomic is atomic.
9108            //   • Crash before snapshot write  → WAL unchanged.  Safe.
9109            //   • Crash after snapshot write but before this WAL write → full
9110            //     pre-snapshot WAL still present; open_with replays idempotently.
9111            //   • Crash after both writes → normal post-snapshot state.
9112            //
9113            // Genesis chain: a WAL-truncating snapshot breaks the archive chain
9114            // for any archives taken AFTER this point (their WAL slices would
9115            // not start at genesis).  Delete any existing genesis marker so that
9116            // open_at refuses archive-resident commits.  Future sessions are
9117            // covered by had_prior_snapshot: snapshot.bin written here persists
9118            // across sessions and prevents a later archiving session from
9119            // incorrectly claiming a complete genesis chain.
9120            if self.archive_genesis_chain {
9121                self.fs.delete_genesis_marker()?;
9122                self.archive_genesis_chain = false;
9123            }
9124            let mut baseline_wal: Vec<u8> = Vec::new();
9125            for (label, field) in self.fulltext.enabled_pairs() {
9126                let rec = WalRecord::EnableFulltext {
9127                    label: label.clone(),
9128                    field: field.clone(),
9129                };
9130                baseline_wal.extend_from_slice(&encode_record(&rec));
9131            }
9132            for (label, field) in self.prop_index.enabled_pairs() {
9133                let rec = WalRecord::EnableIndex {
9134                    label: label.clone(),
9135                    field: field.clone(),
9136                };
9137                baseline_wal.extend_from_slice(&encode_record(&rec));
9138            }
9139            self.fs.write_atomic(FileId::Wal, &baseline_wal)?;
9140        }
9141        // After snapshot the overlay may have changed (V8 merge path clears
9142        // self.topo and self.props). Refresh the MVCC fold so future readers
9143        // see the post-snapshot state rather than stale overlay data.
9144        self.fold_now();
9145        Ok(())
9146    }
9147}
9148
9149/// Queued mutation for a [`BatchBuilder`] or [`GraphDb::commit_group`].
9150///
9151/// The `submit_batch` / `commit_group` APIs accept `Vec<BatchOp>` so that
9152/// callers can build a set of mutations without holding `&mut GraphDb` and
9153/// hand them off to the group-committing writer for durable, batched I/O.
9154pub enum BatchOp {
9155    InsertNode {
9156        label: String,
9157        key: String,
9158        props: Vec<(String, Value)>,
9159    },
9160    InsertEdge {
9161        edge_type: String,
9162        src_key: String,
9163        dst_key: String,
9164    },
9165    SetProp {
9166        key: String,
9167        field: String,
9168        value: Value,
9169    },
9170    RemoveProp {
9171        key: String,
9172        field: String,
9173    },
9174    DeleteEdge {
9175        edge_type: String,
9176        src_key: String,
9177        dst_key: String,
9178    },
9179    DeleteNode {
9180        key: String,
9181    },
9182    CreateRule(RuleDef),
9183    DeleteRule {
9184        name: String,
9185    },
9186    /// Rename a node's key. Validated: old must exist, new must not.
9187    RenameNode {
9188        old_key: String,
9189        new_key: String,
9190    },
9191    /// Insert an edge, auto-creating any missing endpoint as a plain node with
9192    /// `placeholder_label` and no props. Rules fire and last-change is updated
9193    /// for each created endpoint (normal InsertNode semantics in the batch frame).
9194    InsertEdgeUpsert {
9195        edge_type: String,
9196        src_key: String,
9197        dst_key: String,
9198        placeholder_label: String,
9199    },
9200}
9201
9202/// Three-way node visibility status used by `check_single_op_authz`.
9203enum NodeAuthzStatus {
9204    /// Node exists in the store and is in the role's read mask.
9205    Visible(String), // carries the node's label
9206    /// Node exists in the store but is NOT in the role's read mask.
9207    Hidden,
9208    /// Node does not exist in the store.
9209    Absent,
9210}
9211
9212/// Overlay of ops already accepted earlier in the same batch. Never written
9213/// back to the database — validation only.
9214#[derive(Default)]
9215struct Overlay {
9216    extra_keys: BTreeSet<String>,
9217    deleted_keys: BTreeSet<String>,
9218    extra_props: BTreeMap<(String, String), Value>,
9219    removed_props: BTreeSet<(String, String)>,
9220    extra_edges: BTreeSet<(String, String, String)>,
9221    deleted_edges: BTreeSet<(String, String, String)>,
9222    extra_rules: BTreeSet<String>,
9223    deleted_rules: BTreeSet<String>,
9224}
9225
9226/// Read-only view of live db state plus a batch overlay. Shared by single-op
9227/// public methods (empty overlay) and `commit_batch`.
9228struct MutPreview<'a, F: Fs> {
9229    db: &'a GraphDb<F>,
9230    overlay: Overlay,
9231}
9232
9233impl<'a, F: Fs> MutPreview<'a, F> {
9234    fn new(db: &'a GraphDb<F>) -> Self {
9235        Self {
9236            db,
9237            overlay: Overlay::default(),
9238        }
9239    }
9240
9241    fn has_key(&self, key: &str) -> bool {
9242        if self.overlay.extra_keys.contains(key) {
9243            return true;
9244        }
9245        if self.overlay.deleted_keys.contains(key) {
9246            return false;
9247        }
9248        self.db.ids.get(key).is_some()
9249    }
9250
9251    fn has_prop(&self, key: &str, field: &str) -> bool {
9252        if !self.has_key(key) {
9253            return false;
9254        }
9255        let k = (key.to_string(), field.to_string());
9256        if self.overlay.removed_props.contains(&k) {
9257            return false;
9258        }
9259        if self.overlay.extra_props.contains_key(&k) {
9260            return true;
9261        }
9262        // Fresh identity (first insert in this batch, or delete+reinsert):
9263        // ignore props still sitting on the soon-to-be-tombstoned slot.
9264        if self.overlay.extra_keys.contains(key) {
9265            return false;
9266        }
9267        self.db.get_prop(key, field).is_some()
9268    }
9269
9270    fn has_edge(&self, edge_type: &str, src_key: &str, dst_key: &str) -> bool {
9271        let k = (
9272            edge_type.to_string(),
9273            src_key.to_string(),
9274            dst_key.to_string(),
9275        );
9276        if self.overlay.deleted_edges.contains(&k) {
9277            return false;
9278        }
9279        if self.overlay.extra_edges.contains(&k) {
9280            return true;
9281        }
9282        // A key created in this batch (including reinsert) has no db edges.
9283        if self.overlay.extra_keys.contains(src_key) || self.overlay.extra_keys.contains(dst_key) {
9284            return false;
9285        }
9286        if self.overlay.deleted_keys.contains(src_key)
9287            || self.overlay.deleted_keys.contains(dst_key)
9288        {
9289            return false;
9290        }
9291        let Some(src) = self.db.ids.get(src_key) else {
9292            return false;
9293        };
9294        let Some(dst) = self.db.ids.get(dst_key) else {
9295            return false;
9296        };
9297        let Some(sym) = self.db.syms.get(edge_type) else {
9298            return false;
9299        };
9300        self.db
9301            .topo_view()
9302            .neighbors(sym, Direction::Out, src)
9303            .binary_search(&dst)
9304            .is_ok()
9305    }
9306
9307    fn has_rule(&self, name: &str) -> bool {
9308        if self.overlay.extra_rules.contains(name) {
9309            return true;
9310        }
9311        if self.overlay.deleted_rules.contains(name) {
9312            return false;
9313        }
9314        self.db.engine.rules().any(|r| r.name == name)
9315    }
9316
9317    fn is_rule_owned(&self, edge_type: &str, src_key: &str, dst_key: &str) -> bool {
9318        if self.overlay.extra_keys.contains(src_key) || self.overlay.extra_keys.contains(dst_key) {
9319            return false;
9320        }
9321        if self.overlay.deleted_keys.contains(src_key)
9322            || self.overlay.deleted_keys.contains(dst_key)
9323        {
9324            return false;
9325        }
9326        let Some(src) = self.db.ids.get(src_key) else {
9327            return false;
9328        };
9329        let Some(dst) = self.db.ids.get(dst_key) else {
9330            return false;
9331        };
9332        let Some(et) = self.db.syms.get(edge_type) else {
9333            return false;
9334        };
9335        // extra_rules is deliberately not consulted: a CreateRule earlier in
9336        // this batch has not fired, so it contributes no provenance. That is
9337        // the documented rule-window gap (see GraphDb::batch).
9338        if self.overlay.deleted_rules.is_empty() {
9339            return self.db.engine.is_owned(et, src, dst);
9340        }
9341        for (rule, triples) in self.db.engine.provenance() {
9342            if self.overlay.deleted_rules.contains(rule) {
9343                continue;
9344            }
9345            if triples.contains(&(et, src, dst)) {
9346                return true;
9347            }
9348        }
9349        false
9350    }
9351
9352    fn check_insert_node(&self, key: &str) -> Result<()> {
9353        if self.has_key(key) {
9354            Err(GraphError::DuplicateKey { key: key.into() })
9355        } else {
9356            Ok(())
9357        }
9358    }
9359
9360    fn check_live_key(&self, key: &str) -> Result<()> {
9361        if self.has_key(key) {
9362            Ok(())
9363        } else {
9364            Err(GraphError::KeyNotFound { key: key.into() })
9365        }
9366    }
9367
9368    fn prepare_insert_edge(&self, edge_type: &str, src_key: &str, dst_key: &str) -> Result<bool> {
9369        for k in [src_key, dst_key] {
9370            if !self.has_key(k) {
9371                return Err(GraphError::KeyNotFound { key: k.into() });
9372            }
9373        }
9374        if self.is_rule_owned(edge_type, src_key, dst_key) {
9375            return Err(GraphError::RuleOwned {
9376                detail: format!("edge {edge_type} {src_key}→{dst_key} is rule-owned"),
9377            });
9378        }
9379        Ok(!self.has_edge(edge_type, src_key, dst_key))
9380    }
9381
9382    fn prepare_remove_prop(&self, key: &str, field: &str) -> Result<bool> {
9383        self.check_live_key(key)?;
9384        Ok(self.has_prop(key, field))
9385    }
9386
9387    fn prepare_delete_edge(&self, edge_type: &str, src_key: &str, dst_key: &str) -> Result<bool> {
9388        for k in [src_key, dst_key] {
9389            if !self.has_key(k) {
9390                return Err(GraphError::KeyNotFound { key: k.into() });
9391            }
9392        }
9393        // Provenance-owned OR a live rule would derive this pair. User-first
9394        // edges that a later rule matches are not in `owned`, but deleting
9395        // them would leave a hole `rebuild_rule` immediately fills.
9396        if self.is_rule_owned(edge_type, src_key, dst_key) {
9397            return Err(GraphError::RuleOwned {
9398                detail: format!(
9399                    "edge {edge_type} {src_key}→{dst_key} is rule-owned; \
9400                     delete or change the owning rule"
9401                ),
9402            });
9403        }
9404        if self.would_derive(edge_type, src_key, dst_key) {
9405            return Err(GraphError::RuleOwned {
9406                detail: format!(
9407                    "edge {edge_type} {src_key}→{dst_key} is rule-owned; \
9408                     delete or change the owning rule, or a live rule would re-derive it"
9409                ),
9410            });
9411        }
9412        Ok(self.has_edge(edge_type, src_key, dst_key))
9413    }
9414
9415    /// True if any live rule (minus overlay-deleted names) would derive
9416    /// `(edge_type, src, dst)` from current overlay-visible props/labels.
9417    /// CreateRule names in `extra_rules` are ignored — same documented
9418    /// same-batch rule-window as [`Self::is_rule_owned`].
9419    fn would_derive(&self, edge_type: &str, src_key: &str, dst_key: &str) -> bool {
9420        if src_key == dst_key {
9421            return false;
9422        }
9423        let Some(src_label) = self.label_of(src_key) else {
9424            return false;
9425        };
9426        let Some(dst_label) = self.label_of(dst_key) else {
9427            return false;
9428        };
9429        for rule in self.db.engine.rules() {
9430            if self.overlay.deleted_rules.contains(&rule.name) {
9431                continue;
9432            }
9433            if rule.edge_type != edge_type {
9434                continue;
9435            }
9436            if rule.src_label != src_label || rule.dst_label != dst_label {
9437                continue;
9438            }
9439            let src_props = |f: &str| self.prop_value(src_key, f);
9440            let dst_props = |f: &str| self.prop_value(dst_key, f);
9441            let src_view = NodeView {
9442                key: src_key,
9443                props: &src_props,
9444            };
9445            let dst_view = NodeView {
9446                key: dst_key,
9447                props: &dst_props,
9448            };
9449            if evaluate(&rule.predicate, &src_view, &dst_view).is_some() {
9450                return true;
9451            }
9452        }
9453        false
9454    }
9455
9456    fn label_of(&self, key: &str) -> Option<String> {
9457        if self.overlay.deleted_keys.contains(key) {
9458            return None;
9459        }
9460        // Fresh identities created in this batch have no stored label in the
9461        // overlay; they cannot be provenance-owned yet either.
9462        let id = self.db.ids.get(key)?;
9463        let sym = self.db.labels.get(id as usize).copied()?;
9464        if sym == u32::MAX {
9465            return None;
9466        }
9467        self.db.syms.resolve(sym).map(str::to_string)
9468    }
9469
9470    fn prop_value(&self, key: &str, field: &str) -> Option<Value> {
9471        if !self.has_key(key) {
9472            return None;
9473        }
9474        let k = (key.to_string(), field.to_string());
9475        if self.overlay.removed_props.contains(&k) {
9476            return None;
9477        }
9478        if let Some(v) = self.overlay.extra_props.get(&k) {
9479            return Some(v.clone());
9480        }
9481        if self.overlay.extra_keys.contains(key) {
9482            return None;
9483        }
9484        self.db.get_prop(key, field)
9485    }
9486
9487    fn check_create_rule(&self, def: &RuleDef) -> Result<()> {
9488        def.validate()
9489            .map_err(|e| GraphError::RuleInvalid { detail: e })?;
9490        if self.has_rule(&def.name) {
9491            return Err(GraphError::RuleInvalid {
9492                detail: format!("rule {:?} already exists", def.name),
9493            });
9494        }
9495        Ok(())
9496    }
9497
9498    fn check_delete_rule(&self, name: &str) -> Result<()> {
9499        if self.has_rule(name) {
9500            Ok(())
9501        } else {
9502            Err(GraphError::RuleNotFound { name: name.into() })
9503        }
9504    }
9505
9506    fn note_insert_node(&mut self, key: &str, props: &[(String, Value)]) {
9507        self.overlay.deleted_keys.remove(key);
9508        self.overlay.extra_keys.insert(key.to_string());
9509        self.overlay.extra_props.retain(|(k, _), _| k != key);
9510        self.overlay.removed_props.retain(|(k, _)| k != key);
9511        for (field, value) in props {
9512            self.overlay
9513                .extra_props
9514                .insert((key.to_string(), field.clone()), value.clone());
9515        }
9516    }
9517
9518    fn note_insert_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) {
9519        let k = (
9520            edge_type.to_string(),
9521            src_key.to_string(),
9522            dst_key.to_string(),
9523        );
9524        self.overlay.deleted_edges.remove(&k);
9525        self.overlay.extra_edges.insert(k);
9526    }
9527
9528    fn note_set_prop(&mut self, key: &str, field: &str, value: &Value) {
9529        let k = (key.to_string(), field.to_string());
9530        self.overlay.removed_props.remove(&k);
9531        self.overlay.extra_props.insert(k, value.clone());
9532    }
9533
9534    fn note_remove_prop(&mut self, key: &str, field: &str) {
9535        let k = (key.to_string(), field.to_string());
9536        self.overlay.extra_props.remove(&k);
9537        self.overlay.removed_props.insert(k);
9538    }
9539
9540    fn note_delete_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) {
9541        let k = (
9542            edge_type.to_string(),
9543            src_key.to_string(),
9544            dst_key.to_string(),
9545        );
9546        self.overlay.extra_edges.remove(&k);
9547        self.overlay.deleted_edges.insert(k);
9548    }
9549
9550    fn note_delete_node(&mut self, key: &str) {
9551        self.overlay.extra_keys.remove(key);
9552        self.overlay.deleted_keys.insert(key.to_string());
9553        self.overlay.extra_props.retain(|(k, _), _| k != key);
9554        self.overlay.removed_props.retain(|(k, _)| k != key);
9555        self.overlay
9556            .extra_edges
9557            .retain(|(_, s, d)| s != key && d != key);
9558        self.overlay
9559            .deleted_edges
9560            .retain(|(_, s, d)| s != key && d != key);
9561    }
9562
9563    fn note_create_rule(&mut self, name: &str) {
9564        self.overlay.deleted_rules.remove(name);
9565        self.overlay.extra_rules.insert(name.to_string());
9566    }
9567
9568    fn check_rename_node(&self, old: &str, new: &str) -> Result<()> {
9569        if !self.has_key(old) {
9570            return Err(GraphError::KeyNotFound { key: old.into() });
9571        }
9572        if self.has_key(new) {
9573            return Err(GraphError::DuplicateKey { key: new.into() });
9574        }
9575        Ok(())
9576    }
9577
9578    fn note_rename_node(&mut self, old: &str, new: &str) {
9579        // Mark old as deleted so subsequent batch ops cannot reference it.
9580        self.overlay.extra_keys.remove(old);
9581        self.overlay.deleted_keys.insert(old.to_string());
9582        // Mark new as extra so subsequent batch ops can reference it.
9583        self.overlay.deleted_keys.remove(new);
9584        self.overlay.extra_keys.insert(new.to_string());
9585        // Migrate any overlay props from old key to new key.
9586        let new_str = new.to_string();
9587        let transferred: Vec<((String, String), Value)> = self
9588            .overlay
9589            .extra_props
9590            .iter()
9591            .filter(|((k, _), _)| k.as_str() == old)
9592            .map(|((_, f), v)| ((new_str.clone(), f.clone()), v.clone()))
9593            .collect();
9594        self.overlay
9595            .extra_props
9596            .retain(|(k, _), _| k.as_str() != old);
9597        for (k, v) in transferred {
9598            self.overlay.extra_props.insert(k, v);
9599        }
9600        // Migrate removed_props.
9601        let transferred_removed: Vec<(String, String)> = self
9602            .overlay
9603            .removed_props
9604            .iter()
9605            .filter(|(k, _)| k.as_str() == old)
9606            .map(|(_, f)| (new_str.clone(), f.clone()))
9607            .collect();
9608        self.overlay
9609            .removed_props
9610            .retain(|(k, _)| k.as_str() != old);
9611        for k in transferred_removed {
9612            self.overlay.removed_props.insert(k);
9613        }
9614    }
9615
9616    fn note_delete_rule(&mut self, name: &str) {
9617        self.overlay.extra_rules.remove(name);
9618        self.overlay.deleted_rules.insert(name.to_string());
9619        // Treat the deleted rule's current provenance as gone so a later
9620        // delete_edge of those triples is a no-op (matches sequential).
9621        if let Some(triples) = self.db.engine.provenance().get(name) {
9622            for &(et, s, d) in triples {
9623                let Some(etype) = self.db.syms.resolve(et) else {
9624                    continue;
9625                };
9626                let Some(src) = self.db.ids.key_of(s) else {
9627                    continue;
9628                };
9629                let Some(dst) = self.db.ids.key_of(d) else {
9630                    continue;
9631                };
9632                let k = (etype.to_string(), src.to_string(), dst.to_string());
9633                self.overlay.extra_edges.remove(&k);
9634                self.overlay.deleted_edges.insert(k);
9635            }
9636        }
9637    }
9638}
9639
9640/// Collects mutations and commits them as one WAL `Batch` frame.
9641///
9642/// Holds `&mut GraphDb` for its lifetime. Queue with the same method names
9643/// as [`GraphDb`]; call [`commit`](Self::commit) to validate, log, and apply.
9644/// See [`GraphDb::batch`] for validation and atomicity rules.
9645pub struct BatchBuilder<'a, F: Fs> {
9646    db: &'a mut GraphDb<F>,
9647    ops: Vec<BatchOp>,
9648}
9649
9650impl<'a, F: Fs> BatchBuilder<'a, F> {
9651    pub fn insert_node(
9652        &mut self,
9653        label: &str,
9654        key: &str,
9655        props: Vec<(String, Value)>,
9656    ) -> &mut Self {
9657        self.ops.push(BatchOp::InsertNode {
9658            label: label.into(),
9659            key: key.into(),
9660            props,
9661        });
9662        self
9663    }
9664
9665    pub fn insert_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) -> &mut Self {
9666        self.ops.push(BatchOp::InsertEdge {
9667            edge_type: edge_type.into(),
9668            src_key: src_key.into(),
9669            dst_key: dst_key.into(),
9670        });
9671        self
9672    }
9673
9674    pub fn set_prop(&mut self, key: &str, field: &str, value: Value) -> &mut Self {
9675        self.ops.push(BatchOp::SetProp {
9676            key: key.into(),
9677            field: field.into(),
9678            value,
9679        });
9680        self
9681    }
9682
9683    pub fn remove_prop(&mut self, key: &str, field: &str) -> &mut Self {
9684        self.ops.push(BatchOp::RemoveProp {
9685            key: key.into(),
9686            field: field.into(),
9687        });
9688        self
9689    }
9690
9691    pub fn delete_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) -> &mut Self {
9692        self.ops.push(BatchOp::DeleteEdge {
9693            edge_type: edge_type.into(),
9694            src_key: src_key.into(),
9695            dst_key: dst_key.into(),
9696        });
9697        self
9698    }
9699
9700    pub fn delete_node(&mut self, key: &str) -> &mut Self {
9701        self.ops.push(BatchOp::DeleteNode { key: key.into() });
9702        self
9703    }
9704
9705    pub fn create_rule(&mut self, def: RuleDef) -> &mut Self {
9706        self.ops.push(BatchOp::CreateRule(def));
9707        self
9708    }
9709
9710    pub fn delete_rule(&mut self, name: &str) -> &mut Self {
9711        self.ops.push(BatchOp::DeleteRule { name: name.into() });
9712        self
9713    }
9714
9715    /// Queue a node-rename in this batch.
9716    ///
9717    /// Validation (old exists, new not taken) runs at commit time.
9718    pub fn rename_node(&mut self, old_key: &str, new_key: &str) -> &mut Self {
9719        self.ops.push(BatchOp::RenameNode {
9720            old_key: old_key.into(),
9721            new_key: new_key.into(),
9722        });
9723        self
9724    }
9725
9726    /// Queue an edge insert with endpoint auto-creation.
9727    ///
9728    /// Any missing endpoint is created as a plain node `{key, label:
9729    /// placeholder_label, no props}` inside this batch frame. Rules fire and
9730    /// last-change is updated for each auto-created node.
9731    pub fn insert_edge_upsert(
9732        &mut self,
9733        edge_type: &str,
9734        src_key: &str,
9735        dst_key: &str,
9736        placeholder_label: &str,
9737    ) -> &mut Self {
9738        self.ops.push(BatchOp::InsertEdgeUpsert {
9739            edge_type: edge_type.into(),
9740            src_key: src_key.into(),
9741            dst_key: dst_key.into(),
9742            placeholder_label: placeholder_label.into(),
9743        });
9744        self
9745    }
9746
9747    /// Validate every queued op, then log one `Batch` frame and apply.
9748    /// Empty / all-noop batches return `Ok(())` without writing the WAL.
9749    /// A second `commit()` after a successful one is an empty-batch no-op
9750    /// (queued ops were taken).
9751    /// Takes `&mut self` so it chains after the queue methods (`b.insert_node(..).commit()`)
9752    /// and also works as `let mut b = db.batch(); b.insert_node(..); b.commit()`.
9753    ///
9754    /// **Rule-window limitation:** batch validation cannot see edges that a
9755    /// rule created earlier in the *same* batch will derive at apply time, so
9756    /// a `delete_edge` / `insert_edge` in that window is silently no-oped
9757    /// where sequential calls would return `Err(RuleOwned)`. State integrity
9758    /// is unaffected (idempotent apply, provenance intact). Create rules in
9759    /// their own batch, or sequentially, when later ops may touch derived
9760    /// edges.
9761    /// Validate every queued op and commit atomically.
9762    ///
9763    /// Returns `(nodes_inserted, edges_inserted)` — the counts of node and edge
9764    /// WAL records actually written (duplicate edges are silent no-ops and are
9765    /// NOT counted). Both are 0 when the batch is empty or all-noop.
9766    pub fn commit(&mut self) -> Result<(usize, usize)> {
9767        let ops = std::mem::take(&mut self.ops);
9768        self.db.commit_batch(ops)
9769    }
9770
9771    /// Same as [`commit`](Self::commit) but tail the inner events with
9772    /// [`MutationEvent::Ingested`] instead of [`MutationEvent::BatchApplied`].
9773    pub(crate) fn commit_ingest(&mut self, label: &str, inserted: usize) -> Result<(usize, usize)> {
9774        let ops = std::mem::take(&mut self.ops);
9775        self.db
9776            .commit_logged_batch(ops, Some((label.to_string(), inserted)), None)
9777    }
9778}
9779
9780pub struct NodeRef<'a, F: Fs> {
9781    db: &'a GraphDb<F>,
9782    id: u32,
9783}
9784
9785impl<'a, F: Fs> NodeRef<'a, F> {
9786    pub fn key(&self) -> &str {
9787        self.db.ids.key_of(self.id).expect("dense ids")
9788    }
9789
9790    pub fn label(&self) -> &str {
9791        let sym = self
9792            .db
9793            .labels
9794            .get(self.id as usize)
9795            .copied()
9796            .filter(|&s| s != u32::MAX)
9797            .expect("real nodes always have a label; u32::MAX sentinel cannot occur");
9798        self.db.syms.resolve(sym).expect("interned label symbol")
9799    }
9800
9801    pub fn prop(&self, field: &str) -> Option<Value> {
9802        self.db
9803            .props_view()
9804            .get(self.id, field)
9805            .map(|vr| vr.into_value())
9806    }
9807
9808    /// All stored fields for this node, sorted by field name.
9809    ///
9810    /// Reads from the full base+overlay view so that props stored only in the
9811    /// V8 snapshot base (i.e. before any post-snapshot WAL writes) are visible.
9812    pub fn props(&self) -> BTreeMap<String, Value> {
9813        let mut out = BTreeMap::new();
9814        let pv = self.db.props_view();
9815        for field in pv.field_names() {
9816            if let Some(vr) = pv.get(self.id, &field) {
9817                out.insert(field, vr.into_value());
9818            }
9819        }
9820        out
9821    }
9822
9823    /// depth-N BFS as a ResultSet: columns ["key","label","depth"], BFS order.
9824    pub fn neighborhood(&self, depth: u32, edge_types: Option<&[&str]>, dir: Dir) -> ResultSet {
9825        let view = self.db.view();
9826        let resolved: Option<Vec<u32>> = edge_types.map(|names| {
9827            names
9828                .iter()
9829                .filter_map(|name| view.syms.get(name))
9830                .collect()
9831        });
9832        let nb = neighborhood(&view, self.id, depth, resolved.as_deref(), dir);
9833        let mut rs = ResultSet::new(vec!["key".into(), "label".into(), "depth".into()]);
9834        for (nid, d) in nb.nodes {
9835            let key = view.key_of(nid);
9836            let label = view
9837                .label_of(nid)
9838                .expect("real nodes always have a label; u32::MAX sentinel cannot occur");
9839            rs.push_row(vec![
9840                Some(Value::Str(key.to_string())),
9841                Some(Value::Str(label.to_string())),
9842                Some(Value::Int(d as i64)),
9843            ]);
9844        }
9845        rs
9846    }
9847
9848    /// 1-hop, Both directions: edge-type name → sorted unique neighbor keys.
9849    pub fn grouped_by_edge_type(&self) -> BTreeMap<String, Vec<String>> {
9850        let view = self.db.view();
9851        let mut groups: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
9852        for e in expand(&view, self.id, None, Dir::Both) {
9853            // Skip edges with unknown etypes (only possible from corrupt large
9854            // TOPOLOGY section; function returns BTreeMap not Result).
9855            let Some(etype) = view.syms.resolve(e.etype) else {
9856                continue;
9857            };
9858            let etype = etype.to_string();
9859            let nbr = if e.src == self.id { e.dst } else { e.src };
9860            groups
9861                .entry(etype)
9862                .or_default()
9863                .insert(view.key_of(nbr).to_string());
9864        }
9865        groups
9866            .into_iter()
9867            .map(|(k, v)| (k, v.into_iter().collect()))
9868            .collect()
9869    }
9870}
9871
9872#[cfg(test)]
9873mod tests {
9874    use super::*;
9875    use core_rules::Predicate;
9876
9877    fn tmp_dir(name: &str) -> std::path::PathBuf {
9878        let d =
9879            std::env::temp_dir().join(format!("graphdb-db-unit-{}-{}", name, std::process::id()));
9880        let _ = std::fs::remove_dir_all(&d);
9881        d
9882    }
9883
9884    fn fk_rule() -> RuleDef {
9885        RuleDef {
9886            name: "works_at".into(),
9887            src_label: "Person".into(),
9888            dst_label: "Org".into(),
9889            predicate: Predicate::KeyMatch {
9890                field: "org_id".into(),
9891            },
9892            edge_type: "WORKS_AT".into(),
9893            weight_prop: None,
9894            max_edges: None,
9895            approximate: false,
9896            via_label: None,
9897            via_edge: None,
9898            via_dir: None,
9899        }
9900    }
9901
9902    /// Regression guard for the no-views delta-copy fast path.
9903    ///
9904    /// When no views are defined, `pending_deltas_since().to_vec()` must never
9905    /// be called — even during a large CreateRule backfill. The DELTA_COPY_COUNT
9906    /// thread-local is incremented inside every `if !view_store.is_empty()` block;
9907    /// a count of 0 after the entire sequence proves the guard fires correctly.
9908    #[test]
9909    fn no_delta_copy_when_no_views() {
9910        DELTA_COPY_COUNT.with(|c| c.set(0));
9911        let dir = tmp_dir("no-delta-copy");
9912        {
9913            let mut db = GraphDb::open(&dir).unwrap();
9914            // Insert 50 Org + 50 Person nodes with FK links.
9915            for i in 0..50u32 {
9916                db.insert_node("Org", &format!("o{i}"), vec![]).unwrap();
9917            }
9918            for i in 0..50u32 {
9919                db.insert_node(
9920                    "Person",
9921                    &format!("p{i}"),
9922                    vec![("org_id".into(), Value::Str(format!("o{i}")))],
9923                )
9924                .unwrap();
9925            }
9926            // CreateRule backfill should NOT invoke to_vec() when no views are defined.
9927            db.create_rule(fk_rule()).unwrap();
9928
9929            // Counter must stay 0 — no views, no copies.
9930            let copies = DELTA_COPY_COUNT.with(|c| c.get());
9931            assert_eq!(
9932                copies, 0,
9933                "pending_deltas_since().to_vec() called despite no views"
9934            );
9935
9936            // Derived edges must still be correct (the guard skips only the
9937            // empty delta propagation loop, not the rule application itself).
9938            let nbrs = db.neighbors("p0", "WORKS_AT", Direction::Out).unwrap();
9939            assert_eq!(
9940                nbrs,
9941                vec!["o0"],
9942                "rule must derive edges even with no views"
9943            );
9944        }
9945        let _ = std::fs::remove_dir_all(&dir);
9946    }
9947
9948    /// Gating regression: subscribe AFTER a backfill must see no stale events.
9949    /// subscribe BEFORE a backfill must see every edge-fire event.
9950    #[test]
9951    fn subscribe_after_backfill_no_stale_events() {
9952        let dir = tmp_dir("sub-after-backfill");
9953        {
9954            let mut db = GraphDb::open(&dir).unwrap();
9955            for i in 0..10u32 {
9956                db.insert_node("Org", &format!("o{i}"), vec![]).unwrap();
9957                db.insert_node(
9958                    "Person",
9959                    &format!("p{i}"),
9960                    vec![("org_id".into(), Value::Str(format!("o{i}")))],
9961                )
9962                .unwrap();
9963            }
9964            // Create rule BEFORE subscribing — emit_deltas is false during backfill.
9965            db.create_rule(fk_rule()).unwrap();
9966
9967            // Subscribe AFTER the backfill — queue must be empty (no stale events).
9968            let sub = db.subscribe_all_rules().unwrap();
9969            // No events should have queued for the prior backfill.
9970            assert!(
9971                sub.try_recv().is_none(),
9972                "subscribe after backfill must see no stale events"
9973            );
9974
9975            // Inserting a new node now should fire an event (emit_deltas is now true).
9976            db.insert_node("Org", "o_new", vec![]).unwrap();
9977            db.insert_node(
9978                "Person",
9979                "p_new",
9980                vec![("org_id".into(), Value::Str("o_new".into()))],
9981            )
9982            .unwrap();
9983            let ev = sub.recv_timeout(std::time::Duration::from_millis(200));
9984            assert!(
9985                ev.is_some(),
9986                "edge-fire event must arrive after subscribe (emit_deltas=true)"
9987            );
9988        }
9989        let _ = std::fs::remove_dir_all(&dir);
9990    }
9991
9992    /// Gating regression: subscribe BEFORE a backfill → events flow.
9993    #[test]
9994    fn subscribe_before_backfill_events_flow() {
9995        let dir = tmp_dir("sub-before-backfill");
9996        {
9997            let mut db = GraphDb::open(&dir).unwrap();
9998            // Subscribe FIRST — emit_deltas becomes true.
9999            let sub = db.subscribe_all_rules().unwrap();
10000
10001            for i in 0..5u32 {
10002                db.insert_node("Org", &format!("o{i}"), vec![]).unwrap();
10003                db.insert_node(
10004                    "Person",
10005                    &format!("p{i}"),
10006                    vec![("org_id".into(), Value::Str(format!("o{i}")))],
10007                )
10008                .unwrap();
10009            }
10010            // Backfill fires with emit_deltas=true → events queued.
10011            db.create_rule(fk_rule()).unwrap();
10012
10013            // Should receive at least one edge-fired event from the backfill.
10014            let mut received = 0usize;
10015            while sub.try_recv().is_some() {
10016                received += 1;
10017            }
10018            assert!(
10019                received > 0,
10020                "subscribe before backfill must receive edge-fire events (got 0)"
10021            );
10022        }
10023        let _ = std::fs::remove_dir_all(&dir);
10024    }
10025
10026    /// Companion: when a view IS defined, the delta path fires and view values update.
10027    #[test]
10028    fn delta_copy_fires_when_view_exists() {
10029        use core_rules::ViewSource;
10030        DELTA_COPY_COUNT.with(|c| c.set(0));
10031        let dir = tmp_dir("delta-copy-with-view");
10032        {
10033            let mut db = GraphDb::open(&dir).unwrap();
10034            db.insert_node("Org", "o1", vec![]).unwrap();
10035            db.insert_node(
10036                "Person",
10037                "p1",
10038                vec![("org_id".into(), Value::Str("o1".into()))],
10039            )
10040            .unwrap();
10041            // Declare a Degree view so is_empty() returns false.
10042            db.create_view(ViewDef {
10043                name: "degree_out".into(),
10044                label: "Person".into(),
10045                view_prop: "degree_out".into(),
10046                source: ViewSource::Degree {
10047                    edge_type: "WORKS_AT".into(),
10048                    direction: Direction::Out,
10049                },
10050            })
10051            .unwrap();
10052            db.create_rule(fk_rule()).unwrap();
10053
10054            // At least one delta copy should have happened (CreateRule backfill).
10055            let copies = DELTA_COPY_COUNT.with(|c| c.get());
10056            assert!(
10057                copies > 0,
10058                "expected delta copy to fire when a view is defined"
10059            );
10060
10061            // View value should be computed: p1 has one WORKS_AT out-edge.
10062            let info = db.node_info("p1").unwrap();
10063            let degree = info.props.get("degree_out");
10064            assert!(
10065                degree.is_some(),
10066                "view prop should be written to node props"
10067            );
10068        }
10069        let _ = std::fs::remove_dir_all(&dir);
10070    }
10071
10072    /// Regression: `open_at_with` must call `rebuild_all` after WAL replay so
10073    /// derived-edge-driven view values reflect the as-of state rather than just
10074    /// the initial backfill written at `CreateView` time.
10075    ///
10076    /// Base WAL frames (indices 0..=5 before history markers):
10077    ///   0: insert Org "o1"
10078    ///   1: create_view "employee_count" (Degree / WORKS_AT / In) on Org
10079    ///   2: create_rule fk_rule (WORKS_AT, Person→Org via org_id)
10080    ///   3: insert Person "p1" → rule fires WORKS_AT p1→o1 (degree = 1)  ← mid
10081    ///   4: insert Person "p2" → rule fires WORKS_AT p2→o1 (degree = 2)
10082    ///   5: insert Person "p3" → rule fires WORKS_AT p3→o1 (degree = 3)  ← latest
10083    ///
10084    /// Each rule-fire also appends a DerivedEdgeAdded history-marker frame (state
10085    /// no-op), so the total commit count is higher than the base frame count.
10086    /// The "latest" open_at commit is computed dynamically via `wal_commit_count_at`.
10087    ///
10088    /// Without `rebuild_all`, the as-of instance's "emp" view stays at the
10089    /// initial backfill value (0) instead of reflecting the replayed derived edges.
10090    #[test]
10091    fn open_at_derived_edge_view_values_correct() {
10092        use core_rules::ViewSource;
10093        let dir = tmp_dir("open-at-view-rebuild");
10094        {
10095            let mut db = GraphDb::open(&dir).unwrap();
10096            // frame 0
10097            db.insert_node("Org", "o1", vec![]).unwrap();
10098            // frame 1: create view — initial backfill sees 0 derived edges (none fired yet)
10099            db.create_view(ViewDef {
10100                name: "employee_count".into(),
10101                label: "Org".into(),
10102                view_prop: "emp".into(),
10103                source: ViewSource::Degree {
10104                    edge_type: "WORKS_AT".into(),
10105                    direction: Direction::In,
10106                },
10107            })
10108            .unwrap();
10109            // frame 2: create rule — no Persons yet; backfill is a no-op
10110            db.create_rule(fk_rule()).unwrap();
10111            // frame 3: p1 — rule fires WORKS_AT p1→o1; degree = 1
10112            db.insert_node(
10113                "Person",
10114                "p1",
10115                vec![("org_id".into(), Value::Str("o1".into()))],
10116            )
10117            .unwrap();
10118            // frame 4: p2 — degree = 2
10119            db.insert_node(
10120                "Person",
10121                "p2",
10122                vec![("org_id".into(), Value::Str("o1".into()))],
10123            )
10124            .unwrap();
10125            // frame 5: p3 — degree = 3
10126            db.insert_node(
10127                "Person",
10128                "p3",
10129                vec![("org_id".into(), Value::Str("o1".into()))],
10130            )
10131            .unwrap();
10132            // Sanity: normal open sees degree = 3.
10133            assert_eq!(
10134                db.get_view_prop("o1", "emp"),
10135                Some(Value::Int(3)),
10136                "normal db must show degree 3 after 3 derived edges"
10137            );
10138        } // WAL flushed
10139
10140        // Re-open normally to get the authoritative reference value.
10141        let normal_db = GraphDb::open(&dir).unwrap();
10142        let normal_emp = normal_db.get_view_prop("o1", "emp");
10143        assert_eq!(
10144            normal_emp,
10145            Some(Value::Int(3)),
10146            "re-opened normal db must show degree 3"
10147        );
10148
10149        // Latest as-of (last WAL commit): must match the normal open.
10150        // History-marker frames are appended after each rule-fire, so the total
10151        // commit count is computed dynamically rather than hardcoded.
10152        let total = crate::wal_commit_count_at(&dir).unwrap();
10153        let aof_latest = GraphDb::open_at(&dir, total - 1).unwrap();
10154        assert_eq!(
10155            aof_latest.get_view_prop("o1", "emp"),
10156            normal_emp,
10157            "open_at latest: derived-edge view must equal normal open (rebuild_all required)"
10158        );
10159
10160        // Mid-history as-of (commit 3 = p1 insert Batch frame): only p1; degree = 1.
10161        // The DerivedEdgeAdded marker for p1 is at frame 4 (state no-op on replay),
10162        // so replaying 0..=3 correctly re-derives only the p1→o1 edge.
10163        let aof_mid = GraphDb::open_at(&dir, 3).unwrap();
10164        assert_eq!(
10165            aof_mid.get_view_prop("o1", "emp"),
10166            Some(Value::Int(1)),
10167            "open_at mid-history: only p1 exists at frame 3, degree must be 1"
10168        );
10169
10170        let _ = std::fs::remove_dir_all(&dir);
10171    }
10172
10173    /// Pin: subscribe_* on an as-of instance must return Err(ReadOnly) —
10174    /// as-of instances never commit, so distribute_events never runs and any
10175    /// subscription would wait forever.
10176    #[test]
10177    fn subscribe_on_as_of_returns_read_only_error() {
10178        let dir = tmp_dir("sub-as-of-read-only");
10179        {
10180            let mut db = GraphDb::open(&dir).unwrap();
10181            db.insert_node("Org", "o1", vec![]).unwrap();
10182            db.create_rule(fk_rule()).unwrap();
10183        }
10184        let mut aof = GraphDb::open_at(&dir, 0).unwrap();
10185
10186        assert!(
10187            matches!(
10188                aof.subscribe_all_rules(),
10189                Err(core_storage::GraphError::ReadOnly)
10190            ),
10191            "subscribe_all_rules on as-of must return ReadOnly"
10192        );
10193        assert!(
10194            matches!(
10195                aof.subscribe_writes(),
10196                Err(core_storage::GraphError::ReadOnly)
10197            ),
10198            "subscribe_writes on as-of must return ReadOnly"
10199        );
10200        assert!(
10201            matches!(
10202                aof.subscribe_rule("works_at"),
10203                Err(core_storage::GraphError::ReadOnly)
10204            ),
10205            "subscribe_rule on as-of must return ReadOnly"
10206        );
10207        let _ = std::fs::remove_dir_all(&dir);
10208    }
10209
10210    /// Regression: a failed dense WAL rewrite must not leave speculative
10211    /// interns in `syms`. If it does, the next successful mutation logs an
10212    /// `Intern` record with an inflated id; replay (which never saw the
10213    /// orphans) assigns a smaller id and the WAL becomes unreplayable.
10214    #[test]
10215    fn dense_rewrite_error_rolls_back_speculative_interns() {
10216        let dir = tmp_dir("dense-rewrite-rollback");
10217        {
10218            let mut db = GraphDb::open(&dir).unwrap();
10219            db.insert_node("Person", "a", vec![]).unwrap();
10220
10221            // Bypass MutPreview validation to hit the rewrite's own error path
10222            // (same shape as an id-exhaustion failure mid-rewrite). The
10223            // InsertEdge arm interns the edge type before it resolves keys.
10224            let err = db.rewrite_wal_dense(vec![WalRecord::InsertEdge {
10225                edge_type: "ORPHAN_TYPE".into(),
10226                src_key: "missing".into(),
10227                dst_key: "a".into(),
10228            }]);
10229            assert!(err.is_err(), "rewrite of a missing src key must fail");
10230            assert_eq!(
10231                db.syms.get("ORPHAN_TYPE"),
10232                None,
10233                "failed rewrite must roll back speculative interns"
10234            );
10235
10236            // A later successful mutation must produce a replayable WAL.
10237            db.set_prop("a", "later_field", Value::Int(2)).unwrap();
10238        }
10239        let db = GraphDb::open(&dir).expect("WAL must replay after failed rewrite");
10240        assert_eq!(db.get_prop("a", "later_field"), Some(Value::Int(2)));
10241        let _ = std::fs::remove_dir_all(&dir);
10242    }
10243}