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    /// How many writes hit the rule-chaining depth cap with work still pending,
273    /// since this handle was opened. Non-zero means some derived edges beyond
274    /// the cap are stale and no single later write will repair them: split the
275    /// rule chain or shorten it. Never persisted, so it resets on reopen.
276    #[serde(default)]
277    pub chain_truncations: u64,
278}
279
280/// One rule's provenance size, trip latch, and fire counter.
281///
282/// `tripped` is a one-way latch: once set, the engine adds no new edges for
283/// that rule until [`GraphDb::rebuild_rule`] (and only if the full desired
284/// set then fits). `fires` counts `on_node_changed` evaluations plus
285/// backfill/rebuild participant ticks (rebuild counts even when it is a
286/// provenance no-op).
287#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
288pub struct RuleStats {
289    pub name: String,
290    pub edges: u64,
291    pub tripped: bool,
292    pub fires: u64,
293    /// Whether this rule uses the approximate IVF-Flat candidate path.
294    pub approximate: bool,
295}
296
297/// One entry in the slow-query ring buffer.
298#[derive(Debug, Clone, Serialize)]
299pub struct SlowQueryEntry {
300    /// Execution time in whole milliseconds.
301    pub ms: u64,
302    /// The Cypher query string that was slow.
303    pub query: String,
304    /// The commit sequence number at the time the query ran.
305    pub at_commit: u64,
306}
307
308/// Snapshot of the slow-query log returned by [`GraphDb::slow_query_snapshot`].
309#[derive(Debug, Clone, Serialize)]
310pub struct SlowQuerySnapshot {
311    /// Current threshold in milliseconds (0 = disabled).
312    pub threshold_ms: u64,
313    /// Total number of slow queries ever recorded (not capped by ring size).
314    pub count: u64,
315    /// Most-recent slow queries (up to 16), oldest first.
316    pub last: Vec<SlowQueryEntry>,
317}
318
319/// Internal ring-buffer state protected by a `Mutex` so `query(&self)` can
320/// write to it without a mutable borrow.
321struct SlowQueryLog {
322    entries: std::collections::VecDeque<SlowQueryEntry>,
323    total: u64,
324}
325
326/// Maximum number of entries kept in the slow-query ring buffer.
327const SLOW_QUERY_RING_CAP: usize = 16;
328
329/// Wire summary of a [`Predicate`]. JSON only — `Explanation` is never
330/// bincode-persisted (WAL/snapshots store `RuleDef` bytes, not this type).
331#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
332pub struct PredicateSummary {
333    pub kind: String,
334    pub fields: Vec<String>,
335    pub min: Option<f64>,
336    pub tolerance: Option<f64>,
337    pub km: Option<f64>,
338    pub parts: Option<Vec<PredicateSummary>>,
339    /// True when the owning rule has `approximate=true` (IVF-Flat candidate path).
340    /// Always false for predicates reported without rule context (sub-predicates in `parts`).
341    #[serde(default)]
342    pub approximate: bool,
343}
344
345impl From<&Predicate> for PredicateSummary {
346    fn from(p: &Predicate) -> Self {
347        match p {
348            Predicate::KeyMatch { field } => PredicateSummary {
349                kind: "key_match".into(),
350                fields: vec![field.clone()],
351                min: None,
352                tolerance: None,
353                km: None,
354                parts: None,
355                approximate: false,
356            },
357            Predicate::FieldEqual { field } => PredicateSummary {
358                kind: "field_equal".into(),
359                fields: vec![field.clone()],
360                min: None,
361                tolerance: None,
362                km: None,
363                parts: None,
364                approximate: false,
365            },
366            Predicate::Overlap { field, min } => PredicateSummary {
367                kind: "overlap".into(),
368                fields: vec![field.clone()],
369                min: Some(*min),
370                tolerance: None,
371                km: None,
372                parts: None,
373                approximate: false,
374            },
375            Predicate::NumericWithin { field, tolerance } => PredicateSummary {
376                kind: "numeric_within".into(),
377                fields: vec![field.clone()],
378                min: None,
379                tolerance: Some(*tolerance),
380                km: None,
381                parts: None,
382                approximate: false,
383            },
384            Predicate::GeoRadius { field, km } => PredicateSummary {
385                kind: "geo_radius".into(),
386                fields: vec![field.clone()],
387                min: None,
388                tolerance: None,
389                km: Some(*km),
390                parts: None,
391                approximate: false,
392            },
393            Predicate::VectorSimilar { field, min } => PredicateSummary {
394                kind: "vector_similar".into(),
395                fields: vec![field.clone()],
396                min: Some(*min),
397                tolerance: None,
398                km: None,
399                parts: None,
400                approximate: false,
401            },
402            Predicate::All(inner) => {
403                let parts: Vec<PredicateSummary> = inner.iter().map(Self::from).collect();
404                let mut fields = Vec::new();
405                for part in &parts {
406                    for f in &part.fields {
407                        if !fields.contains(f) {
408                            fields.push(f.clone());
409                        }
410                    }
411                }
412                PredicateSummary {
413                    kind: "all".into(),
414                    fields,
415                    min: None,
416                    tolerance: None,
417                    km: None,
418                    parts: Some(parts),
419                    approximate: false,
420                }
421            }
422            Predicate::Any(inner) => {
423                let parts: Vec<PredicateSummary> = inner.iter().map(Self::from).collect();
424                let mut fields = Vec::new();
425                for part in &parts {
426                    for f in &part.fields {
427                        if !fields.contains(f) {
428                            fields.push(f.clone());
429                        }
430                    }
431                }
432                PredicateSummary {
433                    kind: "any".into(),
434                    fields,
435                    min: None,
436                    tolerance: None,
437                    km: None,
438                    parts: Some(parts),
439                    approximate: false,
440                }
441            }
442        }
443    }
444}
445
446/// Snapshot of a live node's key, label, and columnar properties.
447///
448/// `props` is a [`BTreeMap`] so field order is deterministic (sorted by name)
449/// regardless of insert order or the columnar store's `HashMap` iteration.
450///
451/// Deliberately does not derive `Serialize`: `Value`'s serde form is
452/// internally tagged. Wire JSON is built by `value_to_json` in the server.
453#[derive(Debug, Clone, PartialEq)]
454pub struct NodeInfo {
455    pub key: String,
456    pub label: String,
457    pub props: BTreeMap<String, Value>,
458}
459
460/// Counts returned by [`GraphDb::delete_node`].
461#[derive(Debug, Clone, PartialEq, Eq, Default)]
462pub struct DeleteReport {
463    /// Number of manual (user-inserted) edges removed.
464    pub manual_edges: u64,
465    /// Number of derived (rule-owned) edges retracted.
466    pub derived_edges: u64,
467}
468
469/// One directed edge incident on a node, with provenance membership.
470///
471/// `derived` is true iff `(edge_type, src, dst)` is in the rule engine's
472/// Plan-8 `by_node` provenance index.
473#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
474pub struct EdgeInfo {
475    pub edge_type: String,
476    pub src_key: String,
477    pub dst_key: String,
478    pub derived: bool,
479}
480
481/// An edge with mask-aware endpoint visibility.
482///
483/// Returned by [`GraphDb::node_edges_masked`] in [`crate::mask::MaskMode::Stub`]
484/// mode — hidden endpoints carry `*_restricted: true`.
485#[derive(Debug, Clone, PartialEq, Eq)]
486pub struct MaskedEdge {
487    pub edge_type: String,
488    pub src_key: String,
489    /// `true` when `src_key` is in the DB but hidden from the mask.
490    pub src_restricted: bool,
491    pub dst_key: String,
492    /// `true` when `dst_key` is in the DB but hidden from the mask.
493    pub dst_restricted: bool,
494    pub derived: bool,
495}
496
497/// Result of a mask-aware node lookup via [`GraphDb::node_info_masked`].
498///
499/// `None` from that method means the key does not exist (→ 404).
500/// `Some(Restricted)` is only produced when `mask.mode() == MaskMode::Stub`.
501#[derive(Debug, PartialEq)]
502pub enum MaskedNodeResult {
503    Visible(NodeInfo),
504    /// Node exists in the DB but is hidden from this mask.
505    Restricted,
506}
507
508/// One rule-owned edge between two nodes, with the rule name, edge type,
509/// direction (src_key → dst_key), and weight if the rule stores one.
510#[derive(Debug, Clone, PartialEq, Serialize)]
511pub struct Explanation {
512    pub rule: String,
513    pub edge_type: String,
514    pub src_key: String,
515    pub dst_key: String,
516    pub weight: Option<f64>,
517    pub predicate: PredicateSummary,
518    /// For a via-hop rule, the edge type the rule hops over to reach its
519    /// candidates. `None` for a plain two-node rule. A via-hop rule whose
520    /// `via_edge` is itself rule-derived is the chaining case: the hop edge
521    /// was written by another rule in the same commit.
522    #[serde(default)]
523    pub via_edge: Option<String>,
524}
525
526/// Report returned by [`GraphDb::backup_to`].
527#[derive(Debug, Clone)]
528pub struct BackupReport {
529    /// Filenames copied into the destination directory (sorted ascending).
530    pub files: Vec<String>,
531    /// Total bytes written across all copied files.
532    pub bytes: u64,
533    /// `true` when the destination opened cleanly and passed post-copy checks.
534    ///
535    /// For stores that have a `snapshot.bin` this means: all V8 section CRCs
536    /// matched **and** the destination opened without error.
537    ///
538    /// For WAL-only stores (no `snapshot.bin`) there is no snapshot to
539    /// CRC-check; `verified` is `true` when the destination opened and
540    /// replayed the WAL without error (record-level checksums in the WAL
541    /// provide the integrity signal, not section CRCs).
542    pub verified: bool,
543}
544
545/// One directed edge in export form, with optional rule attribution for derived edges.
546///
547/// Returned by [`GraphDb::all_edges_for_export`].
548#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
549pub struct ExportEdge {
550    pub edge_type: String,
551    pub src: String,
552    pub dst: String,
553    pub derived: bool,
554    /// Rule name that created this edge, if derived. `None` for manual edges.
555    pub rule: Option<String>,
556}
557
558/// Construct the standard write-query result set (columns: created, properties_set, deleted).
559fn write_result_set() -> ResultSet {
560    ResultSet::new(vec![
561        "created".into(),
562        "properties_set".into(),
563        "deleted".into(),
564    ])
565}
566
567fn resolve_merge_set_value(op: &Operand, params: &BTreeMap<String, Value>) -> Result<Value> {
568    match op {
569        Operand::Lit(v) => Ok(v.clone()),
570        Operand::Param(name) => params
571            .get(name)
572            .cloned()
573            .ok_or_else(|| GraphError::QueryError {
574                detail: format!("missing parameter `{name}`"),
575            }),
576        _ => Err(GraphError::QueryError {
577            detail: "ON CREATE/ON MATCH SET value must be a literal or $parameter".into(),
578        }),
579    }
580}
581
582fn operand_node_vars(op: &Operand, out: &mut Vec<String>) {
583    match op {
584        Operand::Prop { var, .. } | Operand::Var(var) => {
585            if !out.contains(var) {
586                out.push(var.clone());
587            }
588        }
589        Operand::FuncCall { args, .. } => {
590            for arg in args {
591                operand_node_vars(arg, out);
592            }
593        }
594        Operand::BinArith { left, right, .. } => {
595            operand_node_vars(left, out);
596            operand_node_vars(right, out);
597        }
598        Operand::Case { branches, default } => {
599            // Branch conditions reference vars already bound (and mask-filtered)
600            // by the MATCH phase, so collecting from the value operands + ELSE
601            // is sufficient for RETURN-projection var discovery.
602            for (_, value) in branches {
603                operand_node_vars(value, out);
604            }
605            if let Some(d) = default {
606                operand_node_vars(d, out);
607            }
608        }
609        Operand::Lit(_) | Operand::Param(_) => {}
610    }
611}
612
613fn ret_node_vars(items: &[RetItem]) -> Vec<String> {
614    let mut out = Vec::new();
615    for item in items {
616        match &item.value {
617            RetVal::Var(v) | RetVal::Prop { var: v, .. } => {
618                if !out.contains(v) {
619                    out.push(v.clone());
620                }
621            }
622            RetVal::FuncCall { args, .. } => {
623                for arg in args {
624                    operand_node_vars(arg, &mut out);
625                }
626            }
627            RetVal::ScalarExpr(op) => operand_node_vars(op, &mut out),
628            RetVal::Agg { .. } => {}
629        }
630    }
631    out
632}
633
634fn add_var(out: &mut Vec<String>, v: &str) {
635    if !out.iter().any(|x| x == v) {
636        out.push(v.to_string());
637    }
638}
639
640fn pattern_node_vars(pats: &[Pattern]) -> Vec<String> {
641    let mut out = Vec::new();
642    for p in pats {
643        if let Some(v) = &p.start.var {
644            add_var(&mut out, v);
645        }
646        for (_, dest) in &p.chain {
647            if let Some(v) = &dest.var {
648                add_var(&mut out, v);
649            }
650        }
651    }
652    out
653}
654
655fn pattern_rel_vars(pats: &[Pattern]) -> Vec<String> {
656    let mut out = Vec::new();
657    for p in pats {
658        for (rel, _) in &p.chain {
659            if rel.hops.is_none() {
660                if let Some(v) = &rel.var {
661                    add_var(&mut out, v);
662                }
663            }
664        }
665    }
666    out
667}
668
669fn rel_type_alias(var: &str) -> String {
670    format!("__rt_{var}")
671}
672
673fn ret_column_name(item: &RetItem) -> String {
674    if let Some(alias) = &item.alias {
675        return alias.clone();
676    }
677    match &item.value {
678        RetVal::Var(v) => v.clone(),
679        RetVal::Prop { var, field } => format!("{var}.{field}"),
680        RetVal::FuncCall { name, args } => {
681            let arg_strs: Vec<String> = args
682                .iter()
683                .map(|a| match a {
684                    Operand::Var(v) => v.clone(),
685                    Operand::Prop { var, field } => format!("{var}.{field}"),
686                    Operand::Lit(_) => "<lit>".to_string(),
687                    Operand::Param(p) => format!("${p}"),
688                    Operand::FuncCall { name: n, .. } => format!("{n}(...)"),
689                    Operand::BinArith { .. } => "<arith>".to_string(),
690                    Operand::Case { .. } => "<case>".to_string(),
691                })
692                .collect();
693            format!("{name}({})", arg_strs.join(", "))
694        }
695        RetVal::ScalarExpr(_) => "<expr>".to_string(),
696        RetVal::Agg { .. } => "<agg>".to_string(),
697    }
698}
699
700fn eval_set_return_operand<F: Fs>(
701    db: &GraphDb<F>,
702    match_rs: &ResultSet,
703    row: usize,
704    rel_vars: &[String],
705    op: &Operand,
706    params: &BTreeMap<String, Value>,
707) -> Result<Option<Value>> {
708    match op {
709        Operand::Lit(v) => Ok(Some(v.clone())),
710        Operand::Param(name) => params.get(name).cloned().ok_or_else(|| GraphError::QueryError {
711            detail: format!("missing parameter `{name}`"),
712        }).map(Some),
713        Operand::Var(name) if rel_vars.iter().any(|r| r == name) => Err(GraphError::QueryError {
714            detail: format!(
715                "cannot return relationship variable '{name}' bare; return its properties ({name}.field) instead"
716            ),
717        }),
718        Operand::Var(name) => Ok(match_rs.get(row, name).cloned()),
719        Operand::Prop { var, field } => {
720            if rel_vars.iter().any(|r| r == var) {
721                return Ok(None);
722            }
723            let Some(Value::Str(key)) = match_rs.get(row, var) else {
724                return Ok(None);
725            };
726            Ok(db.get_prop(key, field))
727        }
728        Operand::FuncCall { name, args } => {
729            eval_set_return_func(db, match_rs, row, rel_vars, name, args, params)
730        }
731        Operand::BinArith { op, left, right } => {
732            let lv = eval_set_return_operand(db, match_rs, row, rel_vars, left, params)?;
733            let rv = eval_set_return_operand(db, match_rs, row, rel_vars, right, params)?;
734            eval_set_return_arith(op, lv, rv)
735        }
736        // CASE is supported in read-query RETURN; in a write-statement RETURN
737        // projection (CREATE/MERGE/SET … RETURN) it is not yet wired.
738        Operand::Case { .. } => Err(GraphError::QueryError {
739            detail: "CASE is not supported in a write-statement RETURN projection; \
740                     use a read query"
741                .into(),
742        }),
743    }
744}
745
746fn eval_set_return_arith(
747    op: &ArithOp,
748    lv: Option<Value>,
749    rv: Option<Value>,
750) -> Result<Option<Value>> {
751    match (lv, rv) {
752        (None, _) | (_, None) => Ok(None),
753        (Some(Value::Int(a)), Some(Value::Int(b))) => {
754            let result = match op {
755                ArithOp::Sub => a.saturating_sub(b),
756                ArithOp::Mul => a.saturating_mul(b),
757                ArithOp::Add => a.saturating_add(b),
758                ArithOp::Div => {
759                    if b == 0 {
760                        return Err(GraphError::QueryError {
761                            detail: "division by zero".into(),
762                        });
763                    }
764                    a.checked_div(b).unwrap_or(i64::MAX)
765                }
766            };
767            Ok(Some(Value::Int(result)))
768        }
769        (Some(lv), Some(rv)) => {
770            let a = match &lv {
771                Value::Float(f) => *f,
772                Value::Int(i) => *i as f64,
773                _ => {
774                    return Err(GraphError::QueryError {
775                        detail: format!("arithmetic operand must be numeric, got {lv:?}"),
776                    })
777                }
778            };
779            let b = match &rv {
780                Value::Float(f) => *f,
781                Value::Int(i) => *i as f64,
782                _ => {
783                    return Err(GraphError::QueryError {
784                        detail: format!("arithmetic operand must be numeric, got {rv:?}"),
785                    })
786                }
787            };
788            let result = match op {
789                ArithOp::Sub => a - b,
790                ArithOp::Mul => a * b,
791                ArithOp::Add => a + b,
792                ArithOp::Div => {
793                    if b == 0.0 {
794                        return Err(GraphError::QueryError {
795                            detail: "division by zero".into(),
796                        });
797                    }
798                    a / b
799                }
800            };
801            Ok(Some(Value::Float(result)))
802        }
803    }
804}
805
806fn eval_set_return_func<F: Fs>(
807    db: &GraphDb<F>,
808    match_rs: &ResultSet,
809    row: usize,
810    rel_vars: &[String],
811    name: &str,
812    args: &[Operand],
813    params: &BTreeMap<String, Value>,
814) -> Result<Option<Value>> {
815    let norm = name.to_ascii_lowercase();
816    if norm == "type" {
817        if args.len() != 1 {
818            return Err(GraphError::QueryError {
819                detail: format!("type() requires exactly 1 argument, got {}", args.len()),
820            });
821        }
822        let Operand::Var(rel) = &args[0] else {
823            return Err(GraphError::QueryError {
824                detail: "type() argument must be a relationship variable (e.g. type(r))".into(),
825            });
826        };
827        return Ok(match_rs.get(row, &rel_type_alias(rel)).cloned());
828    }
829    if norm == "key" {
830        if args.len() != 1 {
831            return Err(GraphError::QueryError {
832                detail: format!("key() requires exactly 1 argument, got {}", args.len()),
833            });
834        }
835        let Operand::Var(var) = &args[0] else {
836            return Err(GraphError::QueryError {
837                detail: "key() argument must be a node variable (e.g. key(n))".into(),
838            });
839        };
840        if rel_vars.iter().any(|r| r == var) {
841            return Err(GraphError::QueryError {
842                detail: format!("key() argument `{var}` is a relationship, not a node"),
843            });
844        }
845        // MATCH rows bind node variables to their key string, so the column
846        // value *is* the key.
847        return Ok(match_rs.get(row, var).cloned());
848    }
849    let mut vals = Vec::with_capacity(args.len());
850    for arg in args {
851        vals.push(eval_set_return_operand(
852            db, match_rs, row, rel_vars, arg, params,
853        )?);
854    }
855    match norm.as_str() {
856        "tolower" => {
857            if vals.len() != 1 {
858                return Err(GraphError::QueryError {
859                    detail: format!("toLower() requires exactly 1 argument, got {}", vals.len()),
860                });
861            }
862            Ok(vals[0].clone().map(|val| match val {
863                Value::Str(s) => Value::Str(s.to_ascii_lowercase()),
864                other => other,
865            }))
866        }
867        "toupper" => {
868            if vals.len() != 1 {
869                return Err(GraphError::QueryError {
870                    detail: format!("toUpper() requires exactly 1 argument, got {}", vals.len()),
871                });
872            }
873            Ok(vals[0].clone().map(|val| match val {
874                Value::Str(s) => Value::Str(s.to_ascii_uppercase()),
875                other => other,
876            }))
877        }
878        "size" => match vals.first().cloned().flatten() {
879            None => Ok(None),
880            Some(Value::Str(s)) => Ok(Some(Value::Int(s.len() as i64))),
881            Some(Value::List(items)) => Ok(Some(Value::Int(items.len() as i64))),
882            Some(_) => Ok(None),
883        },
884        "coalesce" => Ok(vals.into_iter().flatten().next()),
885        "abs" => match vals.first().cloned().flatten() {
886            None => Ok(None),
887            Some(Value::Int(n)) => Ok(Some(Value::Int(n.saturating_abs()))),
888            Some(Value::Float(f)) => Ok(Some(Value::Float(f.abs()))),
889            Some(_) => Ok(None),
890        },
891        "round" => match vals.first().cloned().flatten() {
892            None => Ok(None),
893            Some(Value::Float(f)) => Ok(Some(Value::Float(f.round()))),
894            Some(Value::Int(n)) => Ok(Some(Value::Int(n))),
895            Some(_) => Ok(None),
896        },
897        "decay" => {
898            if vals.len() != 3 {
899                return Err(GraphError::QueryError {
900                    detail: format!("decay() requires exactly 3 arguments, got {}", vals.len()),
901                });
902            }
903            match (vals[0].clone(), vals[1].clone(), vals[2].clone()) {
904                (None, _, _) | (_, None, _) | (_, _, None) => Ok(None),
905                (Some(b), Some(a), Some(h)) => {
906                    let numeric = |v: Value| -> Result<f64> {
907                        match v {
908                            Value::Int(n) => Ok(n as f64),
909                            Value::Float(f) => Ok(f),
910                            other => Err(GraphError::QueryError {
911                                detail: format!(
912                                    "decay() requires numeric arguments, got {other:?}"
913                                ),
914                            }),
915                        }
916                    };
917                    let b = numeric(b)?;
918                    let a = numeric(a)?;
919                    let h = numeric(h)?;
920                    if h <= 0.0 {
921                        return Err(GraphError::QueryError {
922                            detail: "decay() requires halflife > 0".into(),
923                        });
924                    }
925                    Ok(Some(Value::Float(b * 0.5f64.powf(a / h))))
926                }
927            }
928        }
929        _ => Err(GraphError::QueryError {
930            detail: format!(
931                "unknown function `{name}`; supported: toLower, toUpper, size, coalesce, type, abs, round, decay, key"
932            ),
933        }),
934    }
935}
936
937fn eval_set_return_item<F: Fs>(
938    db: &GraphDb<F>,
939    match_rs: &ResultSet,
940    row: usize,
941    rel_vars: &[String],
942    item: &RetItem,
943    params: &BTreeMap<String, Value>,
944) -> Result<Option<Value>> {
945    match &item.value {
946        RetVal::Var(v) => eval_set_return_operand(
947            db,
948            match_rs,
949            row,
950            rel_vars,
951            &Operand::Var(v.clone()),
952            params,
953        ),
954        RetVal::Prop { var, field } => eval_set_return_operand(
955            db,
956            match_rs,
957            row,
958            rel_vars,
959            &Operand::Prop {
960                var: var.clone(),
961                field: field.clone(),
962            },
963            params,
964        ),
965        RetVal::FuncCall { name, args } => {
966            eval_set_return_func(db, match_rs, row, rel_vars, name, args, params)
967        }
968        RetVal::ScalarExpr(op) => eval_set_return_operand(db, match_rs, row, rel_vars, op, params),
969        RetVal::Agg { .. } => Err(GraphError::QueryError {
970            detail: "aggregates are not supported in MATCH … SET … RETURN".into(),
971        }),
972    }
973}
974
975/// Project user RETURN from original MATCH rows after SET. No rematch.
976fn project_set_return_rows<F: Fs>(
977    db: &GraphDb<F>,
978    rel_vars: &[String],
979    match_rs: &ResultSet,
980    returns: &[RetItem],
981    params: &BTreeMap<String, Value>,
982) -> Result<ResultSet> {
983    let columns: Vec<String> = returns.iter().map(ret_column_name).collect();
984    let mut out = ResultSet::new(columns);
985    for row in 0..match_rs.len() {
986        let mut cells = Vec::with_capacity(returns.len());
987        for item in returns {
988            cells.push(eval_set_return_item(
989                db, match_rs, row, rel_vars, item, params,
990            )?);
991        }
992        out.push_row(cells);
993    }
994    Ok(out)
995}
996
997/// Single construction point for a `GraphMut` view over the split-borrowed graph fields.
998/// Callers use `std::mem::take` on the engine before calling this, then restore it after.
999/// Extract a `Vec<f64>` from a `Value::List` whose items are all numeric.
1000/// Returns `None` for non-list values or lists with non-numeric elements.
1001fn value_as_float_list(v: &Value) -> Option<Vec<f64>> {
1002    match v {
1003        Value::List(items) => items
1004            .iter()
1005            .map(|item| match item {
1006                Value::Float(f) => Some(*f),
1007                Value::Int(i) => Some(*i as f64),
1008                _ => None,
1009            })
1010            .collect(),
1011        _ => None,
1012    }
1013}
1014
1015fn make_graph_mut<'a>(
1016    ids: &'a IdMap,
1017    syms: &'a mut Interner,
1018    labels: &'a [u32],
1019    props: core_storage::v8::seam::ColumnsView<'a>,
1020    topo: &'a mut Topology,
1021    edge_props: &'a mut EdgeProps,
1022) -> GraphMut<'a> {
1023    GraphMut {
1024        ids,
1025        syms,
1026        labels,
1027        props,
1028        topo,
1029        edge_props,
1030    }
1031}
1032
1033/// Build a `ColumnsView` from the disjoint `props` overlay and optional V8 base.
1034///
1035/// Takes explicit field references rather than `&self` so the caller can hold
1036/// simultaneous mutable borrows of other fields (e.g. `syms`, `topo`).
1037fn build_props_view<'a>(
1038    props: &'a ColumnStore,
1039    base: &'a Option<std::sync::Arc<core_storage::v8::MappedBase>>,
1040) -> core_storage::v8::seam::ColumnsView<'a> {
1041    match base {
1042        None => core_storage::v8::seam::ColumnsView::owned(props),
1043        Some(b) => {
1044            let archived = b
1045                .columns()
1046                .expect("base columns section bounds validated at open");
1047            core_storage::v8::seam::ColumnsView::with_base(props, archived)
1048        }
1049    }
1050}
1051
1052fn build_topo_view<'a>(
1053    overlay: &'a Topology,
1054    base: &'a Option<std::sync::Arc<core_storage::v8::MappedBase>>,
1055) -> core_storage::v8::seam::TopologyView<'a> {
1056    match base {
1057        None => core_storage::v8::seam::TopologyView::owned(overlay),
1058        Some(b) => {
1059            let archived_csr = b
1060                .topology()
1061                .expect("base topology section bounds validated at open");
1062            core_storage::v8::seam::TopologyView::with_base(overlay, archived_csr)
1063        }
1064    }
1065}
1066
1067/// When [`GraphDb`] calls `Fs::sync` after a WAL append.
1068///
1069/// Default is [`Strict`](FsyncPolicy::Strict): every `log_then_apply_with`
1070/// fsyncs (single `insert_node` / `set_prop`). Ingest and `write_batch`
1071/// emit one `WalRecord::Batch` and fsync once at that frame (Batched).
1072/// [`Relaxed`](FsyncPolicy::Relaxed) skips WAL sync; [`GraphDb::snapshot`]
1073/// is still durable via `write_atomic`. Crash-recovery DST stays Strict.
1074#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
1075pub enum FsyncPolicy {
1076    /// Every WAL commit calls `fs.sync` (today's behavior).
1077    #[default]
1078    Strict,
1079    /// Sync only at a `Batch` frame end. Single-op path stays Strict unless
1080    /// this policy is set on the database.
1081    Batched,
1082    /// Never call `fs.sync`. [`GraphDb::snapshot`] still syncs via `write_atomic`.
1083    Relaxed,
1084}
1085
1086/// A precondition for a compare-and-set batch write.
1087///
1088/// All preconditions in a [`GraphDb::write_batch_cas`] or
1089/// [`crate::SharedDb::submit_batch_cas`] call are checked atomically before
1090/// any operation in the batch is applied.  If any precondition fails, the
1091/// entire batch is rejected with [`GraphError::CasConflict`] and no WAL frame
1092/// is written.
1093///
1094/// # Touch definition
1095///
1096/// A node's last-change commit (`last_changed`) is updated when any of the
1097/// following state-changing WAL records touch it:
1098///
1099/// - `InsertNode` / `InsertNodeId` — the newly-inserted node.
1100/// - `SetProp` / `SetPropId` / `RemoveProp` — the property-bearing node.
1101/// - `InsertEdge` / `InsertEdgeId` / `DeleteEdge` — **both** src and dst
1102///   endpoints (an edge change touches both sides).
1103/// - `DeleteNode` — the node is tombstoned; `last_changed` returns `None`
1104///   for deleted keys so the pre-deletion entry is never observed.
1105///
1106/// History markers (`DerivedEdgeAdded` / `DerivedEdgeRetracted`) are
1107/// state no-ops.  The underlying mutation that triggered rule firing already
1108/// updated the relevant nodes' last-change entries.  Rule-management records
1109/// (`CreateRule`, `DeleteRule`, `RebuildRule`) and view/full-text declarations
1110/// do not touch any node's last-change.
1111#[derive(Debug, Clone, PartialEq, Eq)]
1112pub enum Precondition {
1113    /// The node's last-change commit must equal `expected`.
1114    ///
1115    /// Fails with [`GraphError::CasConflict`] when:
1116    /// - The node does not exist (`last_changed` returns `None`), or
1117    /// - The recorded commit seq does not match `expected`.
1118    NodeUnchangedSince { key: String, expected: u64 },
1119    /// The node must not exist (not inserted, or already deleted).
1120    ///
1121    /// Fails with [`GraphError::CasConflict`] (expected=`u64::MAX`,
1122    /// actual=`last_changed(key).unwrap_or(0)`) when the node is live.
1123    NodeAbsent { key: String },
1124}
1125
1126pub struct GraphDb<F: Fs> {
1127    fs: F,
1128    ids: IdMap,
1129    syms: Interner,
1130    topo: Topology,
1131    props: ColumnStore,
1132    labels: Vec<u32>, // node id -> label symbol
1133    edge_props: EdgeProps,
1134    engine: RuleEngine,
1135    view_store: ViewStore,
1136    /// Incremental inverted index for full-text-lite search.
1137    /// Rebuild-on-open: populated from WAL replay + rebuild_all at open end.
1138    fulltext: FulltextIndex,
1139    /// Opt-in equality index over scalar node properties.
1140    /// Rebuild-on-open: declarations replay from the WAL, postings rebuild at
1141    /// open end (mirrors `fulltext`).
1142    prop_index: PropertyIndex,
1143    event_sink: Option<Box<dyn Fn(MutationEvent) + Send + Sync>>,
1144    /// WAL fsync cadence. Default [`FsyncPolicy::Strict`].
1145    fsync: FsyncPolicy,
1146    /// Monotonically increasing per-commit counter.  A single `log_then_apply_with`
1147    /// call increments this once; all events emitted from that call share the same
1148    /// `commit_seq` value.
1149    commit_seq: u64,
1150    /// RBAC role definitions loaded from `roles.json` at open.
1151    ///
1152    /// `Some(roles)` — loaded successfully (may be empty when no roles are defined).
1153    /// `None` — `roles.json` was present but corrupt; `mask_for_role` returns
1154    /// `Err` for any request (fail-loud, never silently grant empty visibility).
1155    roles: Option<Vec<RoleDef>>,
1156    /// Live subscriptions.  Entries with a dead `Weak` are pruned on the next
1157    /// distribute_events call.
1158    subscriptions: Vec<SubEntry>,
1159    /// Live query subscriptions. Re-executed on every commit when non-empty.
1160    /// Dead `Weak` entries are pruned inside `distribute_events`.
1161    query_subscriptions: Vec<QuerySubEntry>,
1162    /// Queue capacity for new subscriptions created by this db.  Default is
1163    /// [`DEFAULT_SUB_CAPACITY`]; can be overridden via [`set_sub_capacity`]
1164    /// to test Lagged behaviour with small queues.
1165    sub_capacity: usize,
1166    /// True for as-of instances opened via [`GraphDb::open_at`].
1167    /// Every mutation method and `snapshot()` returns [`GraphError::ReadOnly`]
1168    /// when this flag is set.
1169    read_only: bool,
1170    /// Total WAL commit count at the time [`open_at`] was called.
1171    /// 0 for normal (non-as-of) instances.
1172    total_wal_commits: u64,
1173    /// Immutable mmap-backed base snapshot (V8).  When `Some`, `self.topo` is
1174    /// the WAL-replay overlay (empty at open time, populated by apply()) and
1175    /// reads go through a merged `TopologyView`.  `self.props` is always
1176    /// fully materialized (base + WAL replay) for HNSW/IVF and view compat.
1177    base: Option<Arc<core_storage::v8::MappedBase>>,
1178    // ── MVCC epoch reader state ───────────────────────────────────────────────
1179    /// Most-recent full overlay clone.  Initialized at end of `open_with` /
1180    /// `open_at_with`; refreshed every `FOLD_EVERY_K` commits.
1181    /// `None` only between struct creation and the first fold.
1182    fold_overlay: Option<Arc<crate::reader::FrozenOverlay>>,
1183    /// Per-commit deltas accumulated since the last fold.
1184    delta_tail: Vec<Arc<crate::reader::CommitDelta>>,
1185    /// How many commits have occurred since the last fold.
1186    commits_since_fold: usize,
1187    /// When true, `log_then_apply_with` buffers event notifications instead of
1188    /// firing them immediately.  Used by the group-commit drain thread to defer
1189    /// events until after the group fsync (R2: durability before notification).
1190    /// Cleared to false once the drain thread flushes or discards the buffer.
1191    defer_events: bool,
1192    /// Buffered events accumulated while `defer_events` is true.
1193    deferred_events: Vec<DeferredEvent>,
1194    /// Set to true by the group-commit drain thread when a group fsync fails
1195    /// after WAL truncation.  All subsequent mutation attempts return an IO
1196    /// error until the database is reopened.
1197    degraded: bool,
1198    /// Set to `true` after `ensure_v8_base_sections_loaded` has read provenance,
1199    /// HNSW, and IVF sections from the mmap base into the engine's retained
1200    /// fields.  `false` on all opens until first use; always `true` for non-V8
1201    /// opens (base is None, fast-path sets flag immediately).
1202    v8_sections_loaded: std::sync::atomic::AtomicBool,
1203    /// Serializes the one-time section population in `ensure_v8_base_sections_loaded`.
1204    v8_sections_mutex: std::sync::Mutex<()>,
1205    /// Per-node last-change commit sequence.  `last_change[node_id] = seq` means
1206    /// the node was last modified by commit `seq`.
1207    ///
1208    /// Loaded from V8 section 11 at open; updated on every state-changing commit
1209    /// and WAL replay frame.  V5-V7 stores start with an empty map; pre-WAL-horizon
1210    /// nodes return `None` from `last_changed` until they are next mutated.
1211    ///
1212    /// See [`Precondition`] for the full touch definition.
1213    last_change: HashMap<u32, u64>,
1214    /// WAL archive retention policy set by [`set_wal_archive_retention`].
1215    /// `None` = unlimited (keep all archives); `Some(N)` = keep N newest archives,
1216    /// pruning older ones at snapshot time.  0 is treated as unlimited.
1217    wal_archive_retention: Option<u32>,
1218    /// Global frame index of the first commit that is still reachable through
1219    /// surviving archives.  Persisted to `wal.floor` sidecar when pruning occurs.
1220    /// Default 0 = all history reachable.
1221    wal_horizon_floor: u64,
1222    /// True when the surviving archive chain forms a continuous WAL history
1223    /// starting from the store's first commit (the genesis chain).
1224    ///
1225    /// `open_at` may replay archive-resident commits from empty state only when
1226    /// this flag is true AND `wal_horizon_floor == 0`.  Cleared whenever:
1227    ///   - a WAL-truncating snapshot (`keep_wal=false`) is taken after archives
1228    ///     already exist (breaks the chain for subsequent archives), or
1229    ///   - any archive is pruned (floor advances past zero).
1230    ///
1231    /// Persisted via the `wal.genesis` marker file; loaded from it at open.
1232    archive_genesis_chain: bool,
1233    /// Transient write-authz context set by `write_batch_authz` /
1234    /// `query_write_authz` for the duration of ONE mutation call.
1235    /// Always `None` at rest.  Never serialized, never WAL-replayed.
1236    pending_write_authz: Option<WriteAuthz>,
1237    /// Slow-query threshold in milliseconds.  0 = disabled.
1238    /// Seeded from `MUSHROOMDB_SLOW_QUERY_MS` at open; override via
1239    /// [`GraphDb::set_slow_query_threshold_ms`] (tests must use the setter
1240    /// — env vars are process-global and race parallel test threads).
1241    slow_query_threshold_ms: u64,
1242    /// Ring buffer of recent slow queries (interior-mutable so `query(&self)`
1243    /// can record entries without requiring `&mut self`).
1244    slow_queries: std::sync::Mutex<SlowQueryLog>,
1245    /// Instant at which the database was opened (used by `/metrics` uptime).
1246    started_at: std::time::Instant,
1247}
1248
1249/// One group of deferred event notifications, held until the group fsync
1250/// completes.  Replayed by [`GraphDb::flush_deferred_events`].
1251struct DeferredEvent {
1252    rec: core_storage::WalRecord,
1253    engine_deltas: Vec<EngineEdgeDelta>,
1254    seq: u64,
1255    ingest: Option<(String, usize)>,
1256}
1257
1258/// Options for [`GraphDb::open_with_options`].
1259#[derive(Clone, Copy, Debug)]
1260pub struct OpenOptions {
1261    /// Rewrite an old-format snapshot to the current VERSION after a
1262    /// successful load (default `true`). The old snapshot is kept as
1263    /// `snapshot.bin.bak` until the next clean open at the current version,
1264    /// at which point the `.bak` is deleted.
1265    ///
1266    /// Set to `false` to open a store without touching any on-disk files
1267    /// (useful for read-only inspection of a store at an older format).
1268    pub auto_migrate: bool,
1269
1270    /// Write the valid WAL prefix back over a torn tail on open (default
1271    /// `true`). Truncating a genuinely torn tail is correct crash recovery.
1272    ///
1273    /// Set to `false` for an unattended reader. The valid prefix is still
1274    /// decoded and replayed in memory, but nothing is written: the store has
1275    /// no cross-process lock, so a reader that opens while another process is
1276    /// mid-append would otherwise discard a frame that writer believes
1277    /// durable. `mushroomdb recall`, which runs on every prompt, passes
1278    /// `false` for exactly this reason.
1279    pub repair_wal: bool,
1280}
1281
1282impl Default for OpenOptions {
1283    fn default() -> Self {
1284        Self {
1285            auto_migrate: true,
1286            repair_wal: true,
1287        }
1288    }
1289}
1290
1291/// Authorization context carried by `write_batch_authz` / `query_write_authz`.
1292///
1293/// `None` at the call site = full authority (today's zero-cost behavior).
1294/// `Some(WriteAuthz)` = role-scoped: the decision table (plan §"authz decision
1295/// table") is evaluated per-op inside `commit_logged_batch` BEFORE any WAL
1296/// record is built.  A denial returns an error with no WAL frame written.
1297///
1298/// The mask is ALWAYS `Omit`-mode: role-token paths must never acknowledge
1299/// hidden-node existence to callers.
1300#[derive(Clone, Debug)]
1301pub struct WriteAuthz {
1302    pub role: String,
1303    pub scope: WriteScope,
1304    /// Resolved by `mask_for_role` under the same write guard as the mutation.
1305    /// Always `Omit`-mode — never `Stub`.
1306    pub mask: crate::mask::NodeMask,
1307}
1308
1309/// Write `bytes` to `snapshot.bin.bak` atomically with full fsync.
1310///
1311/// Uses [`RealFs::write_atomic`] which applies `F_FULLFSYNC` on macOS and
1312/// `sync_all` on other platforms, then renames the `.tmp` file into place and
1313/// syncs the directory entry. This is the only correct path for writing the
1314/// `.bak` — plain `std::fs::write + sync_all` misses both `F_FULLFSYNC` and
1315/// the directory sync.
1316pub fn write_snapshot_bak(dir: &std::path::Path, bytes: &[u8]) -> crate::Result<()> {
1317    use core_storage::fs::{FileId, Fs as _};
1318    RealFs::new(dir)
1319        .map_err(core_storage::GraphError::Io)?
1320        .write_atomic(FileId::SnapshotBak, bytes)
1321        .map_err(core_storage::GraphError::Io)
1322}
1323
1324/// Return the on-disk snapshot format version without decoding the full snapshot.
1325///
1326/// Reads only the 6-byte header (magic + version LE). Returns `None` when no
1327/// snapshot file exists (WAL-only store). Returns an error if the header is
1328/// malformed.
1329pub fn snapshot_version_at(dir: &std::path::Path) -> crate::Result<Option<u16>> {
1330    use std::io::Read as _;
1331    let path = dir.join("snapshot.bin");
1332    let mut header = [0u8; 6];
1333    let n = match std::fs::File::open(&path) {
1334        Ok(mut f) => f.read(&mut header).map_err(core_storage::GraphError::Io)?,
1335        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
1336        Err(e) => return Err(core_storage::GraphError::Io(e)),
1337    };
1338    core_storage::snapshot::peek_version(&header[..n])
1339}
1340
1341/// Options for [`GraphDb::snapshot_with`].
1342#[derive(Debug, Clone, Default)]
1343pub struct SnapshotOptions {
1344    /// When `true`, the WAL is preserved after the snapshot write.
1345    /// Pre-snapshot commits remain reachable via [`GraphDb::open_at`].
1346    /// When `false` (the default), the WAL is truncated to a minimal
1347    /// baseline so cold-start replay stays fast.
1348    pub keep_wal: bool,
1349    /// When `true`, the current WAL is renamed to `wal.<commit_seq>.archive`
1350    /// before a fresh WAL baseline is written (history-preserving snapshot).
1351    ///
1352    /// This is the feature opt-in: `false` (the default) leaves the existing
1353    /// truncation / keep-wal behaviour byte-identical.  `archive_wal` takes
1354    /// precedence over `keep_wal` when both are set.
1355    ///
1356    /// Archives can be scanned by [`GraphDb::node_history`],
1357    /// [`GraphDb::edge_history`], [`GraphDb::was_linked`], and
1358    /// [`GraphDb::open_at`], extending the reachable history horizon across
1359    /// snapshot boundaries.
1360    pub archive_wal: bool,
1361}
1362
1363/// Derive the scan-label sym for the commit-skip fast-path.
1364///
1365/// Walks `ops` to find the plan's leading scan op (`ScanLabel`, `IndexScan`,
1366/// or `IndexIntersect`) with a concrete label string, then interns it.
1367///
1368/// Returns `None` in all cases where skipping is unsafe:
1369/// - Any `Expand` op is present (edge traversal; edges change results regardless
1370///   of node labels).
1371/// - The leading scan has no label (`ScanLabel { label: None }` — full scan).
1372/// - No recognizable leading scan op is found.
1373///
1374/// This is the conservative v0.4.3 boundary. The caller stores the result in
1375/// [`QuerySubEntry::scan_label`] at subscribe time; `None` means always execute.
1376fn extract_scan_label(ops: &[PlanOp], syms: &mut Interner) -> Option<u32> {
1377    // Any Expand → must always re-execute (edges can change join results).
1378    if ops.iter().any(|op| matches!(op, PlanOp::Expand { .. })) {
1379        return None;
1380    }
1381    for op in ops {
1382        match op {
1383            PlanOp::ScanLabel {
1384                label: Some(label), ..
1385            } => return Some(syms.intern(label)),
1386            PlanOp::IndexScan {
1387                label: Some(label), ..
1388            } => return Some(syms.intern(label)),
1389            PlanOp::IndexIntersect {
1390                label: Some(label), ..
1391            } => return Some(syms.intern(label)),
1392            _ => {}
1393        }
1394    }
1395    None
1396}
1397
1398impl GraphDb<RealFs> {
1399    /// Open the database at `dir` with default options.
1400    ///
1401    /// Equivalent to `open_with_options(dir, OpenOptions::default())`.
1402    /// Old-format snapshots (V5, V6) are automatically migrated to the
1403    /// current version on a successful load (see [`OpenOptions::auto_migrate`]).
1404    pub fn open(dir: &std::path::Path) -> Result<Self> {
1405        Self::open_with_options(dir, OpenOptions::default())
1406    }
1407
1408    /// Open the database at `dir` with explicit options.
1409    ///
1410    /// When `opts.auto_migrate` is `true` (the default) and the on-disk
1411    /// snapshot is an older format version, this function:
1412    ///   1. Copies the current `snapshot.bin` to `snapshot.bin.bak` (atomic
1413    ///      + fsynced) before any modification.
1414    ///   2. Rewrites `snapshot.bin` at the current format version via
1415    ///      [`GraphDb::snapshot_with`] with `keep_wal: true` (WAL preserved).
1416    ///
1417    /// If migration fails the error is returned and the original files are
1418    /// intact (the `.bak` was written before the new snapshot was attempted).
1419    ///
1420    /// A clean open that finds the snapshot already at the current version
1421    /// deletes any leftover `.bak` file.
1422    ///
1423    /// WAL-only stores (no snapshot) are never auto-migrated on open.
1424    ///
1425    /// `opts.repair_wal` controls the other write this function can make; see
1426    /// [`OpenOptions::repair_wal`]. With both flags `false` the open touches
1427    /// no file on disk.
1428    pub fn open_with_options(dir: &std::path::Path, opts: OpenOptions) -> Result<Self> {
1429        // Header-only peek — 6 bytes, no full decode.
1430        let snap_version = snapshot_version_at(dir)?;
1431
1432        // Full load: decode snapshot + replay WAL + rebuild indexes.
1433        let mut db = Self::open_with_repair(RealFs::new(dir)?, opts.repair_wal)?;
1434
1435        if opts.auto_migrate {
1436            match snap_version {
1437                Some(ver) if ver < core_storage::snapshot::VERSION => {
1438                    let _tm = std::time::Instant::now();
1439                    // Copy the original snapshot to .bak at OS level — no in-memory
1440                    // buffer required for a 2+ GiB file.
1441                    //
1442                    // Crash-safety: snapshot.bin remains intact (write_atomic inside
1443                    // snapshot_with uses a .tmp+rename) until the V8 write succeeds.
1444                    // A torn .bak on crash is acceptable because the original
1445                    // snapshot.bin is the authoritative source until after the rename.
1446                    std::fs::copy(dir.join("snapshot.bin"), dir.join("snapshot.bin.bak"))
1447                        .map_err(core_storage::GraphError::Io)?;
1448                    trace_migrate!("bak copy done", _tm);
1449                    // Rewrite snapshot at current version; keep WAL intact.
1450                    db.snapshot_with(SnapshotOptions {
1451                        keep_wal: true,
1452                        ..SnapshotOptions::default()
1453                    })?;
1454                    trace_migrate!("snapshot_with done", _tm);
1455                }
1456                Some(_) => {
1457                    // Already current version: remove any leftover .bak.
1458                    let bak = dir.join("snapshot.bin.bak");
1459                    if bak.exists() {
1460                        std::fs::remove_file(&bak).map_err(core_storage::GraphError::Io)?;
1461                    }
1462                }
1463                None => {
1464                    // WAL-only store — nothing to migrate on open.
1465                }
1466            }
1467        }
1468
1469        Ok(db)
1470    }
1471
1472    /// Open a read-only view of the database as it existed after `commit`.
1473    ///
1474    /// Commit indices are 0-based over the current WAL: commit 0 is the state
1475    /// after the first WAL frame, commit N-1 is the state after the N-th (most
1476    /// recent) frame.  Call [`GraphDb::open`] to read the full current state.
1477    ///
1478    /// **Replay base.** [`GraphDb::snapshot`] truncates the WAL when it runs,
1479    /// so as-of can only reach commits recorded in the current WAL (those
1480    /// written after the most recent snapshot, or all commits if no snapshot
1481    /// was ever taken).  Commit 0 in `open_at` always refers to the first
1482    /// frame in the WAL that exists on disk, not the first ever write to the
1483    /// database.  When the on-disk snapshot recorded that it truncated the
1484    /// WAL (V7, default `keep_wal: false`), it is loaded as the base state
1485    /// before frame replay, so the as-of view includes all pre-snapshot data.
1486    /// Snapshots written with `keep_wal: true` (and legacy V5/V6 snapshots)
1487    /// are ignored and replay is WAL-only, as before.
1488    ///
1489    /// **Read-only.** Every mutation method and `snapshot()` on the returned
1490    /// instance returns [`GraphError::ReadOnly`].  Queries, `explain()`, and
1491    /// `stats()` work normally.
1492    ///
1493    /// # Errors
1494    /// - [`GraphError::CommitOutOfRange`] if `commit >= wal_commit_count` (including
1495    ///   when the WAL is empty after a snapshot).
1496    pub fn open_at(dir: &std::path::Path, commit: u64) -> Result<Self> {
1497        Self::open_at_with(RealFs::new(dir)?, commit)
1498    }
1499
1500    /// Run a **read-only** Cypher query against the graph as it existed at
1501    /// `commit` — the "time-travel" / agent-replay query. Opens a temporal view
1502    /// of this store's directory at that commit and executes the read there.
1503    ///
1504    /// The current instance is unaffected. Write statements are rejected (the
1505    /// temporal view is read-only). `commit` is a 0-based WAL commit index;
1506    /// `commit == wal_commit_count` (or `open_at`'s range) yields the newest
1507    /// state. Prefer this over holding many historical instances open.
1508    ///
1509    /// # Errors
1510    /// - [`GraphError::CommitOutOfRange`] if `commit` is past the WAL horizon.
1511    /// - A query error for a malformed or write query.
1512    pub fn query_at(
1513        &self,
1514        commit: u64,
1515        cypher: &str,
1516        params: &std::collections::BTreeMap<String, Value>,
1517    ) -> Result<ResultSet> {
1518        let dir = self.fs.dir().to_path_buf();
1519        let temporal = Self::open_at(&dir, commit)?;
1520        if is_write_tokens(&lex(cypher).map_err(|e| GraphError::QueryError {
1521            detail: format!("lex: {e}"),
1522        })?) {
1523            return Err(GraphError::QueryError {
1524                detail: "query_at is read-only: write statements are not permitted in a \
1525                         time-travel query"
1526                    .into(),
1527            });
1528        }
1529        temporal.query(cypher, params)
1530    }
1531}
1532
1533impl<F: Fs> GraphDb<F> {
1534    /// Open over an arbitrary [`Fs`], repairing a torn WAL tail as usual.
1535    pub fn open_with(fs: F) -> Result<Self> {
1536        Self::open_with_repair(fs, true)
1537    }
1538
1539    /// As [`GraphDb::open_with`], but `repair_wal: false` decodes the valid WAL
1540    /// prefix without writing the truncation back. See
1541    /// [`OpenOptions::repair_wal`].
1542    pub fn open_with_repair(fs: F, repair_wal: bool) -> Result<Self> {
1543        let mut db = Self {
1544            fs,
1545            ids: IdMap::new(),
1546            syms: Interner::new(),
1547            topo: Topology::new(),
1548            props: ColumnStore::new(),
1549            labels: Vec::new(),
1550            edge_props: EdgeProps::new(),
1551            engine: RuleEngine::new(),
1552            view_store: ViewStore::new(),
1553            fulltext: FulltextIndex::new(),
1554            prop_index: PropertyIndex::new(),
1555            event_sink: None,
1556            fsync: FsyncPolicy::Strict,
1557            commit_seq: 0,
1558            roles: Some(vec![]),
1559            subscriptions: Vec::new(),
1560            query_subscriptions: Vec::new(),
1561            sub_capacity: DEFAULT_SUB_CAPACITY,
1562            read_only: false,
1563            total_wal_commits: 0,
1564            base: None,
1565            fold_overlay: None,
1566            delta_tail: Vec::new(),
1567            commits_since_fold: 0,
1568            defer_events: false,
1569            deferred_events: Vec::new(),
1570            degraded: false,
1571            v8_sections_loaded: std::sync::atomic::AtomicBool::new(false),
1572            v8_sections_mutex: std::sync::Mutex::new(()),
1573            last_change: HashMap::new(),
1574            wal_archive_retention: None,
1575            wal_horizon_floor: 0,
1576            archive_genesis_chain: false,
1577            pending_write_authz: None,
1578            slow_query_threshold_ms: std::env::var("MUSHROOMDB_SLOW_QUERY_MS")
1579                .ok()
1580                .and_then(|v| v.parse().ok())
1581                .unwrap_or(100),
1582            slow_queries: std::sync::Mutex::new(SlowQueryLog {
1583                entries: std::collections::VecDeque::new(),
1584                total: 0,
1585            }),
1586            started_at: std::time::Instant::now(),
1587        };
1588        db.wal_horizon_floor = db.fs.read_horizon_floor()?;
1589        db.archive_genesis_chain = db.fs.has_genesis_marker();
1590        // Opening cleanup: remove orphaned archives — archives whose frames all
1591        // fall below the horizon floor.  Orphans arise when a crash interrupted
1592        // the retention-prune sequence after the floor was written but before
1593        // all surplus archives were deleted.  Safe to delete: floor already
1594        // accounts for their frames.
1595        db.cleanup_orphaned_archives()?;
1596        let _t0 = std::time::Instant::now();
1597        // Peek 6 bytes to determine snapshot version without reading the full
1598        // file. For RealFs this is a true partial read (O(1)); for SimFs the
1599        // default impl reads all bytes and truncates (still correct).
1600        let snap_header = db.fs.read_prefix(FileId::Snapshot, 6)?;
1601        let is_v8 = snap_header.len() >= 6
1602            && &snap_header[0..4] == b"GDB1"
1603            && u16::from_le_bytes([snap_header[4], snap_header[5]])
1604                == core_storage::snapshot::VERSION_8;
1605        if is_v8 {
1606            // V8: map the file zero-copy (RealFs) or read full bytes (SimFs).
1607            // No 2.4GB heap Vec is allocated on RealFs.
1608            let mapped = Arc::new(
1609                if let Some(snap_path) = db.fs.snapshot_path() {
1610                    core_storage::v8::MappedBase::map(&snap_path)
1611                } else {
1612                    let snap_bytes = db.fs.read(FileId::Snapshot)?;
1613                    core_storage::v8::MappedBase::from_bytes(snap_bytes)
1614                }
1615                .map_err(|e| GraphError::Corrupt {
1616                    detail: format!("v8: mmap open: {e:?}"),
1617                })?,
1618            );
1619            db.restore_v8_base(Arc::clone(&mapped))?;
1620            trace_open!("restore_v8_base", _t0);
1621            db.base = Some(mapped);
1622            trace_open!("base assigned", _t0);
1623        } else if !snap_header.is_empty() {
1624            // Legacy V5-V7: full read required for decode.
1625            let snap_bytes = db.fs.read(FileId::Snapshot)?;
1626            if let Some(state) = core_storage::snapshot::decode(&snap_bytes)? {
1627                db.restore_snapshot_state(state)?;
1628            }
1629        }
1630        // else: snap_header is empty = no snapshot file, fresh store.
1631        //
1632        // Seed commit_seq from the highest seq persisted in last_change so that
1633        // WAL-replay frames (which start at commit_seq+1) always exceed any seq
1634        // already stored in the snapshot.  Without this, a db with one snapshot
1635        // commit would save last_change["a"]=1, then on reopen the first WAL
1636        // frame would replay at seq=1 again — colliding and making WAL-tail
1637        // mutations indistinguishable from the snapshot baseline.
1638        //
1639        // Safety invariant (seq-recycling):
1640        //   Recycled seqs (those below the seeded baseline) were NEVER stored in
1641        //   last_change because they belonged to a previous db lifetime — a new
1642        //   db starts at commit_seq=0 with an empty last_change.  Therefore no
1643        //   CAS precondition can carry a recycled seq as its `expected` value
1644        //   and accidentally match a live node's last_change entry.
1645        //
1646        // `expected:0` on a deleted-then-reinserted node:
1647        //   After deletion, last_changed() returns None; callers that call
1648        //   last_changed() and then use NodeUnchangedSince get None.unwrap_or(0)
1649        //   = 0.  The reinserted node gets seq > 0, so a subsequent CAS with
1650        //   expected=0 correctly conflicts.  The only way to observe actual=0 in
1651        //   a CasConflict would be a caller that invented expected=0 without ever
1652        //   calling last_changed() — unreachable via the documented API contract.
1653        if let Some(&max_seq) = db.last_change.values().max() {
1654            db.commit_seq = db.commit_seq.max(max_seq);
1655        }
1656        let bytes = db.fs.read(FileId::Wal)?;
1657        let (records, valid_len) = decode_all(&bytes);
1658        // The valid prefix is replayed either way; `repair_wal` only decides
1659        // whether the truncation is written back. A reader that races a live
1660        // appender must not persist a truncation the writer never asked for.
1661        if valid_len < bytes.len() && repair_wal {
1662            db.fs.write_atomic(FileId::Wal, &bytes[..valid_len])?;
1663        }
1664        // WAL-present path: build indexes eagerly BEFORE replay so that the
1665        // first replayed record does not trigger the lazy-init guard (which
1666        // would call reindex_all_load_ivf on an empty graph, defeating the
1667        // point of restoring IVF/HNSW blobs from the snapshot).
1668        if !records.is_empty() {
1669            db.ensure_v8_base_sections_loaded();
1670            trace_open!("lazy sections loaded (WAL path)", _t0);
1671            db.engine.consume_retained_state_eager(
1672                &db.ids,
1673                &db.syms,
1674                &db.labels,
1675                build_props_view(&db.props, &db.base),
1676            );
1677        }
1678        for rec in records {
1679            db.apply(&rec)?;
1680            // Drain per-frame to keep pending_deltas O(1) during replay (I-2).
1681            // No subscriber exists yet; discard is correct.
1682            let _ = db.engine.drain_deltas();
1683            // Track commit_seq during replay so last_change entries are
1684            // consistent with the seqs assigned by log_then_apply_with on
1685            // subsequent live commits.  After N replayed frames, commit_seq=N;
1686            // live commits begin at N+1.
1687            db.commit_seq += 1;
1688            let replay_seq = db.commit_seq;
1689            db.update_last_change_from_rec(&rec, replay_seq);
1690        }
1691        // Enforce I-2: if the per-frame drain above is ever removed or skipped,
1692        // this assert catches the regression in debug builds immediately.
1693        debug_assert_eq!(
1694            db.engine.pending_delta_count(),
1695            0,
1696            "pending_deltas non-empty after replay — \
1697             per-frame drain must run inside the loop to keep memory O(1)"
1698        );
1699        // T2 note: the per-frame drain IS the suppression seam for replay.
1700        // Any future as-of replay path (Plan-15 T2) must drain here to feed
1701        // replaying subscribers; the mechanism is already in place.
1702        let _ = db.engine.drain_deltas(); // belt-and-braces no-op after loop drain
1703        trace_open!("wal replay done", _t0);
1704        // Rebuild view values after WAL replay only when there is no V8 base.
1705        // With a V8 base, view values are correct in the snapshot and are updated
1706        // incrementally during WAL replay (on_edge_changed / on_prop_changed).
1707        // A full rebuild would read overlay-only props (empty after restore_v8_base)
1708        // and overwrite correct base values with wrong results (e.g. NeighborAgg
1709        // Sum reads no "score" in overlay → writes 0.0, shadowing the correct
1710        // base value).
1711        if db.base.is_none() {
1712            let topo_view = TopologyView::owned(&db.topo);
1713            db.view_store
1714                .rebuild_all(&mut db.props, &topo_view, &db.ids, &db.syms, &db.labels);
1715        }
1716        // Rebuild full-text index after WAL replay.  Corrects drift from
1717        // per-record incremental apply during replay.
1718        db.fulltext.rebuild_all(
1719            &db.ids,
1720            &db.labels,
1721            &db.syms,
1722            build_props_view(&db.props, &db.base),
1723        );
1724        db.prop_index.rebuild_all(
1725            &db.ids,
1726            &db.labels,
1727            &db.syms,
1728            build_props_view(&db.props, &db.base),
1729        );
1730        // Load roles sidecar. Missing file = no roles (Some(vec![])).
1731        // Corrupt/unparseable = poisoned (None); mask_for_role will fail-loud.
1732        db.roles = Self::load_roles_from_fs(&db.fs)?;
1733        // Capture the initial MVCC fold so reader() is ready immediately.
1734        db.fold_now();
1735        trace_open!("open_with complete", _t0);
1736        Ok(db)
1737    }
1738
1739    /// As-of replay for [`GraphDb::open_at`]: snapshot base (only when the
1740    /// snapshot truncated the WAL) plus the first `commit + 1` WAL frames;
1741    /// see [`GraphDb::open_at`] for the semantics.  The per-frame drain
1742    /// mirrors `open_with` exactly so pending_delta_count is 0 on exit.
1743    /// Restore all persisted state from a decoded snapshot. Shared by
1744    /// `open_with` and (when the snapshot truncated the WAL) `open_at_with`.
1745    fn restore_snapshot_state(
1746        &mut self,
1747        state: core_storage::snapshot::SnapshotState,
1748    ) -> Result<()> {
1749        self.ids = state.ids;
1750        self.syms = state.syms;
1751        self.topo = state.topo;
1752        self.props = state.props;
1753        self.labels = state.labels;
1754        self.edge_props = state.edge_props;
1755        // Cross-section label integrity for V5/V7 snapshots: same invariants as
1756        // restore_v8_base.  A crafted bincode snapshot with a short `labels` vec,
1757        // out-of-range sym ids, or a sentinel label on a live node would otherwise
1758        // open successfully and panic later in `NodeRef::label()` or
1759        // `neighborhood_masked()`.  Catching it here turns those into typed
1760        // `GraphError::Corrupt` at open time.
1761        {
1762            let ids_len = self.ids.len();
1763            if self.labels.len() != ids_len {
1764                return Err(GraphError::Corrupt {
1765                    detail: format!(
1766                        "snapshot: labels vec has {} entries but id table has {} total slots",
1767                        self.labels.len(),
1768                        ids_len,
1769                    ),
1770                });
1771            }
1772            let syms_len = self.syms.len() as u32;
1773            for (i, &sym) in self.labels.iter().enumerate() {
1774                let is_tombstoned = self.ids.is_tombstoned(i as u32);
1775                if sym == u32::MAX {
1776                    if !is_tombstoned {
1777                        return Err(GraphError::Corrupt {
1778                            detail: format!(
1779                                "snapshot: live node at id slot {i} has sentinel label (u32::MAX)"
1780                            ),
1781                        });
1782                    }
1783                } else if sym >= syms_len {
1784                    return Err(GraphError::Corrupt {
1785                        detail: format!(
1786                            "snapshot: label at id slot {i} references sym {sym} \
1787                             which is out of interner range ({syms_len})"
1788                        ),
1789                    });
1790                }
1791            }
1792        }
1793        let defs: Vec<RuleDef> = state
1794            .rule_defs
1795            .iter()
1796            .map(|b| {
1797                decode_rule_def(b).map_err(|e| GraphError::Corrupt {
1798                    detail: format!("snapshot rule_def deserialize: {e}"),
1799                })
1800            })
1801            .collect::<Result<Vec<_>>>()?;
1802        self.engine =
1803            RuleEngine::from_persist(defs, state.provenance, state.rule_tripped, state.rule_fires);
1804        // Candidate indexes are rebuilt lazily on the first mutation (see
1805        // RuleEngine::on_node_changed).  HNSW blobs and IVF centroids from the
1806        // snapshot are retained without deserializing so that:
1807        //   - clean-open (empty WAL): indexes stay empty; blobs load on first
1808        //     ANN query via ensure_hnsw_loaded, or on first mutation via the
1809        //     lazy-init guard which calls reindex_all_load_ivf + load_hnsw_state.
1810        //   - WAL-present: open_with calls consume_retained_state_eager before
1811        //     replay so HNSW/IVF are live before any record fires the hooks.
1812        let ivf_bytes = if state.ivf_state.is_empty() {
1813            Vec::new()
1814        } else {
1815            bincode::serialize(&state.ivf_state).expect("IVF state serialize cannot fail")
1816        };
1817        // Store blobs without eagerly deserializing them.
1818        self.engine
1819            .store_snapshot_state(state.hnsw_state, ivf_bytes);
1820        // Restore view defs from snapshot (V5).
1821        // The ColumnStore already contains view values from the snapshot;
1822        // use restore_view (no collision check, no backfill) so the store
1823        // is aware of the definitions.  rebuild_all runs after WAL replay.
1824        for def_bytes in &state.view_defs {
1825            let def: ViewDef =
1826                bincode::deserialize(def_bytes).map_err(|e| GraphError::Corrupt {
1827                    detail: format!("snapshot view_def deserialize: {e}"),
1828                })?;
1829            self.view_store
1830                .restore_view(def)
1831                .map_err(|e| GraphError::Corrupt {
1832                    detail: format!("snapshot view restore: {e}"),
1833                })?;
1834        }
1835        Ok(())
1836    }
1837
1838    /// Restore all persisted state from a V8 `MappedBase` snapshot, **except**
1839    /// topology (`self.topo` stays empty and serves as the WAL-replay overlay).
1840    ///
1841    /// `self.props` IS fully materialised from the base so that HNSW/IVF blob
1842    /// deserialization and view rebuild have access to all column data.
1843    fn restore_v8_base(&mut self, mapped: Arc<core_storage::v8::MappedBase>) -> Result<()> {
1844        self.ids = archived_to_idmap(mapped.ids().map_err(|e| GraphError::Corrupt {
1845            detail: format!("v8: ids section: {e:?}"),
1846        })?);
1847        self.syms = archived_to_interner(mapped.syms().map_err(|e| GraphError::Corrupt {
1848            detail: format!("v8: syms section: {e:?}"),
1849        })?);
1850
1851        // C1: self.props is left as an empty overlay. Column reads go through
1852        // props_view() (ColumnsView::with_base), which consults the archived base
1853        // section zero-copy. This avoids the O(columns) heap copy at every open.
1854
1855        // self.topo deliberately left as Topology::new() — overlay path.
1856
1857        let meta = decode_meta(mapped.meta_bytes().map_err(|e| GraphError::Corrupt {
1858            detail: format!("v8: meta section: {e:?}"),
1859        })?)
1860        .map_err(|e| GraphError::Corrupt {
1861            detail: format!("v8: meta decode: {e:?}"),
1862        })?;
1863        self.labels = meta.labels;
1864        // Cross-section label integrity: labels must cover every id slot (live
1865        // and tombstoned), every non-sentinel sym must be within the interner's
1866        // bound, and no live (non-tombstoned) node may carry the u32::MAX
1867        // sentinel label.  Without this check, a crafted snapshot where the META
1868        // section (small, CRC-validated) holds a short `labels` vec, out-of-range
1869        // sym ids, or a sentinel label on a live node, would open successfully
1870        // and then panic in `NodeRef::label()`, `neighborhood_masked()`, and
1871        // related read paths.  Catching the inconsistency here converts those
1872        // panics into typed `GraphError::Corrupt` at open time.
1873        {
1874            let ids_len = self.ids.len();
1875            if self.labels.len() != ids_len {
1876                return Err(GraphError::Corrupt {
1877                    detail: format!(
1878                        "v8: labels section has {} entries but id table has {} total slots",
1879                        self.labels.len(),
1880                        ids_len,
1881                    ),
1882                });
1883            }
1884            let syms_len = self.syms.len() as u32;
1885            for (i, &sym) in self.labels.iter().enumerate() {
1886                let is_tombstoned = self.ids.is_tombstoned(i as u32);
1887                if sym == u32::MAX {
1888                    // Sentinel is only valid for tombstoned slots.
1889                    if !is_tombstoned {
1890                        return Err(GraphError::Corrupt {
1891                            detail: format!(
1892                                "v8: live node at id slot {i} has sentinel label (u32::MAX)"
1893                            ),
1894                        });
1895                    }
1896                } else if sym >= syms_len {
1897                    return Err(GraphError::Corrupt {
1898                        detail: format!(
1899                            "v8: label at id slot {i} references sym {sym} \
1900                             which is out of interner range ({syms_len})"
1901                        ),
1902                    });
1903                }
1904            }
1905        }
1906        // C3: self.edge_props stays as an empty overlay.  Reads go through
1907        // edge_props_view() which consults the mmap'd base section zero-copy
1908        // via EdgePropsView::with_base.  No heap decode at open time.
1909
1910        // Restore rule engine.
1911        let (rule_def_bytes, rule_tripped, rule_fires) =
1912            archived_rules_meta_to_owned(mapped.rules_meta_section().map_err(|e| {
1913                GraphError::Corrupt {
1914                    detail: format!("v8: rules_meta section: {e:?}"),
1915                }
1916            })?);
1917        let defs: Vec<RuleDef> = rule_def_bytes
1918            .iter()
1919            .map(|b| {
1920                decode_rule_def(b).map_err(|e| GraphError::Corrupt {
1921                    detail: format!("v8: rule_def deserialize: {e}"),
1922                })
1923            })
1924            .collect::<Result<Vec<_>>>()?;
1925        self.engine = RuleEngine::from_persist(defs, BTreeMap::new(), rule_tripped, rule_fires);
1926        // C4+C5: provenance, HNSW, and IVF sections are NOT read here.
1927        // `ensure_v8_base_sections_loaded` reads them on first use from
1928        // `self.base` (set by the caller immediately after this returns).
1929        // A clean open touches only: header + IDS + SYMS + META + RULES_META.
1930
1931        // Restore view definitions.
1932        let view_defs =
1933            archived_views_to_owned(mapped.views_section().map_err(|e| GraphError::Corrupt {
1934                detail: format!("v8: views section: {e:?}"),
1935            })?);
1936        for def_bytes in &view_defs {
1937            let def: ViewDef =
1938                bincode::deserialize(def_bytes).map_err(|e| GraphError::Corrupt {
1939                    detail: format!("v8: view_def deserialize: {e}"),
1940                })?;
1941            self.view_store
1942                .restore_view(def)
1943                .map_err(|e| GraphError::Corrupt {
1944                    detail: format!("v8: view restore: {e}"),
1945                })?;
1946        }
1947        // Load the last-change map from section 11 (small section; load eagerly).
1948        // Pre-Task-3 snapshots lack this section; `last_change_bytes` returns &[]
1949        // in that case and `decode_last_change_bytes` returns an empty map.
1950        let last_change_raw = mapped
1951            .last_change_bytes()
1952            .map_err(|e| GraphError::Corrupt {
1953                detail: format!("v8: last_change section: {e:?}"),
1954            })?;
1955        self.last_change = decode_last_change_bytes(last_change_raw);
1956
1957        // Validate that all deferred sections (provenance, HNSW, IVF) fit within
1958        // the file.  Pure bounds check — no bytes read, no page faults triggered.
1959        // Catches truncated snapshots at open time before the lazy deferred reads.
1960        mapped.validate_section_bounds().map_err(|e| match e {
1961            GraphError::Corrupt { detail } => GraphError::Corrupt {
1962                detail: format!("v8: section bounds: {detail}"),
1963            },
1964            other => other,
1965        })?;
1966        Ok(())
1967    }
1968
1969    /// Read provenance, HNSW, and IVF sections from the mmap base into the
1970    /// engine's retained fields on first call.  Subsequent calls are a no-op
1971    /// (AtomicBool fast-path).
1972    ///
1973    /// Must be called before any code path that reads or mutates engine
1974    /// provenance, HNSW, or IVF state:
1975    /// - WAL replay (before `consume_retained_state_eager`)
1976    /// - First mutation (`log_then_apply_with`)
1977    /// - Read-only paths (`stats`, `explain`, `node_edges`)
1978    /// - Snapshot (`snapshot_with`)
1979    ///
1980    /// No-op for fresh stores and V5-V7 opens (`self.base` is `None`).
1981    fn ensure_v8_base_sections_loaded(&self) {
1982        use std::sync::atomic::Ordering;
1983        if self.v8_sections_loaded.load(Ordering::Acquire) {
1984            return;
1985        }
1986        let _guard = self
1987            .v8_sections_mutex
1988            .lock()
1989            .expect("v8 sections mutex poisoned");
1990        if self.v8_sections_loaded.load(Ordering::Acquire) {
1991            return; // another caller populated while we waited
1992        }
1993        let _t = std::time::Instant::now();
1994        if let Some(base) = &self.base {
1995            // Provenance: raw rkyv bytes; CRC validated inside section_bytes.
1996            // Bounds are already validated at open time (restore_v8_base →
1997            // validate_section_bounds) — unreachable post-validate_section_bounds;
1998            // unwrap_or_default is a safety belt against impossible errors.
1999            let prov_bytes = base
2000                .provenance_raw_bytes()
2001                .map(|b| b.to_vec())
2002                .unwrap_or_default();
2003            self.engine.store_provenance_bytes(prov_bytes);
2004            // HNSW: decode rkyv blobs into owned map.
2005            let hnsw_state = base
2006                .hnsw_section()
2007                .map(archived_hnsw_to_owned)
2008                .unwrap_or_default();
2009            // IVF: raw bincode bytes; deserialized on first mutation/query.
2010            let ivf_bytes = base.ivf_bytes().map(|b| b.to_vec()).unwrap_or_default();
2011            self.engine.store_snapshot_state(hnsw_state, ivf_bytes);
2012        }
2013        self.v8_sections_loaded.store(true, Ordering::Release);
2014        if std::env::var("MUSHROOMDB_TRACE_OPEN").is_ok() {
2015            eprintln!(
2016                "[MUSHROOMDB_TRACE_OPEN] ensure_v8_base_sections_loaded: {:>9.3?}",
2017                _t.elapsed()
2018            );
2019        }
2020    }
2021
2022    /// Return a `TopologyView` that merges the mmap'd base (when present) with
2023    /// the in-memory WAL overlay.  Used by all read paths in db.rs that need
2024    /// the full merged topology without going through `self.view()`.
2025    fn topo_view(&self) -> TopologyView<'_> {
2026        match self.base {
2027            None => TopologyView::owned(&self.topo),
2028            Some(ref base) => {
2029                // SAFETY: base lives as long as self; section bounds validated at open.
2030                // topology() uses access_unchecked; all field reads are bounds-checked in seam.rs.
2031                let archived = base
2032                    .topology()
2033                    .expect("base topology section bounds validated at open");
2034                TopologyView::with_base(&self.topo, archived)
2035            }
2036        }
2037    }
2038
2039    /// Return a `ColumnsView` that merges the mmap'd base columns (when a V8
2040    /// snapshot is open) with the in-memory WAL overlay.  Reads consult the
2041    /// overlay first, then fall through to the archived base section zero-copy.
2042    fn props_view(&self) -> core_storage::v8::seam::ColumnsView<'_> {
2043        match self.base {
2044            None => core_storage::v8::seam::ColumnsView::owned(&self.props),
2045            Some(ref base) => {
2046                // columns() uses access_unchecked; field reads are bounds-checked in seam.rs.
2047                let archived = base
2048                    .columns()
2049                    .expect("base columns section bounds validated at open");
2050                core_storage::v8::seam::ColumnsView::with_base(&self.props, archived)
2051            }
2052        }
2053    }
2054
2055    /// Return an `EdgePropsView` that merges the mmap'd base edge-props section
2056    /// (when a V8 snapshot is open) with the in-memory WAL overlay.
2057    ///
2058    /// Reads consult the overlay first (for post-snapshot mutations), then fall
2059    /// through to the archived base section zero-copy.  Tombstones in the
2060    /// overlay mask deleted-from-base entries.
2061    fn edge_props_view(&self) -> EdgePropsView<'_> {
2062        match self.base {
2063            None => EdgePropsView::owned(&self.edge_props),
2064            Some(ref base) => {
2065                // edge_props_section() uses access_unchecked; field reads bounds-checked in seam.rs.
2066                let archived = base
2067                    .edge_props_section()
2068                    .expect("base edge_props section bounds validated at open");
2069                EdgePropsView::with_base(&self.edge_props, archived)
2070            }
2071        }
2072    }
2073
2074    fn open_at_with(fs: F, commit: u64) -> Result<Self> {
2075        let mut db = Self {
2076            fs,
2077            ids: IdMap::new(),
2078            syms: Interner::new(),
2079            topo: Topology::new(),
2080            props: ColumnStore::new(),
2081            labels: Vec::new(),
2082            edge_props: EdgeProps::new(),
2083            engine: RuleEngine::new(),
2084            view_store: ViewStore::new(),
2085            fulltext: FulltextIndex::new(),
2086            prop_index: PropertyIndex::new(),
2087            event_sink: None,
2088            fsync: FsyncPolicy::Strict,
2089            commit_seq: 0,
2090            roles: Some(vec![]),
2091            subscriptions: Vec::new(),
2092            query_subscriptions: Vec::new(),
2093            sub_capacity: DEFAULT_SUB_CAPACITY,
2094            read_only: false, // set to true after replay
2095            total_wal_commits: 0,
2096            base: None,
2097            fold_overlay: None,
2098            delta_tail: Vec::new(),
2099            commits_since_fold: 0,
2100            defer_events: false,
2101            deferred_events: Vec::new(),
2102            degraded: false,
2103            v8_sections_loaded: std::sync::atomic::AtomicBool::new(false),
2104            v8_sections_mutex: std::sync::Mutex::new(()),
2105            last_change: HashMap::new(),
2106            wal_archive_retention: None,
2107            wal_horizon_floor: 0,
2108            archive_genesis_chain: false,
2109            pending_write_authz: None,
2110            slow_query_threshold_ms: std::env::var("MUSHROOMDB_SLOW_QUERY_MS")
2111                .ok()
2112                .and_then(|v| v.parse().ok())
2113                .unwrap_or(100),
2114            slow_queries: std::sync::Mutex::new(SlowQueryLog {
2115                entries: std::collections::VecDeque::new(),
2116                total: 0,
2117            }),
2118            started_at: std::time::Instant::now(),
2119        };
2120        db.wal_horizon_floor = db.fs.read_horizon_floor()?;
2121        db.archive_genesis_chain = db.fs.has_genesis_marker();
2122        // Same orphaned-archive cleanup as open_with: floor was written first
2123        // during pruning, so a crash may have left stale archives below floor.
2124        db.cleanup_orphaned_archives()?;
2125        // Collect archive frames (oldest-first) and live WAL frames.
2126        // Archives represent pre-snapshot history; the snapshot captures the
2127        // cumulative state at the time of archiving.  Crash-window guarantee:
2128        //   A: crash before rename → WAL intact, no archive. Reopen: normal.
2129        //   B: crash after rename, before new WAL → archive present, WAL
2130        //      absent. Reopen: snapshot loaded (full state), no WAL replay.
2131        //   C: crash after new baseline WAL written → normal post-archive.
2132        let archive_ns = db.fs.list_archives()?;
2133        let mut archive_frames_all: Vec<WalRecord> = Vec::new();
2134        for n in &archive_ns {
2135            let arc_bytes = db.fs.read_archive(*n)?;
2136            let (arc_frames, _) = decode_all(&arc_bytes);
2137            archive_frames_all.extend(arc_frames);
2138        }
2139        let total_archive_frames = archive_frames_all.len() as u64;
2140
2141        let live_bytes = db.fs.read(FileId::Wal)?;
2142        let (live_records, _valid_len) = decode_all(&live_bytes);
2143        let total_surviving = total_archive_frames + live_records.len() as u64;
2144        // Global total including any pruned history below the horizon floor.
2145        let total = db.wal_horizon_floor + total_surviving;
2146
2147        // Horizon and range check.
2148        if commit < db.wal_horizon_floor {
2149            return Err(GraphError::CommitOutOfRange { commit, total });
2150        }
2151        if commit >= total {
2152            return Err(GraphError::CommitOutOfRange { commit, total });
2153        }
2154
2155        // Local index into surviving frames (0 = first frame of oldest archive).
2156        let local = commit - db.wal_horizon_floor;
2157
2158        if local < total_archive_frames {
2159            // Target commit is in an archive.  Correct replay from empty state
2160            // is only possible when the archive chain is an uninterrupted
2161            // genesis chain (first archive taken from a fresh store, no prior
2162            // WAL truncation) and no archives have been pruned (floor == 0).
2163            //
2164            // If either condition is violated the prefix needed to reconstruct
2165            // the requested state is gone; refuse rather than return wrong data.
2166            if db.wal_horizon_floor > 0 || !db.archive_genesis_chain {
2167                return Err(GraphError::CommitOutOfRange { commit, total });
2168            }
2169            // Replay all archive frames up to and including the target commit
2170            // from an empty database state.  Archives must be replayed in order
2171            // so that dense-id intern tables are built up correctly.
2172            for rec in archive_frames_all.into_iter().take((local + 1) as usize) {
2173                db.apply(&rec)?;
2174                let _ = db.engine.drain_deltas();
2175            }
2176        } else {
2177            // Target commit is in the live WAL: load snapshot as base, then
2178            // replay the needed live WAL prefix.
2179            //
2180            // Base state: a truncating snapshot (wal_truncated=true) compacts
2181            // all pre-truncation / pre-archive commits.  Dense-id records in
2182            // the live WAL reference ids/interns that the snapshot provides.
2183            // Peek 6 bytes (same pattern as open_with).
2184            let snap_header = db.fs.read_prefix(FileId::Snapshot, 6)?;
2185            let is_v8 = snap_header.len() >= 6
2186                && &snap_header[0..4] == b"GDB1"
2187                && u16::from_le_bytes([snap_header[4], snap_header[5]])
2188                    == core_storage::snapshot::VERSION_8;
2189            if is_v8 {
2190                let state = if let Some(snap_path) = db.fs.snapshot_path() {
2191                    let mapped = core_storage::v8::MappedBase::map(&snap_path).map_err(|e| {
2192                        GraphError::Corrupt {
2193                            detail: format!("v8: open_at mmap: {e:?}"),
2194                        }
2195                    })?;
2196                    core_storage::snapshot::decode_v8_from_mapped(&mapped)?
2197                } else {
2198                    let snap_bytes = db.fs.read(FileId::Snapshot)?;
2199                    core_storage::snapshot::decode(&snap_bytes)?
2200                };
2201                if let Some(state) = state {
2202                    if state.wal_truncated {
2203                        db.restore_snapshot_state(state)?;
2204                    }
2205                }
2206            } else if !snap_header.is_empty() {
2207                let snap_bytes = db.fs.read(FileId::Snapshot)?;
2208                if let Some(state) = core_storage::snapshot::decode(&snap_bytes)? {
2209                    if state.wal_truncated {
2210                        db.restore_snapshot_state(state)?;
2211                    }
2212                }
2213            }
2214            // else: snap_header empty = no snapshot file.
2215            let live_local = local - total_archive_frames;
2216            for rec in live_records.into_iter().take((live_local + 1) as usize) {
2217                db.apply(&rec)?;
2218                let _ = db.engine.drain_deltas();
2219            }
2220        }
2221        // Pin: pending_delta_count must be 0 after as-of replay, mirroring T1's
2222        // post-loop assert in open_with.
2223        debug_assert_eq!(
2224            db.engine.pending_delta_count(),
2225            0,
2226            "pending_deltas non-empty after open_at replay — \
2227             per-frame drain must run inside the loop to keep memory O(1)"
2228        );
2229        let _ = db.engine.drain_deltas(); // belt-and-braces no-op
2230                                          // Rebuild view values after WAL replay so derived-edge-driven views
2231                                          // reflect the as-of state.  open_at always uses the legacy path (no V8
2232                                          // base), so topo_view is always owned.
2233        {
2234            let topo_view = TopologyView::owned(&db.topo);
2235            db.view_store
2236                .rebuild_all(&mut db.props, &topo_view, &db.ids, &db.syms, &db.labels);
2237        }
2238        // Rebuild full-text index for as-of view (mirrors open_with pattern).
2239        db.fulltext.rebuild_all(
2240            &db.ids,
2241            &db.labels,
2242            &db.syms,
2243            build_props_view(&db.props, &db.base),
2244        );
2245        db.prop_index.rebuild_all(
2246            &db.ids,
2247            &db.labels,
2248            &db.syms,
2249            build_props_view(&db.props, &db.base),
2250        );
2251        // Load roles sidecar (current roles, not point-in-time).
2252        db.roles = Self::load_roles_from_fs(&db.fs)?;
2253        db.read_only = true;
2254        db.total_wal_commits = total;
2255        // Capture initial fold so reader() is immediately usable.
2256        db.fold_now();
2257        Ok(db)
2258    }
2259
2260    /// Whether this instance is a read-only as-of view.
2261    pub fn is_read_only(&self) -> bool {
2262        self.read_only
2263    }
2264
2265    // ── MVCC epoch reader ─────────────────────────────────────────────────────
2266
2267    /// Clone the current overlay state into a new `FrozenOverlay` and reset
2268    /// the delta tail. Called automatically every `FOLD_EVERY_K` commits and at
2269    /// the end of `open_with` / `open_at_with` to prime the reader.
2270    fn fold_now(&mut self) {
2271        let frozen = crate::reader::FrozenOverlay {
2272            ids: self.ids.clone(),
2273            syms: self.syms.clone(),
2274            topo: self.topo.clone(),
2275            props: self.props.clone(),
2276            labels: self.labels.clone(),
2277            edge_props: self.edge_props.clone(),
2278            roles: self.roles.clone(),
2279            fulltext: self.fulltext.clone(),
2280        };
2281        self.fold_overlay = Some(Arc::new(frozen));
2282        self.delta_tail.clear();
2283        self.commits_since_fold = 0;
2284    }
2285
2286    /// Capture a lock-free reader snapshot of the current db state.
2287    ///
2288    /// The read lock is held only for the duration of this call (to clone a
2289    /// handful of `Arc` handles). Subsequent query operations run without any
2290    /// lock.
2291    pub fn reader(&self) -> crate::reader::ReaderSnapshot {
2292        crate::reader::ReaderSnapshot::new(
2293            self.fold_overlay
2294                .clone()
2295                .expect("fold_overlay is always Some after open_with; call reader() after open"),
2296            self.base.clone(),
2297            self.delta_tail.clone(),
2298        )
2299    }
2300
2301    /// Total number of WAL commits at the time [`open_at`] was called.
2302    /// Returns 0 for normal (non-as-of) instances.
2303    pub fn total_wal_commits(&self) -> u64 {
2304        self.total_wal_commits
2305    }
2306
2307    /// Apply a record to in-memory state. Used by both live writes and replay,
2308    /// so replay is definitionally identical to the original execution.
2309    fn apply(&mut self, rec: &WalRecord) -> Result<()> {
2310        match rec {
2311            WalRecord::InsertNode { label, key, props } => {
2312                let id = self.ids.try_insert(key)?;
2313                let sym = self.syms.intern(label);
2314                if self.labels.len() <= id as usize {
2315                    // gap slots are sentinels, never valid label symbols
2316                    self.labels.resize(id as usize + 1, u32::MAX);
2317                }
2318                self.labels[id as usize] = sym;
2319                for (field, value) in props {
2320                    self.props.set(id, field, value.clone());
2321                }
2322                // Initialize view values for the new node before the engine runs so
2323                // delta-based increments start from a known zero baseline.
2324                self.view_store
2325                    .init_node_views(id, &mut self.props, &self.syms, &self.labels);
2326                // Fire rules for the newly inserted node.
2327                let cursor = self.engine.pending_delta_count();
2328                let mut eng = std::mem::take(&mut self.engine);
2329                {
2330                    let mut gm = make_graph_mut(
2331                        &self.ids,
2332                        &mut self.syms,
2333                        &self.labels,
2334                        build_props_view(&self.props, &self.base),
2335                        &mut self.topo,
2336                        &mut self.edge_props,
2337                    );
2338                    eng.on_node_changed(id, None, &mut gm);
2339                }
2340                self.engine = eng;
2341                // Process derived-edge deltas for view maintenance.
2342                // Fast path: skip the O(delta_count) allocation when no views exist.
2343                if !self.view_store.is_empty() {
2344                    #[cfg(test)]
2345                    DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
2346                    let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
2347                    for d in &new_deltas {
2348                        self.view_store.on_edge_changed(
2349                            d.etype_sym,
2350                            d.src_id,
2351                            d.dst_id,
2352                            d.fired,
2353                            &mut self.props,
2354                            &build_topo_view(&self.topo, &self.base),
2355                            &self.ids,
2356                            &self.syms,
2357                            &self.labels,
2358                            self.base.as_ref().map(|b| {
2359                                b.columns()
2360                                    .expect("base columns section bounds validated at open")
2361                            }),
2362                        );
2363                    }
2364                }
2365                // Full-text index maintenance: index enabled fields for this label.
2366                if self.fulltext.has_label(label) {
2367                    for (field, value) in props {
2368                        if self.fulltext.is_enabled(label, field) {
2369                            self.fulltext.add_tokens(id, field, value);
2370                        }
2371                    }
2372                }
2373                // Property (equality) index maintenance.
2374                if self.prop_index.has_label(label) {
2375                    for (field, value) in props {
2376                        self.prop_index.set(label, field, id, value);
2377                    }
2378                }
2379            }
2380            WalRecord::InsertEdge {
2381                edge_type,
2382                src_key,
2383                dst_key,
2384            } => {
2385                let src = self.ids.get(src_key).ok_or_else(|| GraphError::Corrupt {
2386                    detail: format!("wal replay references unknown key {src_key}"),
2387                })?;
2388                let dst = self.ids.get(dst_key).ok_or_else(|| GraphError::Corrupt {
2389                    detail: format!("wal replay references unknown key {dst_key}"),
2390                })?;
2391                let etype = self.syms.intern(edge_type);
2392                // Skip if the edge is already visible in the merged base+overlay
2393                // view.  This keeps WAL replay idempotent when the WAL contains
2394                // pre-snapshot records that are already encoded in a V8 base
2395                // (keep_wal=true opens and crash-before-truncation scenarios).
2396                if self.base.is_some()
2397                    && self
2398                        .topo_view()
2399                        .neighbors(etype, Direction::Out, src)
2400                        .contains(&dst)
2401                {
2402                    return Ok(());
2403                }
2404                self.topo.add_edge(etype, src, dst);
2405                // View maintenance for manual edge insert.
2406                self.view_store.on_edge_changed(
2407                    etype,
2408                    src,
2409                    dst,
2410                    true,
2411                    &mut self.props,
2412                    &build_topo_view(&self.topo, &self.base),
2413                    &self.ids,
2414                    &self.syms,
2415                    &self.labels,
2416                    self.base.as_ref().map(|b| {
2417                        b.columns()
2418                            .expect("base columns section bounds validated at open")
2419                    }),
2420                );
2421                // Rule engine: via-hop rules must update when user edges change.
2422                let cursor = self.engine.pending_delta_count();
2423                let mut eng = std::mem::take(&mut self.engine);
2424                {
2425                    let mut gm = make_graph_mut(
2426                        &self.ids,
2427                        &mut self.syms,
2428                        &self.labels,
2429                        build_props_view(&self.props, &self.base),
2430                        &mut self.topo,
2431                        &mut self.edge_props,
2432                    );
2433                    eng.on_edge_changed(edge_type, src, dst, &mut gm);
2434                }
2435                self.engine = eng;
2436                if !self.view_store.is_empty() {
2437                    let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
2438                    for d in &new_deltas {
2439                        self.view_store.on_edge_changed(
2440                            d.etype_sym,
2441                            d.src_id,
2442                            d.dst_id,
2443                            d.fired,
2444                            &mut self.props,
2445                            &build_topo_view(&self.topo, &self.base),
2446                            &self.ids,
2447                            &self.syms,
2448                            &self.labels,
2449                            self.base.as_ref().map(|b| {
2450                                b.columns()
2451                                    .expect("base columns section bounds validated at open")
2452                            }),
2453                        );
2454                    }
2455                }
2456            }
2457            WalRecord::SetProp { key, field, value } => {
2458                let id = self.ids.get(key).ok_or_else(|| GraphError::Corrupt {
2459                    detail: format!("wal replay references unknown key {key}"),
2460                })?;
2461                let old_value = build_props_view(&self.props, &self.base)
2462                    .get(id, field)
2463                    .map(|vr| vr.into_value());
2464                self.props.set(id, field, value.clone());
2465                // Fire rules for the changed field.
2466                let cursor = self.engine.pending_delta_count();
2467                let mut eng = std::mem::take(&mut self.engine);
2468                {
2469                    let mut gm = make_graph_mut(
2470                        &self.ids,
2471                        &mut self.syms,
2472                        &self.labels,
2473                        build_props_view(&self.props, &self.base),
2474                        &mut self.topo,
2475                        &mut self.edge_props,
2476                    );
2477                    eng.on_node_changed(id, Some((field, old_value)), &mut gm);
2478                }
2479                self.engine = eng;
2480                // Derived-edge deltas → view updates.
2481                if !self.view_store.is_empty() {
2482                    #[cfg(test)]
2483                    DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
2484                    let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
2485                    for d in &new_deltas {
2486                        self.view_store.on_edge_changed(
2487                            d.etype_sym,
2488                            d.src_id,
2489                            d.dst_id,
2490                            d.fired,
2491                            &mut self.props,
2492                            &build_topo_view(&self.topo, &self.base),
2493                            &self.ids,
2494                            &self.syms,
2495                            &self.labels,
2496                            self.base.as_ref().map(|b| {
2497                                b.columns()
2498                                    .expect("base columns section bounds validated at open")
2499                            }),
2500                        );
2501                    }
2502                }
2503                // Neighbor-aggregate views that read `field` must also update.
2504                self.view_store.on_prop_changed(
2505                    id,
2506                    field,
2507                    &mut self.props,
2508                    &build_topo_view(&self.topo, &self.base),
2509                    &self.ids,
2510                    &self.syms,
2511                    &self.labels,
2512                    self.base.as_ref().map(|b| {
2513                        b.columns()
2514                            .expect("base columns section bounds validated at open")
2515                    }),
2516                );
2517                // Full-text index maintenance: update tokens for this field if indexed.
2518                if self.fulltext.field_indexed(field) {
2519                    let label_opt = self.labels.get(id as usize).and_then(|&sym| {
2520                        if sym == u32::MAX {
2521                            None
2522                        } else {
2523                            self.syms.resolve(sym)
2524                        }
2525                    });
2526                    if let Some(label) = label_opt {
2527                        if self.fulltext.is_enabled(label, field) {
2528                            self.fulltext.remove_node_field(id, field);
2529                            self.fulltext.add_tokens(id, field, value);
2530                        }
2531                    }
2532                }
2533                // Property (equality) index maintenance: re-key this node's value.
2534                if self.prop_index.field_indexed(field) {
2535                    let label_opt = self.labels.get(id as usize).and_then(|&sym| {
2536                        if sym == u32::MAX {
2537                            None
2538                        } else {
2539                            self.syms.resolve(sym)
2540                        }
2541                    });
2542                    if let Some(label) = label_opt {
2543                        self.prop_index.set(label, field, id, value);
2544                    }
2545                }
2546            }
2547            WalRecord::Intern { id, text } => {
2548                if let Some(existing) = self.syms.get(text) {
2549                    if existing != *id {
2550                        return Err(GraphError::Corrupt {
2551                            detail: format!(
2552                                "wal intern mismatch for {text:?}: have {existing}, record {id}"
2553                            ),
2554                        });
2555                    }
2556                } else {
2557                    let got = self.syms.intern(text);
2558                    if got != *id {
2559                        return Err(GraphError::Corrupt {
2560                            detail: format!(
2561                                "wal intern assigned {got} for {text:?}, record wanted {id}"
2562                            ),
2563                        });
2564                    }
2565                }
2566            }
2567            WalRecord::InsertNodeId { label, key, props } => {
2568                let id = self.ids.try_insert(key)?;
2569                if self.labels.len() <= id as usize {
2570                    self.labels.resize(id as usize + 1, u32::MAX);
2571                }
2572                self.labels[id as usize] = *label;
2573                let label_str = self
2574                    .syms
2575                    .resolve(*label)
2576                    .ok_or_else(|| GraphError::Corrupt {
2577                        detail: format!("wal InsertNodeId unknown label intern {label}"),
2578                    })?
2579                    .to_string();
2580                for (field_sym, value) in props {
2581                    let field =
2582                        self.syms
2583                            .resolve(*field_sym)
2584                            .ok_or_else(|| GraphError::Corrupt {
2585                                detail: format!(
2586                                    "wal InsertNodeId unknown field intern {field_sym}"
2587                                ),
2588                            })?;
2589                    self.props.set(id, field, value.clone());
2590                }
2591                self.view_store
2592                    .init_node_views(id, &mut self.props, &self.syms, &self.labels);
2593                let cursor = self.engine.pending_delta_count();
2594                let mut eng = std::mem::take(&mut self.engine);
2595                {
2596                    let mut gm = make_graph_mut(
2597                        &self.ids,
2598                        &mut self.syms,
2599                        &self.labels,
2600                        build_props_view(&self.props, &self.base),
2601                        &mut self.topo,
2602                        &mut self.edge_props,
2603                    );
2604                    eng.on_node_changed(id, None, &mut gm);
2605                }
2606                self.engine = eng;
2607                if !self.view_store.is_empty() {
2608                    #[cfg(test)]
2609                    DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
2610                    let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
2611                    for d in &new_deltas {
2612                        self.view_store.on_edge_changed(
2613                            d.etype_sym,
2614                            d.src_id,
2615                            d.dst_id,
2616                            d.fired,
2617                            &mut self.props,
2618                            &build_topo_view(&self.topo, &self.base),
2619                            &self.ids,
2620                            &self.syms,
2621                            &self.labels,
2622                            self.base.as_ref().map(|b| {
2623                                b.columns()
2624                                    .expect("base columns section bounds validated at open")
2625                            }),
2626                        );
2627                    }
2628                }
2629                if self.fulltext.has_label(&label_str) {
2630                    for (field_sym, value) in props {
2631                        let Some(field) = self.syms.resolve(*field_sym) else {
2632                            continue;
2633                        };
2634                        if self.fulltext.is_enabled(&label_str, field) {
2635                            self.fulltext.add_tokens(id, field, value);
2636                        }
2637                    }
2638                }
2639                if self.prop_index.has_label(&label_str) {
2640                    for (field_sym, value) in props {
2641                        let Some(field) = self.syms.resolve(*field_sym) else {
2642                            continue;
2643                        };
2644                        self.prop_index.set(&label_str, field, id, value);
2645                    }
2646                }
2647            }
2648            WalRecord::InsertEdgeId { etype, src, dst } => {
2649                // Replay-over-snapshot: dense ids in the pre-snapshot WAL may
2650                // already be tombstoned. Skip rather than attaching edges to
2651                // dead ids (DeleteNode keys the live re-insert, not the old id).
2652                if self.ids.is_tombstoned(*src)
2653                    || self.ids.is_tombstoned(*dst)
2654                    || self.ids.key_of(*src).is_none()
2655                    || self.ids.key_of(*dst).is_none()
2656                {
2657                    return Ok(());
2658                }
2659                // Skip if already visible in the merged view (same idempotency
2660                // guard as InsertEdge above: prevents double-counting when
2661                // pre-snapshot WAL records are replayed over a V8 base).
2662                if self.base.is_some()
2663                    && self
2664                        .topo_view()
2665                        .neighbors(*etype, Direction::Out, *src)
2666                        .contains(dst)
2667                {
2668                    return Ok(());
2669                }
2670                self.topo.add_edge(*etype, *src, *dst);
2671                self.view_store.on_edge_changed(
2672                    *etype,
2673                    *src,
2674                    *dst,
2675                    true,
2676                    &mut self.props,
2677                    &build_topo_view(&self.topo, &self.base),
2678                    &self.ids,
2679                    &self.syms,
2680                    &self.labels,
2681                    self.base.as_ref().map(|b| {
2682                        b.columns()
2683                            .expect("base columns section bounds validated at open")
2684                    }),
2685                );
2686                // Rule engine: via-hop rules fire when user via-edges are inserted.
2687                // Resolve etype back to string so on_edge_changed can match rules by name.
2688                if let Some(etype_str) = self.syms.resolve(*etype).map(|s| s.to_string()) {
2689                    let cursor = self.engine.pending_delta_count();
2690                    let mut eng = std::mem::take(&mut self.engine);
2691                    {
2692                        let mut gm = make_graph_mut(
2693                            &self.ids,
2694                            &mut self.syms,
2695                            &self.labels,
2696                            build_props_view(&self.props, &self.base),
2697                            &mut self.topo,
2698                            &mut self.edge_props,
2699                        );
2700                        eng.on_edge_changed(&etype_str, *src, *dst, &mut gm);
2701                    }
2702                    self.engine = eng;
2703                    if !self.view_store.is_empty() {
2704                        let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
2705                        for d in &new_deltas {
2706                            self.view_store.on_edge_changed(
2707                                d.etype_sym,
2708                                d.src_id,
2709                                d.dst_id,
2710                                d.fired,
2711                                &mut self.props,
2712                                &build_topo_view(&self.topo, &self.base),
2713                                &self.ids,
2714                                &self.syms,
2715                                &self.labels,
2716                                self.base.as_ref().map(|b| {
2717                                    b.columns()
2718                                        .expect("base columns section bounds validated at open")
2719                                }),
2720                            );
2721                        }
2722                    }
2723                }
2724            }
2725            WalRecord::SetPropId { id, field, value } => {
2726                if self.ids.is_tombstoned(*id) || self.ids.key_of(*id).is_none() {
2727                    return Ok(());
2728                }
2729                let field_str = self
2730                    .syms
2731                    .resolve(*field)
2732                    .ok_or_else(|| GraphError::Corrupt {
2733                        detail: format!("wal SetPropId unknown field intern {field}"),
2734                    })?
2735                    .to_string();
2736                let old_value = build_props_view(&self.props, &self.base)
2737                    .get(*id, &field_str)
2738                    .map(|vr| vr.into_value());
2739                self.props.set(*id, &field_str, value.clone());
2740                let cursor = self.engine.pending_delta_count();
2741                let mut eng = std::mem::take(&mut self.engine);
2742                {
2743                    let mut gm = make_graph_mut(
2744                        &self.ids,
2745                        &mut self.syms,
2746                        &self.labels,
2747                        build_props_view(&self.props, &self.base),
2748                        &mut self.topo,
2749                        &mut self.edge_props,
2750                    );
2751                    eng.on_node_changed(*id, Some((field_str.as_str(), old_value)), &mut gm);
2752                }
2753                self.engine = eng;
2754                if !self.view_store.is_empty() {
2755                    #[cfg(test)]
2756                    DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
2757                    let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
2758                    for d in &new_deltas {
2759                        self.view_store.on_edge_changed(
2760                            d.etype_sym,
2761                            d.src_id,
2762                            d.dst_id,
2763                            d.fired,
2764                            &mut self.props,
2765                            &build_topo_view(&self.topo, &self.base),
2766                            &self.ids,
2767                            &self.syms,
2768                            &self.labels,
2769                            self.base.as_ref().map(|b| {
2770                                b.columns()
2771                                    .expect("base columns section bounds validated at open")
2772                            }),
2773                        );
2774                    }
2775                }
2776                self.view_store.on_prop_changed(
2777                    *id,
2778                    &field_str,
2779                    &mut self.props,
2780                    &build_topo_view(&self.topo, &self.base),
2781                    &self.ids,
2782                    &self.syms,
2783                    &self.labels,
2784                    self.base.as_ref().map(|b| {
2785                        b.columns()
2786                            .expect("base columns section bounds validated at open")
2787                    }),
2788                );
2789                if self.fulltext.field_indexed(&field_str) {
2790                    let label_opt = self.labels.get(*id as usize).and_then(|&sym| {
2791                        if sym == u32::MAX {
2792                            None
2793                        } else {
2794                            self.syms.resolve(sym)
2795                        }
2796                    });
2797                    if let Some(label) = label_opt {
2798                        if self.fulltext.is_enabled(label, &field_str) {
2799                            self.fulltext.remove_node_field(*id, &field_str);
2800                            self.fulltext.add_tokens(*id, &field_str, value);
2801                        }
2802                    }
2803                }
2804                if self.prop_index.field_indexed(&field_str) {
2805                    let label_opt = self.labels.get(*id as usize).and_then(|&sym| {
2806                        if sym == u32::MAX {
2807                            None
2808                        } else {
2809                            self.syms.resolve(sym)
2810                        }
2811                    });
2812                    if let Some(label) = label_opt {
2813                        self.prop_index.set(label, &field_str, *id, value);
2814                    }
2815                }
2816            }
2817            WalRecord::CreateRule { def_bytes } => {
2818                let def: RuleDef = decode_rule_def(def_bytes).map_err(|e| GraphError::Corrupt {
2819                    detail: format!("CreateRule def_bytes deserialize failed: {e}"),
2820                })?;
2821                // Replay-over-snapshot idempotency: the rule was captured in the snapshot
2822                // so the engine already has it; silently skip to avoid a spurious
2823                // RuleInvalid error in the crash window between snapshot write and WAL
2824                // truncation.
2825                if self.engine.rules().any(|r| r.name == def.name) {
2826                    return Ok(());
2827                }
2828                let cursor = self.engine.pending_delta_count();
2829                let mut eng = std::mem::take(&mut self.engine);
2830                let result = {
2831                    let mut gm = make_graph_mut(
2832                        &self.ids,
2833                        &mut self.syms,
2834                        &self.labels,
2835                        build_props_view(&self.props, &self.base),
2836                        &mut self.topo,
2837                        &mut self.edge_props,
2838                    );
2839                    eng.create_rule(def, &mut gm)
2840                };
2841                self.engine = eng;
2842                result.map_err(|e| GraphError::RuleInvalid { detail: e })?;
2843                // Derived-edge fires from backfill → view updates.
2844                // Fast path: skip O(edge_count) allocation when no views exist.
2845                if !self.view_store.is_empty() {
2846                    #[cfg(test)]
2847                    DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
2848                    let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
2849                    for d in &new_deltas {
2850                        self.view_store.on_edge_changed(
2851                            d.etype_sym,
2852                            d.src_id,
2853                            d.dst_id,
2854                            d.fired,
2855                            &mut self.props,
2856                            &build_topo_view(&self.topo, &self.base),
2857                            &self.ids,
2858                            &self.syms,
2859                            &self.labels,
2860                            self.base.as_ref().map(|b| {
2861                                b.columns()
2862                                    .expect("base columns section bounds validated at open")
2863                            }),
2864                        );
2865                    }
2866                }
2867            }
2868            WalRecord::DeleteRule { name } => {
2869                // Replay-over-snapshot idempotency: the snapshot already captured the
2870                // post-delete state so the rule is absent; silently skip to avoid a
2871                // spurious RuleNotFound error in the crash window between snapshot write
2872                // and WAL truncation.
2873                if !self.engine.rules().any(|r| r.name == *name) {
2874                    return Ok(());
2875                }
2876                let cursor = self.engine.pending_delta_count();
2877                let mut eng = std::mem::take(&mut self.engine);
2878                let result = {
2879                    let mut gm = make_graph_mut(
2880                        &self.ids,
2881                        &mut self.syms,
2882                        &self.labels,
2883                        build_props_view(&self.props, &self.base),
2884                        &mut self.topo,
2885                        &mut self.edge_props,
2886                    );
2887                    eng.delete_rule(name, &mut gm)
2888                };
2889                self.engine = eng;
2890                result.map_err(|_| GraphError::RuleNotFound { name: name.clone() })?;
2891                // Derived-edge retractions → view updates.
2892                if !self.view_store.is_empty() {
2893                    #[cfg(test)]
2894                    DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
2895                    let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
2896                    for d in &new_deltas {
2897                        self.view_store.on_edge_changed(
2898                            d.etype_sym,
2899                            d.src_id,
2900                            d.dst_id,
2901                            d.fired,
2902                            &mut self.props,
2903                            &build_topo_view(&self.topo, &self.base),
2904                            &self.ids,
2905                            &self.syms,
2906                            &self.labels,
2907                            self.base.as_ref().map(|b| {
2908                                b.columns()
2909                                    .expect("base columns section bounds validated at open")
2910                            }),
2911                        );
2912                    }
2913                }
2914            }
2915            WalRecord::RemoveProp { key, field } => {
2916                // Recovery-safe: unknown key or already-absent field is a
2917                // clean no-op. Crash-window replay over a snapshot that
2918                // already applied this record must not Err.
2919                let Some(id) = self.ids.get(key) else {
2920                    return Ok(());
2921                };
2922                // Read old value through the seam for rule retraction.
2923                let old = build_props_view(&self.props, &self.base)
2924                    .get(id, field)
2925                    .map(|vr| vr.into_value());
2926                self.props.remove(id, field);
2927                // If the base still supplies the value after the overlay removal,
2928                // record a tombstone so ColumnsView::get does not resurrect it.
2929                // This covers both the base-only case AND the both-resident case:
2930                //   base-only (in_overlay=false): old prop was only in base, remove
2931                //     is a no-op on overlay, base still visible → tombstone needed.
2932                //   both-resident (in_overlay=true): overlay had v2, base has v1;
2933                //     removing overlay uncovers v1 → tombstone needed.
2934                // Idempotent on double-replay: second pass sees the tombstone →
2935                // get() returns None → condition is false → no duplicate tombstone.
2936                if build_props_view(&self.props, &self.base)
2937                    .get(id, field)
2938                    .is_some()
2939                {
2940                    self.props.record_prop_tombstone(id, field);
2941                }
2942                let cursor = self.engine.pending_delta_count();
2943                let mut eng = std::mem::take(&mut self.engine);
2944                {
2945                    let mut gm = make_graph_mut(
2946                        &self.ids,
2947                        &mut self.syms,
2948                        &self.labels,
2949                        build_props_view(&self.props, &self.base),
2950                        &mut self.topo,
2951                        &mut self.edge_props,
2952                    );
2953                    eng.on_node_changed(id, Some((field, old)), &mut gm);
2954                }
2955                self.engine = eng;
2956                // Derived-edge deltas → view updates.
2957                if !self.view_store.is_empty() {
2958                    #[cfg(test)]
2959                    DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
2960                    let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
2961                    for d in &new_deltas {
2962                        self.view_store.on_edge_changed(
2963                            d.etype_sym,
2964                            d.src_id,
2965                            d.dst_id,
2966                            d.fired,
2967                            &mut self.props,
2968                            &build_topo_view(&self.topo, &self.base),
2969                            &self.ids,
2970                            &self.syms,
2971                            &self.labels,
2972                            self.base.as_ref().map(|b| {
2973                                b.columns()
2974                                    .expect("base columns section bounds validated at open")
2975                            }),
2976                        );
2977                    }
2978                }
2979                // Neighbor-aggregate views that read `field` must also update.
2980                self.view_store.on_prop_changed(
2981                    id,
2982                    field,
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                // Full-text index maintenance: remove tokens for this field.
2994                if self.fulltext.field_indexed(field) {
2995                    self.fulltext.remove_node_field(id, field);
2996                }
2997                // Property (equality) index maintenance: drop this node's entry.
2998                if self.prop_index.field_indexed(field) {
2999                    if let Some(label) = self.labels.get(id as usize).and_then(|&sym| {
3000                        (sym != u32::MAX).then(|| self.syms.resolve(sym)).flatten()
3001                    }) {
3002                        self.prop_index.remove_node(label, field, id);
3003                    }
3004                }
3005            }
3006            WalRecord::DeleteEdge {
3007                edge_type,
3008                src_key,
3009                dst_key,
3010            } => {
3011                // Recovery-safe: unknown keys, unknown etype, or already-
3012                // absent edge is a clean no-op (remove_edge returns false).
3013                let Some(src) = self.ids.get(src_key) else {
3014                    return Ok(());
3015                };
3016                let Some(dst) = self.ids.get(dst_key) else {
3017                    return Ok(());
3018                };
3019                let Some(etype) = self.syms.get(edge_type) else {
3020                    return Ok(());
3021                };
3022                // I3: phantom-tombstone guard.  When a V8 base is present, a
3023                // DeleteEdge WAL record for an edge that was already absorbed into
3024                // the new base (i.e. neither in overlay nor in base) must be skipped.
3025                // Without this guard, remove_edge records a tombstone for an edge
3026                // that no longer exists, incorrectly understating edge_count.
3027                if self.base.is_some()
3028                    && !self
3029                        .topo_view()
3030                        .neighbors(etype, core_storage::topology::Direction::Out, src)
3031                        .contains(&dst)
3032                {
3033                    return Ok(());
3034                }
3035                self.topo.remove_edge(etype, src, dst);
3036                self.edge_props.remove_edge(etype, src, dst);
3037                // View maintenance for manual edge delete (topo already updated above).
3038                self.view_store.on_edge_changed(
3039                    etype,
3040                    src,
3041                    dst,
3042                    false,
3043                    &mut self.props,
3044                    &build_topo_view(&self.topo, &self.base),
3045                    &self.ids,
3046                    &self.syms,
3047                    &self.labels,
3048                    self.base.as_ref().map(|b| {
3049                        b.columns()
3050                            .expect("base columns section bounds validated at open")
3051                    }),
3052                );
3053                // Rule engine: via-hop rules must retract when user via-edges are deleted.
3054                let cursor = self.engine.pending_delta_count();
3055                let mut eng = std::mem::take(&mut self.engine);
3056                {
3057                    let mut gm = make_graph_mut(
3058                        &self.ids,
3059                        &mut self.syms,
3060                        &self.labels,
3061                        build_props_view(&self.props, &self.base),
3062                        &mut self.topo,
3063                        &mut self.edge_props,
3064                    );
3065                    eng.on_edge_changed(edge_type, src, dst, &mut gm);
3066                }
3067                self.engine = eng;
3068                if !self.view_store.is_empty() {
3069                    let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
3070                    for d in &new_deltas {
3071                        self.view_store.on_edge_changed(
3072                            d.etype_sym,
3073                            d.src_id,
3074                            d.dst_id,
3075                            d.fired,
3076                            &mut self.props,
3077                            &build_topo_view(&self.topo, &self.base),
3078                            &self.ids,
3079                            &self.syms,
3080                            &self.labels,
3081                            self.base.as_ref().map(|b| {
3082                                b.columns()
3083                                    .expect("base columns section bounds validated at open")
3084                            }),
3085                        );
3086                    }
3087                }
3088            }
3089            WalRecord::DeleteNode { key } => {
3090                // Recovery-safe: already-tombstoned / unknown key is a clean
3091                // no-op. Crash-window replay over a snapshot that already
3092                // applied this record cannot recover the retired id from the
3093                // key (`IdMap::get` is None), so every subsequent step is
3094                // skipped. Each step is independently idempotent if invoked
3095                // twice on a still-live id: retraction is a no-op on empty
3096                // provenance, `remove_edge` returns false, `remove_all` is a
3097                // no-op, `ids.delete` returns None, label sentinel is sticky.
3098                let Some(n) = self.ids.get(key) else {
3099                    return Ok(());
3100                };
3101
3102                // (1) Retract derived edges + de-index while props/labels live.
3103                let cursor = self.engine.pending_delta_count();
3104                let mut eng = std::mem::take(&mut self.engine);
3105                {
3106                    let mut gm = make_graph_mut(
3107                        &self.ids,
3108                        &mut self.syms,
3109                        &self.labels,
3110                        build_props_view(&self.props, &self.base),
3111                        &mut self.topo,
3112                        &mut self.edge_props,
3113                    );
3114                    eng.on_node_removed(n, &mut gm);
3115                }
3116                self.engine = eng;
3117                // Derived-edge retractions → view updates for neighbors.
3118                if !self.view_store.is_empty() {
3119                    #[cfg(test)]
3120                    DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
3121                    let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
3122                    for d in &new_deltas {
3123                        self.view_store.on_edge_changed(
3124                            d.etype_sym,
3125                            d.src_id,
3126                            d.dst_id,
3127                            d.fired,
3128                            &mut self.props,
3129                            &build_topo_view(&self.topo, &self.base),
3130                            &self.ids,
3131                            &self.syms,
3132                            &self.labels,
3133                            self.base.as_ref().map(|b| {
3134                                b.columns()
3135                                    .expect("base columns section bounds validated at open")
3136                            }),
3137                        );
3138                    }
3139                }
3140
3141                // (2) Sweep ALL remaining edges incident to n, both directions,
3142                // every etype. This cascade is intentionally mask-independent:
3143                // topology integrity requires removing every edge touching the
3144                // deleted node regardless of the caller's visibility scope.
3145                // (The mask limits which nodes a role's read phase can return;
3146                // the WAL delete always executes with full storage authority.)
3147                // Collect then remove so neighbor slices stay valid during
3148                // iteration. Remove from topo first, then call view maintenance
3149                // so Avg/Min/Max recompute sees the correct (reduced) neighbor set.
3150                let etypes: Vec<u32> = self.topo.etypes().collect();
3151                let mut doomed = Vec::new();
3152                for et in &etypes {
3153                    for &dst in self.topo.neighbors(*et, Direction::Out, n).as_ref() {
3154                        doomed.push((*et, n, dst));
3155                    }
3156                    for &src in self.topo.neighbors(*et, Direction::In, n).as_ref() {
3157                        doomed.push((*et, src, n));
3158                    }
3159                }
3160                for (et, s, d) in doomed {
3161                    self.topo.remove_edge(et, s, d);
3162                    self.edge_props.remove_edge(et, s, d);
3163                    // View maintenance: n's own view values will be cleared by
3164                    // remove_all below; only update surviving neighbors.
3165                    self.view_store.on_edge_changed(
3166                        et,
3167                        s,
3168                        d,
3169                        false,
3170                        &mut self.props,
3171                        &build_topo_view(&self.topo, &self.base),
3172                        &self.ids,
3173                        &self.syms,
3174                        &self.labels,
3175                        self.base.as_ref().map(|b| {
3176                            b.columns()
3177                                .expect("base columns section bounds validated at open")
3178                        }),
3179                    );
3180                }
3181
3182                // (3) Drop every remaining prop (`ColumnStore::remove_all`).
3183                self.props.remove_all(n);
3184                // Full-text index maintenance: remove all tokens for this node.
3185                self.fulltext.remove_node(n);
3186                // Property (equality) index maintenance: drop all entries for n.
3187                self.prop_index.remove_node_all(n);
3188
3189                // (4) Retire the dense id and stamp the label sentinel.
3190                self.ids.delete(key);
3191                if let Some(slot) = self.labels.get_mut(n as usize) {
3192                    *slot = u32::MAX;
3193                }
3194            }
3195            WalRecord::Batch(inner) => {
3196                // Apply each inner record in order through the same apply path.
3197                // Inner records are validated free of nested Batch by encode_record.
3198                for rec in inner {
3199                    self.apply(rec)?;
3200                }
3201            }
3202            WalRecord::RebuildRule { name } => {
3203                // Replay-over-snapshot idempotency: the snapshot may already
3204                // reflect a later delete_rule, so the rule is absent; skip.
3205                if !self.engine.rules().any(|r| r.name == *name) {
3206                    return Ok(());
3207                }
3208                let cursor = self.engine.pending_delta_count();
3209                let mut eng = std::mem::take(&mut self.engine);
3210                let result = {
3211                    let mut gm = make_graph_mut(
3212                        &self.ids,
3213                        &mut self.syms,
3214                        &self.labels,
3215                        build_props_view(&self.props, &self.base),
3216                        &mut self.topo,
3217                        &mut self.edge_props,
3218                    );
3219                    eng.rebuild(name, &mut gm)
3220                };
3221                self.engine = eng;
3222                result.map_err(|_| GraphError::RuleNotFound { name: name.clone() })?;
3223                // Derived-edge delta changes → view updates.
3224                if !self.view_store.is_empty() {
3225                    #[cfg(test)]
3226                    DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
3227                    let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
3228                    for d in &new_deltas {
3229                        self.view_store.on_edge_changed(
3230                            d.etype_sym,
3231                            d.src_id,
3232                            d.dst_id,
3233                            d.fired,
3234                            &mut self.props,
3235                            &build_topo_view(&self.topo, &self.base),
3236                            &self.ids,
3237                            &self.syms,
3238                            &self.labels,
3239                            self.base.as_ref().map(|b| {
3240                                b.columns()
3241                                    .expect("base columns section bounds validated at open")
3242                            }),
3243                        );
3244                    }
3245                }
3246            }
3247            WalRecord::CreateView { def_bytes } => {
3248                let def: ViewDef =
3249                    bincode::deserialize(def_bytes).map_err(|e| GraphError::Corrupt {
3250                        detail: format!("CreateView def_bytes deserialize failed: {e}"),
3251                    })?;
3252                // Replay-over-snapshot idempotency: view already present → skip.
3253                if self.view_store.has_view(&def.name) {
3254                    return Ok(());
3255                }
3256                self.view_store
3257                    .create_view(
3258                        def,
3259                        &mut self.props,
3260                        &build_topo_view(&self.topo, &self.base),
3261                        &self.ids,
3262                        &self.syms,
3263                        &self.labels,
3264                    )
3265                    .map_err(|e| GraphError::RuleInvalid { detail: e })?;
3266            }
3267            WalRecord::DeleteView { name } => {
3268                // Replay-over-snapshot idempotency: view already absent → skip.
3269                if !self.view_store.has_view(name) {
3270                    return Ok(());
3271                }
3272                self.view_store
3273                    .delete_view(name, &mut self.props, &self.ids, &self.labels, &self.syms)
3274                    .map_err(|_| GraphError::RuleNotFound { name: name.clone() })?;
3275            }
3276            WalRecord::EnableFulltext { label, field } => {
3277                // Replay-over-snapshot idempotency: already enabled → skip.
3278                if self.fulltext.is_enabled(label, field) {
3279                    return Ok(());
3280                }
3281                self.fulltext.enable(label, field);
3282                // Backfill: index all live nodes of this label that have the field.
3283                let n = self.ids.len() as u32;
3284                for id in 0..n {
3285                    let Some(&sym) = self.labels.get(id as usize) else {
3286                        continue;
3287                    };
3288                    if sym == u32::MAX {
3289                        continue; // tombstoned
3290                    }
3291                    let Some(lbl) = self.syms.resolve(sym) else {
3292                        continue;
3293                    };
3294                    if lbl != label {
3295                        continue;
3296                    }
3297                    if let Some(value) = build_props_view(&self.props, &self.base)
3298                        .get(id, field)
3299                        .map(|vr| vr.into_value())
3300                    {
3301                        self.fulltext.add_tokens(id, field, &value);
3302                    }
3303                }
3304            }
3305            WalRecord::DisableFulltext { label, field } => {
3306                // Replay-over-snapshot idempotency: already disabled → skip.
3307                if !self.fulltext.is_enabled(label, field) {
3308                    return Ok(());
3309                }
3310                // If another label still indexes this field, the postings column
3311                // is kept — but it must not contain node_ids from the now-disabled
3312                // label.  Remove them before calling disable() so the field_indexed
3313                // guard inside disable() sees the correct post-removal state.
3314                if self.fulltext.field_indexed_by_other(label, field) {
3315                    if let Some(label_sym) = self.syms.get(label) {
3316                        for (node_id, &lsym) in self.labels.iter().enumerate() {
3317                            if lsym == label_sym {
3318                                self.fulltext.remove_node_field(node_id as u32, field);
3319                            }
3320                        }
3321                    }
3322                }
3323                self.fulltext.disable(label, field);
3324            }
3325            WalRecord::EnableIndex { label, field } => {
3326                // Replay-over-snapshot idempotency: already enabled → skip.
3327                if self.prop_index.is_enabled(label, field) {
3328                    return Ok(());
3329                }
3330                self.prop_index.enable(label, field);
3331                // Backfill: index all live nodes of this label that have the field.
3332                let n = self.ids.len() as u32;
3333                for id in 0..n {
3334                    let Some(&sym) = self.labels.get(id as usize) else {
3335                        continue;
3336                    };
3337                    if sym == u32::MAX {
3338                        continue; // tombstoned
3339                    }
3340                    let Some(lbl) = self.syms.resolve(sym) else {
3341                        continue;
3342                    };
3343                    if lbl != label {
3344                        continue;
3345                    }
3346                    if let Some(value) = build_props_view(&self.props, &self.base)
3347                        .get(id, field)
3348                        .map(|vr| vr.into_value())
3349                    {
3350                        self.prop_index.set(label, field, id, &value);
3351                    }
3352                }
3353            }
3354            WalRecord::DisableIndex { label, field } => {
3355                self.prop_index.disable(label, field);
3356            }
3357            // History markers carry no replay state — rules re-derive edges
3358            // deterministically on open/replay. Skip unconditionally.
3359            WalRecord::DerivedEdgeAdded { .. } | WalRecord::DerivedEdgeRetracted { .. } => {}
3360            // ── rename_node ──────────────────────────────────────────────────
3361            WalRecord::RenameNode { old_key, new_key } => {
3362                // Recovery-safe: if old_key is already gone (key was renamed
3363                // by a snapshot or a prior replay frame), skip cleanly.
3364                if self.ids.get(old_key).is_none() {
3365                    return Ok(());
3366                }
3367                // The rename only updates the key-table; the dense id, all
3368                // topo edges, props, labels, and rule state are id-indexed and
3369                // require no change.
3370                self.ids
3371                    .rename(old_key, new_key)
3372                    .map_err(|e| GraphError::Corrupt {
3373                        detail: format!("wal replay RenameNode {old_key}→{new_key}: {e}"),
3374                    })?;
3375            }
3376        }
3377        Ok(())
3378    }
3379
3380    /// Intern `s` in `syms` and emit a WAL `Intern` record so `*Id` records
3381    /// replay on WAL-only `open_at` (no snapshot intern table). Apply is
3382    /// idempotent when the string is already bound. Always emit: after
3383    /// `snapshot()` the WAL is truncated and live intern is not on disk.
3384    fn intern_wal(&mut self, s: &str) -> (u32, WalRecord) {
3385        let id = if let Some(id) = self.syms.get(s) {
3386            id
3387        } else {
3388            self.syms.intern(s)
3389        };
3390        (
3391            id,
3392            WalRecord::Intern {
3393                id,
3394                text: s.to_string(),
3395            },
3396        )
3397    }
3398
3399    /// Rewrite user-facing records into dense-id records. On `Err`, no live
3400    /// state is left mutated: speculative interns made while building the
3401    /// output are rolled back, so a later successful mutation cannot log an
3402    /// `Intern` record whose id replay would never reproduce.
3403    fn rewrite_wal_dense(&mut self, recs: Vec<WalRecord>) -> Result<Vec<WalRecord>> {
3404        let syms_checkpoint = self.syms.len();
3405        let result = self.rewrite_wal_dense_inner(recs);
3406        if result.is_err() {
3407            self.syms.truncate(syms_checkpoint);
3408        }
3409        result
3410    }
3411
3412    fn rewrite_wal_dense_inner(&mut self, recs: Vec<WalRecord>) -> Result<Vec<WalRecord>> {
3413        let mut out = Vec::with_capacity(recs.len());
3414        // Node ids allocated by later apply(InsertNodeId) in this same batch.
3415        let mut pending: std::collections::HashMap<String, u32> = std::collections::HashMap::new();
3416        let mut interned = std::collections::HashSet::<u32>::new();
3417        let mut next = u32::try_from(self.ids.len()).map_err(|_| GraphError::Corrupt {
3418            detail: "id space exhausted".into(),
3419        })?;
3420        let lookup = |ids: &IdMap,
3421                      pending: &std::collections::HashMap<String, u32>,
3422                      key: &str|
3423         -> Option<u32> { ids.get(key).or_else(|| pending.get(key).copied()) };
3424        for rec in recs {
3425            match rec {
3426                WalRecord::InsertNode { label, key, props } => {
3427                    let (label_id, intern) = self.intern_wal(&label);
3428                    if interned.insert(label_id) {
3429                        out.push(intern);
3430                    }
3431                    let mut props_id = Vec::with_capacity(props.len());
3432                    for (field, value) in props {
3433                        let (field_id, intern) = self.intern_wal(&field);
3434                        if interned.insert(field_id) {
3435                            out.push(intern);
3436                        }
3437                        props_id.push((field_id, value));
3438                    }
3439                    if lookup(&self.ids, &pending, &key).is_none() {
3440                        pending.insert(key.clone(), next);
3441                        next = next.checked_add(1).ok_or_else(|| GraphError::Corrupt {
3442                            detail: "id space exhausted".into(),
3443                        })?;
3444                    }
3445                    out.push(WalRecord::InsertNodeId {
3446                        label: label_id,
3447                        key,
3448                        props: props_id,
3449                    });
3450                }
3451                WalRecord::SetProp { key, field, value } => {
3452                    let id =
3453                        lookup(&self.ids, &pending, &key).ok_or_else(|| GraphError::Corrupt {
3454                            detail: format!("dense WAL rewrite missing key {key}"),
3455                        })?;
3456                    let (field_id, intern) = self.intern_wal(&field);
3457                    if interned.insert(field_id) {
3458                        out.push(intern);
3459                    }
3460                    out.push(WalRecord::SetPropId {
3461                        id,
3462                        field: field_id,
3463                        value,
3464                    });
3465                }
3466                WalRecord::InsertEdge {
3467                    edge_type,
3468                    src_key,
3469                    dst_key,
3470                } => {
3471                    let (etype, intern) = self.intern_wal(&edge_type);
3472                    if interned.insert(etype) {
3473                        out.push(intern);
3474                    }
3475                    let src = lookup(&self.ids, &pending, &src_key).ok_or_else(|| {
3476                        GraphError::Corrupt {
3477                            detail: format!("dense WAL rewrite missing src {src_key}"),
3478                        }
3479                    })?;
3480                    let dst = lookup(&self.ids, &pending, &dst_key).ok_or_else(|| {
3481                        GraphError::Corrupt {
3482                            detail: format!("dense WAL rewrite missing dst {dst_key}"),
3483                        }
3484                    })?;
3485                    out.push(WalRecord::InsertEdgeId { etype, src, dst });
3486                }
3487                WalRecord::RenameNode {
3488                    ref old_key,
3489                    ref new_key,
3490                } => {
3491                    // Track the rename in `pending` so subsequent InsertEdge /
3492                    // SetProp records in this batch can resolve the new key.
3493                    let id = lookup(&self.ids, &pending, old_key).ok_or_else(|| {
3494                        GraphError::Corrupt {
3495                            detail: format!(
3496                                "dense WAL rewrite: RenameNode old key {old_key} not found"
3497                            ),
3498                        }
3499                    })?;
3500                    pending.remove(old_key.as_str());
3501                    pending.insert(new_key.clone(), id);
3502                    out.push(rec);
3503                }
3504                // # Symbol-order invariant (load-bearing)
3505                //
3506                // Write-time and replay-time symbol assignment must agree: every
3507                // symbol in a `Batch` frame has to receive the same dense id when
3508                // the frame's records are replayed in order as it received when
3509                // the frame was written.
3510                //
3511                // A rule's backfill interns its `edge_type` lazily
3512                // (`core_rules::engine`, every `g.syms.intern(&def.edge_type)`
3513                // site), and that backfill runs from `apply` — during the
3514                // `CreateRule` record itself, and again from any later
3515                // `InsertNodeId` in the same frame that makes the rule fire. At
3516                // write time the whole batch is rewritten before any of it is
3517                // applied, so a later `InsertEdge` in the same batch would win the
3518                // lower id for its edge type; on replay the rule's lazy intern
3519                // gets there first and steals it, and the `Intern` record fails at
3520                // the `wal intern assigned …` check in `apply`.
3521                //
3522                // Pre-interning the rule's `edge_type` here, and emitting its
3523                // `Intern` record ahead of the `CreateRule` record, makes both
3524                // orders identical. `weight_prop` needs no pre-intern:
3525                // `EdgeProps::set` keys props by `String`, never through the
3526                // interner. `via_edge` needs none either: via-hop rules resolve it
3527                // with `syms.get` and skip when it is absent.
3528                //
3529                // `RebuildRule` and `DeleteRule` need no such handling here:
3530                // `RebuildRule` has no `BatchOp` variant, so it never appears
3531                // inside a `Batch` today — it is only ever issued as its own
3532                // standalone commit (`rebuild_rule`, or the auto-rebuild path
3533                // that logs it as a second commit after the triggering op).
3534                // `DeleteRule` does have a `BatchOp` variant and can appear
3535                // inside a `Batch`, but it carries only a rule `name` — no
3536                // `edge_type` or other symbol that needs pre-interning — so
3537                // only `CreateRule` needs this arm.
3538                WalRecord::CreateRule { ref def_bytes } => {
3539                    let def = decode_rule_def(def_bytes).map_err(|e| GraphError::Corrupt {
3540                        detail: format!("CreateRule def_bytes deserialize failed: {e}"),
3541                    })?;
3542                    let (etype, intern) = self.intern_wal(&def.edge_type);
3543                    if interned.insert(etype) {
3544                        out.push(intern);
3545                    }
3546                    out.push(rec);
3547                }
3548                other => out.push(other),
3549            }
3550        }
3551        Ok(out)
3552    }
3553
3554    fn log_dense(&mut self, recs: Vec<WalRecord>) -> Result<()> {
3555        let recs = self.rewrite_wal_dense(recs)?;
3556        match recs.len() {
3557            0 => Ok(()),
3558            1 => self.log_then_apply(recs.into_iter().next().unwrap()),
3559            _ => self.log_then_apply(WalRecord::Batch(recs)),
3560        }
3561    }
3562
3563    /// Durable write, then notify the event sink. Replay (`apply` during
3564    /// `open`) never enters this function, so it is the replay-silent seam.
3565    fn log_then_apply(&mut self, rec: WalRecord) -> Result<()> {
3566        self.log_then_apply_with(rec, None, self.fsync)
3567    }
3568
3569    /// Whether this frame must fsync under `policy`.
3570    ///
3571    /// Batched contract: user-visible batches (>1 mutation) fsync; single
3572    /// mutations do not. The dense rewrite wraps a single mutation in a
3573    /// `Batch([Intern.., <one *Id record>])`, so `Intern` records are excluded
3574    /// from the count — removing that filter would make every single-op write
3575    /// fsync under Batched (or, if the threshold were raised instead, skip a
3576    /// needed fsync for real two-op batches).
3577    fn wal_needs_sync(policy: FsyncPolicy, rec: &WalRecord) -> bool {
3578        match policy {
3579            FsyncPolicy::Relaxed => false,
3580            FsyncPolicy::Strict => true,
3581            FsyncPolicy::Batched => match rec {
3582                // Intern + one mutation is the single-op rewrite, not a user batch.
3583                WalRecord::Batch(inner) => {
3584                    inner
3585                        .iter()
3586                        .filter(|r| !matches!(r, WalRecord::Intern { .. }))
3587                        .count()
3588                        > 1
3589                }
3590                _ => false,
3591            },
3592        }
3593    }
3594
3595    /// # Apply-infallibility invariant (load-bearing)
3596    ///
3597    /// The ordering is: WAL append → fsync → apply. If `apply` returned `Err`
3598    /// for a `Batch` frame after a successful WAL write, the WAL would contain
3599    /// the full frame while in-memory state would reflect only the ops before
3600    /// the failure. On reopen, WAL replay would then apply the entire batch —
3601    /// diverging permanently from what the pre-crash process had in memory.
3602    ///
3603    /// For `Batch` frames this situation cannot arise because:
3604    /// - All validation runs via `commit_logged_batch`/`MutPreview` **before**
3605    ///   the WAL write. `MutPreview` uses the same `&mut self` that apply will
3606    ///   use, with no concurrent mutation between validation exit and apply entry.
3607    /// - Every `apply` arm for a validated op is either infallible by construction
3608    ///   (`InsertNode`, `RemoveProp`, `DeleteEdge`, `DeleteNode`), has idempotency
3609    ///   guards that return `Ok(())` (`CreateRule`, `DeleteRule`), or is
3610    ///   guaranteed-present by validation (`InsertEdge`/`SetProp` key lookups).
3611    /// - `on_node_changed` and `on_node_removed` return `()` — never `Err`.
3612    ///
3613    /// A `debug_assert!` below fires in debug builds if `apply` ever returns
3614    /// `Err` for a `Batch` frame, making any future regression immediately visible
3615    /// in tests rather than silently diverging crash-recovery behaviour.
3616    fn log_then_apply_with(
3617        &mut self,
3618        rec: WalRecord,
3619        ingest: Option<(String, usize)>,
3620        policy: FsyncPolicy,
3621    ) -> Result<()> {
3622        // Read-only guard: as-of instances must never write the WAL.
3623        if self.read_only {
3624            return Err(GraphError::ReadOnly);
3625        }
3626        // Degraded guard: fsync failure left WAL truncated; in-memory state
3627        // is ahead of the on-disk WAL, so further mutations would deepen the
3628        // divergence.  Reopen the database to recover.
3629        if self.degraded {
3630            return Err(GraphError::Io(std::io::Error::other(
3631                "database degraded after group-commit fsync failure; reopen required",
3632            )));
3633        }
3634        // Ensure retained provenance bytes are decoded into the live mutable
3635        // fields before any mutation touches self.engine.provenance.  This is a
3636        // no-op if provenance was never stored (fresh store) or has already been
3637        // consumed (subsequent mutations).  WAL replay calls apply() directly
3638        // and is covered by consume_retained_state_eager before replay.
3639        self.ensure_v8_base_sections_loaded();
3640        self.engine.ensure_provenance_loaded_mut();
3641        // Invariant (I-1): no stale deltas may enter from a previous apply.
3642        // If any engine method ever accumulates deltas before erroring, they would
3643        // contaminate the *next* commit's event stream. This assert fires in debug
3644        // builds, making any future regression visible at the earliest point.
3645        debug_assert_eq!(
3646            self.engine.pending_delta_count(),
3647            0,
3648            "stale engine deltas at log_then_apply_with entry — \
3649             a previous apply arm may have accumulated deltas before erroring; \
3650             the caller must drain_deltas() on any error path before returning"
3651        );
3652        self.fs.append(FileId::Wal, &encode_record(&rec))?;
3653        if Self::wal_needs_sync(policy, &rec) {
3654            self.fs.sync(FileId::Wal)?;
3655        }
3656        // Marker writing always needs the engine deltas, but the engine only
3657        // accumulates them when emit_deltas is true (normally gated on subscribers
3658        // or views being present).  Enable emission for this apply if it is
3659        // currently off, then restore the original state unconditionally via an
3660        // RAII guard — this prevents a panic in apply() from leaking the flag.
3661        // The same guard resets the engine's transient chaining state. A panic
3662        // unwinding out of a rule hook would otherwise leave `chain_depth`
3663        // non-zero, which makes every later `begin_chain` decide chaining is
3664        // already running and silently switch it off for good.
3665        struct RestoreEmitDeltas(*mut RuleEngine, bool);
3666        impl Drop for RestoreEmitDeltas {
3667            fn drop(&mut self) {
3668                // SAFETY: pointer into self (GraphDb); guard is dropped within
3669                // this frame before log_then_apply_with returns.
3670                unsafe {
3671                    (*self.0).set_emit_deltas(self.1);
3672                    (*self.0).reset_chain_state();
3673                }
3674            }
3675        }
3676        let original_emit = self.engine.emit_deltas();
3677        if !original_emit {
3678            self.engine.set_emit_deltas(true);
3679        }
3680        // SAFETY: raw pointer into self; guard dropped within this frame.
3681        let _emit_guard = RestoreEmitDeltas(&mut self.engine as *mut _, original_emit);
3682
3683        let apply_result = self.apply(&rec);
3684        // For Batch frames, post-validation apply must be infallible (see above).
3685        // A debug_assert here catches any future change that makes apply fallible
3686        // before the caller notices via silent WAL/memory divergence.
3687        if matches!(&rec, WalRecord::Batch(_)) {
3688            debug_assert!(
3689                apply_result.is_ok(),
3690                "Batch apply returned Err after successful WAL write — \
3691                 the validate-then-apply invariant has been violated; \
3692                 see log_then_apply_with invariant doc"
3693            );
3694        }
3695        if apply_result.is_err() {
3696            // Discard any partial deltas accumulated by the failed apply.
3697            // They must not ride the next commit's event stream (I-1).
3698            // _emit_guard restores emit_deltas on drop automatically.
3699            let _ = self.engine.drain_deltas();
3700            let _ = self.engine.take_rebuild_needed();
3701            apply_result?;
3702        }
3703        self.commit_seq += 1;
3704        let seq = self.commit_seq;
3705        // Update per-node last-change map for the committed record.
3706        // Must happen after commit_seq is incremented so the seq is correct.
3707        self.update_last_change_from_rec(&rec, seq);
3708        // Drain engine deltas and distribute to subscribers before the existing
3709        // MutationEvent sink fires — both happen post-fsync, post-apply.
3710        // _emit_guard restores emit_deltas after this line when it drops.
3711        let engine_deltas = self.engine.drain_deltas();
3712
3713        // Append history-marker WAL records for any derived-edge changes so
3714        // that `edge_history` and `was_linked` can surface rule-attributed
3715        // events. Markers are STATE NO-OPS during replay; they are written
3716        // without an additional fsync (the triggering commit's sync already
3717        // happened; the next commit's sync covers these lazily).
3718        if !engine_deltas.is_empty() {
3719            let markers: Vec<WalRecord> = engine_deltas
3720                .iter()
3721                .map(|d| {
3722                    if d.fired {
3723                        WalRecord::DerivedEdgeAdded {
3724                            rule: d.rule.clone(),
3725                            edge_type: d.edge_type.clone(),
3726                            src_key: d.src_key.clone(),
3727                            dst_key: d.dst_key.clone(),
3728                        }
3729                    } else {
3730                        WalRecord::DerivedEdgeRetracted {
3731                            rule: d.rule.clone(),
3732                            edge_type: d.edge_type.clone(),
3733                            src_key: d.src_key.clone(),
3734                            dst_key: d.dst_key.clone(),
3735                        }
3736                    }
3737                })
3738                .collect();
3739            let marker_frame = if markers.len() == 1 {
3740                markers.into_iter().next().unwrap()
3741            } else {
3742                WalRecord::Batch(markers)
3743            };
3744            // Ignore append errors: markers are best-effort history
3745            // annotations. Losing them does not affect state correctness.
3746            let _ = self.fs.append(FileId::Wal, &encode_record(&marker_frame));
3747        }
3748
3749        // Record MVCC CommitDelta for the epoch reader.  The WAL record is
3750        // stored as-is (including any nested Batch / Intern records); the
3751        // ReaderSnapshot's apply_one function handles all variants.
3752        {
3753            let derived_inserts = engine_deltas
3754                .iter()
3755                .filter(|d| d.fired)
3756                .map(|d| (d.etype_sym, d.src_id, d.dst_id))
3757                .collect();
3758            let derived_deletes = engine_deltas
3759                .iter()
3760                .filter(|d| !d.fired)
3761                .map(|d| (d.etype_sym, d.src_id, d.dst_id))
3762                .collect();
3763            let delta = Arc::new(crate::reader::CommitDelta {
3764                records: vec![rec.clone()],
3765                derived_inserts,
3766                derived_deletes,
3767            });
3768            self.delta_tail.push(delta);
3769            self.commits_since_fold += 1;
3770            if self.commits_since_fold >= crate::reader::FOLD_EVERY_K {
3771                self.fold_now();
3772            }
3773        }
3774
3775        if self.defer_events {
3776            // Group-commit drain thread: hold events until after the group
3777            // fsync so subscribers only observe durable data (R2).
3778            self.deferred_events.push(DeferredEvent {
3779                rec: rec.clone(),
3780                engine_deltas,
3781                seq,
3782                ingest,
3783            });
3784        } else {
3785            self.distribute_events(&rec, &engine_deltas, seq);
3786            self.emit_committed(&rec, ingest);
3787        }
3788        // Drift is only known after apply, so auto-rebuild cannot join the
3789        // triggering op's WAL frame. Issue RebuildRule as a second commit.
3790        // Skip when `rec` is itself RebuildRule: rebuild resets drift, so a
3791        // retrigger loop is impossible if the fit succeeded, but we still
3792        // drain the flag so a leftover cannot re-enter.
3793        let rebuilds = self.engine.take_rebuild_needed();
3794        if !matches!(&rec, WalRecord::RebuildRule { .. }) {
3795            let mut failed = Vec::new();
3796            for name in rebuilds {
3797                if self.engine.rules().any(|r| r.name == name) {
3798                    // User op is already durable. A failed second commit must
3799                    // not surface as the caller's error.
3800                    if let Err(e) =
3801                        self.log_then_apply(WalRecord::RebuildRule { name: name.clone() })
3802                    {
3803                        eprintln!(
3804                            "auto-rebuild of rule {name:?} failed after durable user commit: {e}"
3805                        );
3806                        failed.push(name);
3807                    }
3808                }
3809            }
3810            for name in failed {
3811                self.engine.queue_rebuild_needed(name);
3812            }
3813        }
3814        Ok(())
3815    }
3816
3817    /// Install a post-commit hook. Replaces any previous sink.
3818    ///
3819    /// The sink runs inside `log_then_apply` after a successful
3820    /// durable commit, while the caller still holds `&mut self`. When this
3821    /// database is behind a [`crate::SharedDb`], that means the **write
3822    /// guard is held**. The sink must never call `read` / `write` (or any
3823    /// other method) on the same `SharedDb` — the `RwLock` is not
3824    /// re-entrant and doing so deadlocks. The sink is `Send + Sync`;
3825    /// `std::sync::mpsc::Sender` is not `Sync` and will not type-check.
3826    /// Intended examples: `std::sync::mpsc::SyncSender`,
3827    /// `tokio::sync::mpsc::Sender`, `tokio::sync::broadcast::Sender`
3828    /// (non-blocking `send`), or `Arc<Mutex<Vec<MutationEvent>>>`.
3829    pub fn set_event_sink(&mut self, sink: Box<dyn Fn(MutationEvent) + Send + Sync>) {
3830        self.event_sink = Some(sink);
3831    }
3832
3833    /// Whether a post-commit event sink is currently installed.
3834    pub fn has_event_sink(&self) -> bool {
3835        self.event_sink.is_some()
3836    }
3837
3838    /// Set WAL fsync cadence. Default [`FsyncPolicy::Strict`].
3839    pub fn set_fsync_policy(&mut self, p: FsyncPolicy) {
3840        self.fsync = p;
3841    }
3842
3843    /// Return the current WAL fsync cadence.
3844    pub fn fsync_policy(&self) -> FsyncPolicy {
3845        self.fsync
3846    }
3847
3848    // ── Group-commit event deferral ───────────────────────────────────────────
3849
3850    /// Enable or disable deferred event mode.
3851    ///
3852    /// When `true`, event notifications (subscription `DbEvent`s and legacy
3853    /// `MutationEvent` sink calls) are buffered rather than fired immediately.
3854    /// Call [`flush_deferred_events`] after the group fsync to deliver them,
3855    /// or [`discard_deferred_events`] if the fsync failed and the group must
3856    /// be treated as lost.
3857    pub fn set_deferred_events_mode(&mut self, defer: bool) {
3858        self.defer_events = defer;
3859    }
3860
3861    /// Fire all buffered events accumulated since [`set_deferred_events_mode`]
3862    /// was set to true.  Clears the buffer.
3863    ///
3864    /// Called by the drain thread AFTER a successful group fsync, so
3865    /// subscribers observe only data that is durably on disk.
3866    pub fn flush_deferred_events(&mut self) {
3867        let events = std::mem::take(&mut self.deferred_events);
3868        for de in events {
3869            self.distribute_events(&de.rec, &de.engine_deltas, de.seq);
3870            self.emit_committed(&de.rec, de.ingest);
3871        }
3872    }
3873
3874    /// Discard all buffered events without firing them.
3875    ///
3876    /// Called by the drain thread when a group fsync fails: the WAL has been
3877    /// truncated back to the pre-group offset, so the committed-but-unsynced
3878    /// ops must not be observable to subscribers.
3879    pub fn discard_deferred_events(&mut self) {
3880        self.deferred_events.clear();
3881    }
3882
3883    // ── Degraded state ────────────────────────────────────────────────────────
3884
3885    /// Mark this database as degraded.
3886    ///
3887    /// Called by the group-commit drain thread after a group fsync failure and
3888    /// WAL truncation: the in-memory state is now ahead of the on-disk WAL, so
3889    /// further mutations would deepen the divergence.  All subsequent calls to
3890    /// [`log_then_apply_with`] return `Err` until the database is reopened.
3891    pub fn set_degraded(&mut self) {
3892        self.degraded = true;
3893    }
3894
3895    fn emit(&self, ev: MutationEvent) {
3896        if let Some(sink) = &self.event_sink {
3897            sink(ev);
3898        }
3899    }
3900
3901    fn emit_committed(&self, rec: &WalRecord, ingest: Option<(String, usize)>) {
3902        match rec {
3903            WalRecord::Batch(inner) => {
3904                for r in inner {
3905                    if let Some(ev) = event_from_record(r, &self.syms, &self.ids) {
3906                        self.emit(ev);
3907                    }
3908                }
3909                match ingest {
3910                    Some((label, inserted)) => {
3911                        self.emit(MutationEvent::Ingested { label, inserted })
3912                    }
3913                    None => {
3914                        let ops = inner
3915                            .iter()
3916                            .filter(|r| !matches!(r, WalRecord::Intern { .. }))
3917                            .count();
3918                        if ops > 1 {
3919                            self.emit(MutationEvent::BatchApplied { ops });
3920                        }
3921                    }
3922                }
3923            }
3924            other => {
3925                if let Some(ev) = event_from_record(other, &self.syms, &self.ids) {
3926                    self.emit(ev);
3927                }
3928            }
3929        }
3930    }
3931
3932    // -----------------------------------------------------------------------
3933    // Subscription API
3934    // -----------------------------------------------------------------------
3935
3936    /// Distribute post-commit events to all live subscribers.
3937    ///
3938    /// Build a row-key → row-data map from a [`ResultSet`].
3939    ///
3940    /// Each row is serialized to JSON to form its key; a debug fallback is used
3941    /// if serialization fails. Used by both the initial-seed path in
3942    /// [`Self::subscribe_query`] and the per-commit diff path in
3943    /// [`Self::distribute_events`] to keep the two in sync.
3944    fn result_to_row_map(
3945        result: &core_query::ResultSet,
3946    ) -> std::collections::HashMap<String, Vec<Option<Value>>> {
3947        (0..result.len())
3948            .map(|i| {
3949                let row = result.row(i).to_vec();
3950                let key = serde_json::to_string(&row).unwrap_or_else(|_| format!("{row:?}"));
3951                (key, row)
3952            })
3953            .collect()
3954    }
3955
3956    /// Collect the set of label syms touched by a WAL record.
3957    ///
3958    /// Returns `Some(set)` when every record in this commit can be attributed to
3959    /// a known label sym. Returns `None` when the commit must not be skipped:
3960    /// edge records, unresolvable key→label lookups, or any record type not in
3961    /// the explicit handled set.
3962    ///
3963    /// Handled record types and their actions:
3964    /// - `InsertNode`   → look up label in interner (fails → None)
3965    /// - `InsertNodeId` → label sym is carried directly
3966    /// - `SetProp`      → resolve key→id→label (fails → None)
3967    /// - `DeleteNode`   → resolve key→id→label (fails → None)
3968    /// - `Batch`        → recurse into every inner record
3969    /// - `InsertEdge`, `DeleteEdge`, `InsertEdgeId` → always None (edge records)
3970    /// - everything else → None (conservative)
3971    fn commit_touched_labels(
3972        rec: &WalRecord,
3973        syms: &Interner,
3974        ids: &IdMap,
3975        labels: &[u32],
3976    ) -> Option<BTreeSet<u32>> {
3977        let mut out = BTreeSet::new();
3978        if Self::collect_touched_labels(rec, syms, ids, labels, &mut out) {
3979            Some(out)
3980        } else {
3981            None
3982        }
3983    }
3984
3985    fn collect_touched_labels(
3986        rec: &WalRecord,
3987        syms: &Interner,
3988        ids: &IdMap,
3989        labels: &[u32],
3990        out: &mut BTreeSet<u32>,
3991    ) -> bool {
3992        match rec {
3993            // String-key insert: the dense rewrite converts this to
3994            // [Intern, InsertNodeId], so this arm fires only for legacy WAL
3995            // records written before the dense path was added.
3996            WalRecord::InsertNode { label, .. } => {
3997                if let Some(sym) = syms.get(label) {
3998                    out.insert(sym);
3999                    true
4000                } else {
4001                    false
4002                }
4003            }
4004            // Dense-id insert (produced by rewrite_wal_dense for every
4005            // insert_node call in the current codebase).
4006            WalRecord::InsertNodeId { label, .. } => {
4007                out.insert(*label);
4008                true
4009            }
4010            // String-key prop set: dense path converts to [Intern, SetPropId].
4011            WalRecord::SetProp { key, .. } => {
4012                if let Some(sym) = Self::resolve_key_label_sym(key, ids, labels) {
4013                    out.insert(sym);
4014                    true
4015                } else {
4016                    false
4017                }
4018            }
4019            // Dense-id prop set (produced by rewrite_wal_dense for set_prop).
4020            WalRecord::SetPropId { id, .. } => {
4021                if let Some(sym) = labels.get(*id as usize).copied().filter(|&s| s != u32::MAX) {
4022                    out.insert(sym);
4023                    true
4024                } else {
4025                    false
4026                }
4027            }
4028            WalRecord::DeleteNode { key } => {
4029                if let Some(sym) = Self::resolve_key_label_sym(key, ids, labels) {
4030                    out.insert(sym);
4031                    true
4032                } else {
4033                    false
4034                }
4035            }
4036            WalRecord::Batch(inner) => inner
4037                .iter()
4038                .all(|r| Self::collect_touched_labels(r, syms, ids, labels, out)),
4039            // Intern is a pure metadata record — it does not touch any node's
4040            // label and is safe to skip for the label-skip predicate.
4041            WalRecord::Intern { .. } => true,
4042            // Edge records: always re-execute (edges can change join results).
4043            WalRecord::InsertEdge { .. }
4044            | WalRecord::DeleteEdge { .. }
4045            | WalRecord::InsertEdgeId { .. } => false,
4046            _ => false,
4047        }
4048    }
4049
4050    /// Resolve a node key to its label sym via the dense id table.
4051    /// Returns `None` if the key is unknown or the label is a tombstone sentinel.
4052    fn resolve_key_label_sym(key: &str, ids: &IdMap, labels: &[u32]) -> Option<u32> {
4053        let id = ids.get(key)?;
4054        let sym = labels.get(id as usize).copied()?;
4055        (sym != u32::MAX).then_some(sym)
4056    }
4057
4058    /// Distribute post-commit events to all live subscribers.
4059    ///
4060    /// Called from `log_then_apply_with` after apply + fsync, before the
4061    /// legacy MutationEvent sink. Prunes dead `Weak` entries in-place.
4062    ///
4063    /// Query subscriptions (subscribe_query) re-execute their plan on every
4064    /// call and diff the result against the previous run. Zero overhead when
4065    /// no query subscriptions are active.
4066    fn distribute_events(&mut self, rec: &WalRecord, engine_deltas: &[EngineEdgeDelta], seq: u64) {
4067        if self.subscriptions.is_empty() && self.query_subscriptions.is_empty() {
4068            return;
4069        }
4070
4071        if !self.subscriptions.is_empty() {
4072            // Build write events from the WAL record.
4073            let write_events: Vec<DbEvent> =
4074                Self::write_events_from_record(rec, seq, &self.syms, &self.ids);
4075
4076            // Build edge events from engine deltas.  Weight is looked up from
4077            // edge_props at distribution time (after apply), so it's always fresh.
4078            let edge_events: Vec<DbEvent> = engine_deltas
4079                .iter()
4080                .map(|d| {
4081                    if d.fired {
4082                        // The score lives under the rule's declared weight_prop,
4083                        // which is not always the literal "weight".
4084                        let prop = self
4085                            .engine
4086                            .rules()
4087                            .find(|r| r.name == d.rule)
4088                            .and_then(|r| r.weight_prop.as_deref());
4089                        let weight = prop.and_then(|p| {
4090                            self.edge_props
4091                                .get(d.etype_sym, d.src_id, d.dst_id, p)
4092                                .and_then(|v| {
4093                                    if let core_storage::Value::Float(f) = v {
4094                                        Some(*f)
4095                                    } else {
4096                                        None
4097                                    }
4098                                })
4099                        });
4100                        DbEvent::EdgeFired {
4101                            rule: d.rule.clone(),
4102                            src_key: d.src_key.clone(),
4103                            dst_key: d.dst_key.clone(),
4104                            edge_type: d.edge_type.clone(),
4105                            weight,
4106                            commit_seq: seq,
4107                        }
4108                    } else {
4109                        DbEvent::EdgeRetracted {
4110                            rule: d.rule.clone(),
4111                            src_key: d.src_key.clone(),
4112                            dst_key: d.dst_key.clone(),
4113                            edge_type: d.edge_type.clone(),
4114                            commit_seq: seq,
4115                        }
4116                    }
4117                })
4118                .collect();
4119
4120            // Prune dead entries; push matching events to live ones.
4121            self.subscriptions.retain(|entry| {
4122                let Some(inner) = entry.inner.upgrade() else {
4123                    return false;
4124                };
4125                for ev in &write_events {
4126                    if event_matches(ev, &entry.filter) {
4127                        inner.push(ev.clone());
4128                    }
4129                }
4130                for ev in &edge_events {
4131                    if event_matches(ev, &entry.filter) {
4132                        inner.push(ev.clone());
4133                    }
4134                }
4135                true
4136            });
4137
4138            // Turn off delta accumulation if all subscribers dropped and no views remain.
4139            if self.subscriptions.is_empty() && self.view_store.is_empty() {
4140                self.engine.set_emit_deltas(false);
4141            }
4142        }
4143
4144        // Query subscriptions: full re-run per commit, then diff rows.
4145        // IMPORTANT: full re-execution on every commit — use LIMIT to bound cost.
4146        // Differential evaluation is roadmap / Phase 5.
4147        if !self.query_subscriptions.is_empty() {
4148            // Take the list out so we can call self.view() without borrow conflict.
4149            let mut query_subs = std::mem::take(&mut self.query_subscriptions);
4150            let empty_params = BTreeMap::new();
4151            query_subs.retain_mut(|entry| {
4152                let Some(inner) = entry.inner.upgrade() else {
4153                    return false; // subscriber dropped — prune
4154                };
4155                // Label-skip: if the plan has a known scan label and this commit
4156                // can be proven to touch only different labels (and no rule-derived
4157                // edge deltas fired), the result set cannot have changed — skip.
4158                if let Some(scan_sym) = entry.scan_label {
4159                    if engine_deltas.is_empty() {
4160                        let touched =
4161                            Self::commit_touched_labels(rec, &self.syms, &self.ids, &self.labels);
4162                        if touched.map(|t| !t.contains(&scan_sym)).unwrap_or(false) {
4163                            return true; // safe to skip — result set unchanged
4164                        }
4165                    }
4166                }
4167                QUERY_SUB_EXECS_TL.with(|c| c.set(c.get() + 1));
4168                let result = match execute(&self.view(), &entry.ops, &Params(&empty_params)) {
4169                    Ok(r) => r,
4170                    Err(e) => {
4171                        // Keep the subscription alive; skip the diff for this commit.
4172                        // Re-run errors are transient (e.g., planner change) and
4173                        // self-heal when the next commit succeeds.
4174                        eprintln!("[mushroomdb] subscribe_query re-run failed: {e}");
4175                        return true;
4176                    }
4177                };
4178                // Build new row map: serialized-key → row data.
4179                let new_row_map = Self::result_to_row_map(&result);
4180                // Removed rows: in prev but not in new.
4181                for (key, row) in &entry.prev_row_map {
4182                    if !new_row_map.contains_key(key) {
4183                        inner.push(DbEvent::QueryRowRemoved {
4184                            columns: entry.columns.clone(),
4185                            row: row.clone(),
4186                        });
4187                    }
4188                }
4189                // Added rows: in new but not in prev.
4190                for (key, row) in &new_row_map {
4191                    if !entry.prev_row_map.contains_key(key) {
4192                        inner.push(DbEvent::QueryRowAdded {
4193                            columns: entry.columns.clone(),
4194                            row: row.clone(),
4195                        });
4196                    }
4197                }
4198                entry.prev_row_map = new_row_map;
4199                true
4200            });
4201            self.query_subscriptions = query_subs;
4202        }
4203    }
4204
4205    /// Returns `true` if any live subscriber or view definition requires delta
4206    /// accumulation. Used to set `engine.emit_deltas` on subscribe/view DDL.
4207    fn needs_emit_deltas(&self) -> bool {
4208        !self.view_store.is_empty()
4209            || self
4210                .subscriptions
4211                .iter()
4212                .any(|e| e.inner.upgrade().is_some())
4213    }
4214
4215    /// Convert a WAL record into `DbEvent` write events with the given seq.
4216    fn write_events_from_record(
4217        rec: &WalRecord,
4218        seq: u64,
4219        intern: &Interner,
4220        ids: &IdMap,
4221    ) -> Vec<DbEvent> {
4222        match rec {
4223            WalRecord::InsertNode { label, key, .. } => vec![DbEvent::NodeInserted {
4224                label: label.clone(),
4225                key: key.clone(),
4226                commit_seq: seq,
4227            }],
4228            // *Id arms run after a successful apply, so resolution can only
4229            // fail on a programming error. Skip the event rather than emit a
4230            // fabricated "" that clients can't tell from a real empty value
4231            // (mirrors event_from_record returning None).
4232            WalRecord::InsertNodeId { label, key, .. } => intern
4233                .resolve(*label)
4234                .map(|label| DbEvent::NodeInserted {
4235                    label: label.to_string(),
4236                    key: key.clone(),
4237                    commit_seq: seq,
4238                })
4239                .into_iter()
4240                .collect(),
4241            WalRecord::SetProp { key, field, .. } => vec![DbEvent::PropSet {
4242                key: key.clone(),
4243                field: field.clone(),
4244                commit_seq: seq,
4245            }],
4246            WalRecord::SetPropId { id, field, .. } => ids
4247                .key_of(*id)
4248                .zip(intern.resolve(*field))
4249                .map(|(key, field)| DbEvent::PropSet {
4250                    key: key.to_string(),
4251                    field: field.to_string(),
4252                    commit_seq: seq,
4253                })
4254                .into_iter()
4255                .collect(),
4256            WalRecord::RemoveProp { key, field } => vec![DbEvent::PropRemoved {
4257                key: key.clone(),
4258                field: field.clone(),
4259                commit_seq: seq,
4260            }],
4261            WalRecord::InsertEdge {
4262                edge_type,
4263                src_key,
4264                dst_key,
4265            } => vec![DbEvent::EdgeInserted {
4266                edge_type: edge_type.clone(),
4267                src: src_key.clone(),
4268                dst: dst_key.clone(),
4269                commit_seq: seq,
4270            }],
4271            WalRecord::InsertEdgeId { etype, src, dst } => (|| {
4272                Some(DbEvent::EdgeInserted {
4273                    edge_type: intern.resolve(*etype)?.to_string(),
4274                    src: ids.key_of(*src)?.to_string(),
4275                    dst: ids.key_of(*dst)?.to_string(),
4276                    commit_seq: seq,
4277                })
4278            })()
4279            .into_iter()
4280            .collect(),
4281            WalRecord::DeleteEdge {
4282                edge_type,
4283                src_key,
4284                dst_key,
4285            } => vec![DbEvent::EdgeDeleted {
4286                edge_type: edge_type.clone(),
4287                src: src_key.clone(),
4288                dst: dst_key.clone(),
4289                commit_seq: seq,
4290            }],
4291            WalRecord::DeleteNode { key } => vec![DbEvent::NodeDeleted {
4292                key: key.clone(),
4293                commit_seq: seq,
4294            }],
4295            WalRecord::Batch(inner) => inner
4296                .iter()
4297                .flat_map(|r| Self::write_events_from_record(r, seq, intern, ids))
4298                .collect(),
4299            WalRecord::CreateRule { .. }
4300            | WalRecord::DeleteRule { .. }
4301            | WalRecord::RebuildRule { .. }
4302            | WalRecord::CreateView { .. }
4303            | WalRecord::DeleteView { .. }
4304            | WalRecord::EnableFulltext { .. }
4305            | WalRecord::DisableFulltext { .. }
4306            | WalRecord::EnableIndex { .. }
4307            | WalRecord::DisableIndex { .. }
4308            | WalRecord::Intern { .. }
4309            // History markers produce no DbEvent — the engine delta already
4310            // fired the EdgeFired/EdgeRetracted subscription events.
4311            | WalRecord::DerivedEdgeAdded { .. }
4312            | WalRecord::DerivedEdgeRetracted { .. }
4313            | WalRecord::RenameNode { .. } => vec![],
4314        }
4315    }
4316
4317    /// Subscribe to edge-fire and edge-retract events for one named rule.
4318    ///
4319    /// Returns `Err(GraphError::RuleNotFound)` if `rule_name` is not
4320    /// currently registered. Dropping the returned [`Subscription`] handle
4321    /// unregisters the subscriber — no further events are queued, no
4322    /// resources leak.
4323    pub fn subscribe_rule(&mut self, rule_name: &str) -> core_storage::Result<Subscription> {
4324        if self.read_only {
4325            return Err(core_storage::GraphError::ReadOnly);
4326        }
4327        if !self.engine.rules().any(|r| r.name == rule_name) {
4328            return Err(core_storage::GraphError::RuleNotFound {
4329                name: rule_name.to_string(),
4330            });
4331        }
4332        let inner = SubInner::new(self.sub_capacity());
4333        self.subscriptions.push(SubEntry {
4334            filter: SubFilter::Rule(rule_name.to_string()),
4335            inner: std::sync::Arc::downgrade(&inner),
4336        });
4337        self.engine.set_emit_deltas(true);
4338        Ok(Subscription(inner))
4339    }
4340
4341    /// Subscribe to edge-fire and edge-retract events for **all** rules.
4342    ///
4343    /// Returns `Err(GraphError::ReadOnly)` if called on an as-of instance —
4344    /// as-of instances never commit, so `distribute_events` never runs and the
4345    /// subscription would never deliver events.
4346    pub fn subscribe_all_rules(&mut self) -> core_storage::Result<Subscription> {
4347        if self.read_only {
4348            return Err(core_storage::GraphError::ReadOnly);
4349        }
4350        let inner = SubInner::new(self.sub_capacity());
4351        self.subscriptions.push(SubEntry {
4352            filter: SubFilter::AllRules,
4353            inner: std::sync::Arc::downgrade(&inner),
4354        });
4355        self.engine.set_emit_deltas(true);
4356        Ok(Subscription(inner))
4357    }
4358
4359    /// Subscribe to write events: node insert/delete, prop set/remove.
4360    ///
4361    /// Does not include edge-fire / edge-retract (rule-derived edge events).
4362    ///
4363    /// Returns `Err(GraphError::ReadOnly)` if called on an as-of instance —
4364    /// as-of instances never commit, so `distribute_events` never runs and the
4365    /// subscription would never deliver events.
4366    pub fn subscribe_writes(&mut self) -> core_storage::Result<Subscription> {
4367        if self.read_only {
4368            return Err(core_storage::GraphError::ReadOnly);
4369        }
4370        let inner = SubInner::new(self.sub_capacity());
4371        self.subscriptions.push(SubEntry {
4372            filter: SubFilter::Writes,
4373            inner: std::sync::Arc::downgrade(&inner),
4374        });
4375        self.engine.set_emit_deltas(true);
4376        Ok(Subscription(inner))
4377    }
4378
4379    /// Subscribe to incremental Cypher query results.
4380    ///
4381    /// Parses and plans `cypher`; rejects the query if the plan is not in the
4382    /// allowlisted subset (see [`core_query::cypher::is_subscribable`]):
4383    ///   - `MATCH (n:Label) WHERE … RETURN … [LIMIT n]`
4384    ///   - `MATCH (a)-[r:TYPE]->(b) RETURN … [LIMIT n]`  (exactly one hop)
4385    ///
4386    /// SKIP is not supported — it shifts the result window on every commit,
4387    /// causing spurious Added/Removed churn for rows whose data never changed.
4388    /// Multi-hop Expand chains are not supported; each additional MATCH clause
4389    /// widens scope beyond the documented single-scan / single-hop subset.
4390    ///
4391    /// After each successful commit, the plan is **fully re-executed** and the
4392    /// result is diffed against the previous run. Added rows produce
4393    /// [`DbEvent::QueryRowAdded`]; removed rows produce
4394    /// [`DbEvent::QueryRowRemoved`].
4395    ///
4396    /// **Full re-run per commit; use LIMIT to bound execution cost.**
4397    /// The existing 1 M intermediate-row cap applies. Differential evaluation
4398    /// is roadmap / Phase 5.
4399    ///
4400    /// Returns `Err(GraphError::ReadOnly)` if called on an as-of instance —
4401    /// as-of instances never commit, so `distribute_events` never runs and the
4402    /// subscription would never deliver events.
4403    ///
4404    /// Returns `Err(GraphError::QueryError)` if the query fails to parse, plan,
4405    /// or if the plan shape is not in the allowlist.
4406    pub fn subscribe_query(&mut self, cypher: &str) -> Result<Subscription> {
4407        if self.read_only {
4408            return Err(GraphError::ReadOnly);
4409        }
4410        let tokens = lex(cypher).map_err(|e| GraphError::QueryError {
4411            detail: format!("lex: {e}"),
4412        })?;
4413        let ast = parse(&tokens).map_err(|e| GraphError::QueryError {
4414            detail: format!("parse: {e}"),
4415        })?;
4416        let ops = plan(&ast).map_err(|e| GraphError::QueryError {
4417            detail: format!("plan: {e}"),
4418        })?;
4419        if !is_subscribable(&ops) {
4420            return Err(GraphError::QueryError {
4421                detail: "subscribe_query only supports allowlisted plan shapes: \
4422                         MATCH (n:Label) WHERE … RETURN … [LIMIT n] or \
4423                         MATCH (a)-[r:TYPE]->(b) RETURN … [LIMIT n] (exactly one hop). \
4424                         Not supported: multi-hop Expand chains, SKIP (creates \
4425                         unstable offset windows), ORDER BY, DISTINCT, aggregates, \
4426                         variable-length paths, OPTIONAL MATCH, WITH, UNWIND. \
4427                         Use LIMIT to bound re-execution cost."
4428                    .to_string(),
4429            });
4430        }
4431        // Execute once to capture initial state (initial rows are not emitted as
4432        // events — the subscriber learns the baseline via the first query call).
4433        let empty_params = BTreeMap::new();
4434        let initial = execute(&self.view(), &ops, &Params(&empty_params)).map_err(|e| {
4435            GraphError::QueryError {
4436                detail: format!("execute: {e}"),
4437            }
4438        })?;
4439        let columns = initial.columns().to_vec();
4440        let prev_row_map = Self::result_to_row_map(&initial);
4441        let inner = SubInner::new(self.sub_capacity());
4442        // Derive the scan-label sym for the commit-skip fast-path.  Any Expand op
4443        // or unrecognized leading scan → None (always re-execute).
4444        let scan_label = extract_scan_label(&ops, &mut self.syms);
4445        self.query_subscriptions.push(QuerySubEntry {
4446            ops,
4447            columns,
4448            prev_row_map,
4449            inner: std::sync::Arc::downgrade(&inner),
4450            scan_label,
4451        });
4452        Ok(Subscription(inner))
4453    }
4454
4455    /// Queue capacity used for new subscriptions.
4456    fn sub_capacity(&self) -> usize {
4457        self.sub_capacity
4458    }
4459
4460    /// Override per-subscriber queue capacity for subsequently created
4461    /// subscriptions on this db instance.
4462    ///
4463    /// Default is [`DEFAULT_SUB_CAPACITY`] (65,536 events). Use a smaller
4464    /// value in tests to exercise the [`DbEvent::Lagged`] path without
4465    /// generating tens of thousands of events.
4466    ///
4467    /// This is a test-support escape hatch. Calling it in production reduces
4468    /// subscriber reliability (more Lagged events). It is hidden from rustdoc
4469    /// to discourage accidental production use.
4470    #[doc(hidden)]
4471    pub fn set_sub_capacity(&mut self, capacity: usize) {
4472        self.sub_capacity = capacity;
4473    }
4474
4475    // -----------------------------------------------------------------------
4476
4477    /// Start an atomic batch.
4478    ///
4479    /// The returned [`BatchBuilder`] borrows `self` mutably until
4480    /// [`BatchBuilder::commit`]. Builder methods queue ops only — no
4481    /// validation, no WAL I/O. `commit` validates every queued op against
4482    /// live state plus preceding ops in this batch (duplicate key inside
4483    /// the batch is `Err`; an edge between two nodes created earlier in
4484    /// the batch is valid; `delete_node` then insert of the same key is a
4485    /// fresh identity). Validation never mutates the database. Any failure
4486    /// leaves WAL bytes and in-memory state identical to before `commit`.
4487    /// On success, one `WalRecord::Batch` frame is appended (one fsync)
4488    /// and each inner record is applied in order so rules fire per record.
4489    /// An empty batch, or a batch of only no-ops, writes zero WAL bytes.
4490    ///
4491    /// **Rule-window limitation:** batch validation cannot see edges that a
4492    /// rule created earlier in the *same* batch will derive at apply time, so
4493    /// a `delete_edge` / `insert_edge` in that window is silently no-oped
4494    /// where sequential calls would return `Err(RuleOwned)`. State integrity
4495    /// is unaffected (idempotent apply, provenance intact). Create rules in
4496    /// their own batch, or sequentially, when later ops may touch derived
4497    /// edges.
4498    pub fn batch(&mut self) -> BatchBuilder<'_, F> {
4499        BatchBuilder {
4500            db: self,
4501            ops: Vec::new(),
4502        }
4503    }
4504
4505    /// Closure-style atomic write batch.
4506    ///
4507    /// Equivalent to calling [`GraphDb::batch`], invoking `build` to queue ops,
4508    /// then committing. All ops queued inside `build` are validated in order and
4509    /// committed as a single `WalRecord::Batch` frame (one fsync). Rules fire
4510    /// once per inner record, in order, after commit — semantically identical to
4511    /// sequential single-op writes.
4512    ///
4513    /// **Error semantics — validate-then-apply.** `build` queues ops without
4514    /// touching the database. [`BatchBuilder::commit`] validates every op against
4515    /// live state plus earlier ops in this batch before writing anything. If op N
4516    /// fails validation (duplicate key, unknown key, rule-owned edge, …) the
4517    /// entire batch is rejected: no WAL bytes are written and no in-memory state
4518    /// changes. The database is identical to its state before `write_batch` was
4519    /// called.
4520    ///
4521    /// **Atomicity is crash-level, NOT isolation-level.** On replay after a crash,
4522    /// a partial (torn) `Batch` frame applies NONE of its ops — the frame is
4523    /// either fully applied or not at all. However, while applying a committed
4524    /// batch, concurrent readers may observe intermediate states as ops are applied
4525    /// sequentially in memory. There is no interactive transaction isolation in v1.
4526    /// This is documented as "crash-atomic write batches; no interactive
4527    /// transactions or read isolation."
4528    ///
4529    /// **Returns** `(nodes_inserted, edges_inserted)`. An empty or all-noop batch
4530    /// writes zero WAL bytes and returns `(0, 0)`.
4531    ///
4532    /// # Example
4533    ///
4534    /// ```rust,ignore
4535    /// let (nodes, edges) = db.write_batch(|b| {
4536    ///     b.insert_node("Person", "alice", vec![("age".into(), Value::Int(30))]);
4537    ///     b.insert_node("Person", "bob", vec![]);
4538    ///     b.insert_edge("KNOWS", "alice", "bob");
4539    ///     b.set_prop("alice", "role", Value::Str("admin".into()));
4540    ///     b.delete_node("old_key");
4541    /// })?;
4542    /// // One fsync; on crash replay: all five ops land or none do.
4543    /// ```
4544    pub fn write_batch<C>(&mut self, build: C) -> Result<(usize, usize)>
4545    where
4546        C: FnOnce(&mut BatchBuilder<'_, F>),
4547    {
4548        let mut b = self.batch();
4549        build(&mut b);
4550        b.commit()
4551    }
4552
4553    /// Insert `rows` as nodes of `label`. One call is one atomic batch:
4554    /// auto-declared KeyMatch rules (if any) first, then the accepted node
4555    /// inserts, so incremental fire sees the new rules. Per-row key problems
4556    /// are collected in [`IngestReport::row_errors`] and skipped; a commit
4557    /// `Err` means nothing was applied.
4558    ///
4559    /// Auto-FK rule names are `auto_fk_<src_label_lowercase>_<field>` so
4560    /// distinct source labels sharing an FK field each get their own rule.
4561    pub fn ingest(
4562        &mut self,
4563        label: &str,
4564        rows: Vec<BTreeMap<String, Value>>,
4565        opts: &IngestOptions,
4566    ) -> Result<IngestReport> {
4567        self.ingest_with_edges(label, rows, opts, &[])
4568    }
4569
4570    /// [`ingest`] plus user edges in the **same** previewed WAL batch.
4571    /// A failing edge rejects the whole request; nothing is applied.
4572    pub fn ingest_with_edges(
4573        &mut self,
4574        label: &str,
4575        rows: Vec<BTreeMap<String, Value>>,
4576        opts: &IngestOptions,
4577        edges: &[(String, String, String)],
4578    ) -> Result<IngestReport> {
4579        crate::ingest::run(self, label, rows, opts, edges)
4580    }
4581
4582    /// Parse `json` as an array of objects and ingest via [`GraphDb::ingest`].
4583    ///
4584    /// JSON `null` fields are silently omitted (not stored, not a row error).
4585    /// Nested objects and arrays-of-objects are a per-row error (row skipped).
4586    /// Parse failures and a top-level value that is not an array of objects
4587    /// return [`GraphError::IngestError`].
4588    pub fn ingest_json(
4589        &mut self,
4590        label: &str,
4591        json: &str,
4592        opts: &IngestOptions,
4593    ) -> Result<IngestReport> {
4594        crate::ingest::run_json(self, label, json, opts)
4595    }
4596
4597    fn commit_logged_batch(
4598        &mut self,
4599        ops: Vec<BatchOp>,
4600        ingest: Option<(String, usize)>,
4601        // Two-source rule: write_batch_authz threads authz here directly (never
4602        // touches pending_write_authz); query_write_authz sets the field instead
4603        // and passes None.  Only one source is non-None per call.
4604        param_authz: Option<WriteAuthz>,
4605    ) -> Result<(usize, usize)> {
4606        // Read-only guard: catches empty-batch calls before the early-return
4607        // that skips log_then_apply_with, ensuring all mutation entry points fail.
4608        if self.read_only {
4609            return Err(GraphError::ReadOnly);
4610        }
4611        // Ensure provenance is decoded before MutPreview accesses it
4612        // (note_delete_rule / is_rule_owned may call engine.provenance()).
4613        self.engine.ensure_provenance_loaded_mut();
4614
4615        // ── Authz pre-check ──────────────────────────────────────────────────
4616        // Evaluate the decision table per-op BEFORE MutPreview so that a denial
4617        // produces no WAL frame (all-or-nothing at the authz boundary extends
4618        // the existing validate-then-apply contract to role-scope checks).
4619        //
4620        // `batch_created` tracks key→label for nodes created by earlier ops in
4621        // THIS batch, so InsertEdgeUpsert can count same-batch placeholder nodes
4622        // as visible without needing to call `self.ids.get` on not-yet-committed
4623        // keys (they won't be there yet).
4624        //
4625        // Two-source rule: param_authz (write_batch_authz path) takes precedence;
4626        // fall back to self.pending_write_authz (query_write_authz/Cypher path).
4627        // Cloning the field copy avoids a simultaneous borrow of self.ids below.
4628        let authz_opt = param_authz.or_else(|| self.pending_write_authz.clone());
4629        if let Some(ref authz) = authz_opt {
4630            let mut batch_created: BTreeMap<String, String> = BTreeMap::new();
4631            for op in &ops {
4632                self.check_single_op_authz(authz, op, &batch_created)?;
4633                // Update batch_created after a passing authz check so that
4634                // subsequent ops in this batch see the nodes as "about to exist".
4635                match op {
4636                    BatchOp::InsertNode { label, key, .. } => {
4637                        // Only track genuinely new nodes (absent from the
4638                        // snapshot at authz-check time). A pre-existing visible
4639                        // key would be a DuplicateKey — not a real creation —
4640                        // so MutPreview handles it. Letting it into batch_created
4641                        // would allow a later SetProp to bypass update_labels
4642                        // via the "batch-created → always updatable" ruling
4643                        // (delete+recreate exploit, fix for I1 review round 2).
4644                        //
4645                        // Accepted edge: for a delete+recreate-with-different-
4646                        // label batch, node_status resolves the pre-delete
4647                        // (store) label for any subsequent update checks. This
4648                        // grants no net-new capability — a role that can delete+
4649                        // create can already place arbitrary props via
4650                        // InsertNode's own props field.
4651                        if self.ids.get(key.as_str()).is_none() {
4652                            batch_created.insert(key.clone(), label.clone());
4653                        }
4654                    }
4655                    BatchOp::InsertEdgeUpsert {
4656                        placeholder_label,
4657                        src_key,
4658                        dst_key,
4659                        ..
4660                    } => {
4661                        // Both endpoints will be created if not already in store.
4662                        for ep_key in [src_key, dst_key] {
4663                            if self.ids.get(ep_key.as_str()).is_none()
4664                                && !batch_created.contains_key(ep_key.as_str())
4665                            {
4666                                batch_created.insert(ep_key.clone(), placeholder_label.clone());
4667                            }
4668                        }
4669                    }
4670                    _ => {}
4671                }
4672            }
4673        }
4674
4675        let recs = {
4676            let mut preview = MutPreview::new(self);
4677            let mut recs = Vec::with_capacity(ops.len());
4678            for op in ops {
4679                match op {
4680                    BatchOp::InsertNode { label, key, props } => {
4681                        preview.check_insert_node(&key)?;
4682                        preview.note_insert_node(&key, &props);
4683                        recs.push(WalRecord::InsertNode { label, key, props });
4684                    }
4685                    BatchOp::InsertEdge {
4686                        edge_type,
4687                        src_key,
4688                        dst_key,
4689                    } => {
4690                        if preview.prepare_insert_edge(&edge_type, &src_key, &dst_key)? {
4691                            preview.note_insert_edge(&edge_type, &src_key, &dst_key);
4692                            recs.push(WalRecord::InsertEdge {
4693                                edge_type,
4694                                src_key,
4695                                dst_key,
4696                            });
4697                        }
4698                    }
4699                    BatchOp::SetProp { key, field, value } => {
4700                        preview.check_live_key(&key)?;
4701                        preview.note_set_prop(&key, &field, &value);
4702                        recs.push(WalRecord::SetProp { key, field, value });
4703                    }
4704                    BatchOp::RemoveProp { key, field } => {
4705                        if preview.prepare_remove_prop(&key, &field)? {
4706                            preview.note_remove_prop(&key, &field);
4707                            recs.push(WalRecord::RemoveProp { key, field });
4708                        }
4709                    }
4710                    BatchOp::DeleteEdge {
4711                        edge_type,
4712                        src_key,
4713                        dst_key,
4714                    } => {
4715                        if preview.prepare_delete_edge(&edge_type, &src_key, &dst_key)? {
4716                            preview.note_delete_edge(&edge_type, &src_key, &dst_key);
4717                            recs.push(WalRecord::DeleteEdge {
4718                                edge_type,
4719                                src_key,
4720                                dst_key,
4721                            });
4722                        }
4723                    }
4724                    BatchOp::DeleteNode { key } => {
4725                        preview.check_live_key(&key)?;
4726                        preview.note_delete_node(&key);
4727                        recs.push(WalRecord::DeleteNode { key });
4728                    }
4729                    BatchOp::CreateRule(def) => {
4730                        preview.check_create_rule(&def)?;
4731                        let def_bytes =
4732                            bincode::serialize(&def).map_err(|e| GraphError::Corrupt {
4733                                detail: format!("serialize rule: {e}"),
4734                            })?;
4735                        preview.note_create_rule(&def);
4736                        recs.push(WalRecord::CreateRule { def_bytes });
4737                    }
4738                    BatchOp::DeleteRule { name } => {
4739                        preview.check_delete_rule(&name)?;
4740                        preview.note_delete_rule(&name);
4741                        recs.push(WalRecord::DeleteRule { name });
4742                    }
4743                    BatchOp::RenameNode { old_key, new_key } => {
4744                        preview.check_rename_node(&old_key, &new_key)?;
4745                        preview.note_rename_node(&old_key, &new_key);
4746                        recs.push(WalRecord::RenameNode { old_key, new_key });
4747                    }
4748                    BatchOp::InsertEdgeUpsert {
4749                        edge_type,
4750                        src_key,
4751                        dst_key,
4752                        placeholder_label,
4753                    } => {
4754                        // Auto-create any missing endpoints as plain InsertNode ops.
4755                        // Rules fire and last-change is updated for each created node.
4756                        for key in [&src_key, &dst_key] {
4757                            if !preview.has_key(key) {
4758                                preview.check_insert_node(key)?;
4759                                preview.note_insert_node(key, &[]);
4760                                recs.push(WalRecord::InsertNode {
4761                                    label: placeholder_label.clone(),
4762                                    key: key.clone(),
4763                                    props: vec![],
4764                                });
4765                            }
4766                        }
4767                        if preview.prepare_insert_edge(&edge_type, &src_key, &dst_key)? {
4768                            preview.note_insert_edge(&edge_type, &src_key, &dst_key);
4769                            recs.push(WalRecord::InsertEdge {
4770                                edge_type,
4771                                src_key,
4772                                dst_key,
4773                            });
4774                        }
4775                    }
4776                }
4777            }
4778            recs
4779        };
4780        if recs.is_empty() {
4781            return Ok((0, 0));
4782        }
4783        // rewrite_wal_dense converts every InsertNode/InsertEdge into its
4784        // *Id form, so only the dense variants can appear in `recs` here.
4785        let recs = self.rewrite_wal_dense(recs)?;
4786        let nodes_inserted = recs
4787            .iter()
4788            .filter(|r| matches!(r, WalRecord::InsertNodeId { .. }))
4789            .count();
4790        let edges_inserted = recs
4791            .iter()
4792            .filter(|r| matches!(r, WalRecord::InsertEdgeId { .. }))
4793            .count();
4794        // Ingest / write_batch / query_write: one Batch frame, one fsync per call
4795        // under Strict.  Pass self.fsync directly so Strict stays Strict —
4796        // wal_needs_sync(Strict, _) always returns true regardless of op count.
4797        // Mapping Strict → Batched (the prior bug) caused wal_needs_sync to
4798        // short-circuit on single-op batches and silently skip the fsync.
4799        // Batched fsyncs only for multi-op batches; Relaxed always skips.
4800        self.log_then_apply_with(WalRecord::Batch(recs), ingest, self.fsync)?;
4801        Ok((nodes_inserted, edges_inserted))
4802    }
4803
4804    fn commit_batch(&mut self, ops: Vec<BatchOp>) -> Result<(usize, usize)> {
4805        self.commit_logged_batch(ops, None, None)
4806    }
4807
4808    /// Commit one submission WITHOUT an fsync — for use inside `commit_group`
4809    /// and the group-commit drain thread, which do a single group fsync later.
4810    fn commit_batch_nosync(&mut self, ops: Vec<BatchOp>) -> Result<(usize, usize)> {
4811        // Restore fsync policy even on panic via a raw-pointer drop guard.
4812        // A panic here would poison the RwLock anyway, but the correct policy
4813        // must be in place if the guard is ever unwrapped.
4814        struct RestoreFsync(*mut FsyncPolicy, FsyncPolicy);
4815        impl Drop for RestoreFsync {
4816            fn drop(&mut self) {
4817                // SAFETY: the pointer is valid for the full duration of
4818                // commit_batch_nosync; the guard is dropped before the frame
4819                // returns, and GraphDb outlives this frame.
4820                unsafe {
4821                    *self.0 = self.1;
4822                }
4823            }
4824        }
4825        let saved = self.fsync;
4826        // SAFETY: raw pointer into self; guard dropped within this frame.
4827        let _g = RestoreFsync(&mut self.fsync as *mut FsyncPolicy, saved);
4828        self.fsync = FsyncPolicy::Relaxed;
4829        self.commit_logged_batch(ops, None, None)
4830    }
4831
4832    /// Commit multiple op-batches as a **group**: each submission gets its own
4833    /// WAL `Batch` frame, but there is exactly **one** `Fs::sync` for the whole
4834    /// group (under `Strict` / `Batched` policy; `Relaxed` skips all syncs).
4835    ///
4836    /// # Durability semantics
4837    ///
4838    /// A crash before the group fsync may lose **all** submissions in the group.
4839    /// A crash after the group fsync preserves all of them.  No submission is
4840    /// ever torn: each WAL frame is either fully applied on replay or dropped
4841    /// in its entirety (CRC-protected frame boundaries).
4842    ///
4843    /// Events and subscription notifications fire per-submission immediately
4844    /// after apply, which may be before the group fsync.  From a subscriber's
4845    /// perspective this is equivalent to the `Relaxed` durability window.
4846    /// Submitters using [`SharedDb::submit_batch`] only unblock after the group
4847    /// fsync, so from their perspective durability is fully guaranteed.
4848    ///
4849    /// # MVCC interplay
4850    ///
4851    /// Each submission records its own `CommitDelta`; the fold-every-K counter
4852    /// increments per submission (not per group), preserving existing reader
4853    /// snapshot semantics.
4854    ///
4855    /// # Returns
4856    ///
4857    /// One `Result<(nodes_inserted, edges_inserted)>` per input group element,
4858    /// in order.  Failures are per-submission (validation errors); the group
4859    /// fsync error (if any) is returned as the second tuple element.
4860    pub fn commit_group(
4861        &mut self,
4862        groups: Vec<Vec<BatchOp>>,
4863    ) -> (Vec<Result<(usize, usize)>>, Option<GraphError>) {
4864        let mut results = Vec::with_capacity(groups.len());
4865        for ops in groups {
4866            results.push(self.commit_batch_nosync(ops));
4867        }
4868        let any_ok = results.iter().any(|r| r.is_ok());
4869        let sync_err = if self.fsync != FsyncPolicy::Relaxed && any_ok {
4870            self.fs
4871                .sync(core_storage::fs::FileId::Wal)
4872                .map_err(GraphError::Io)
4873                .err()
4874        } else {
4875            None
4876        };
4877        (results, sync_err)
4878    }
4879
4880    /// Like [`commit_group`] but skips the group fsync entirely.
4881    ///
4882    /// Used by the drain thread to apply submissions under the write lock and
4883    /// then perform the single fsync OUTSIDE the lock (via
4884    /// `core_storage::sync_wal_at`), reducing the write-lock hold time visible
4885    /// to concurrent readers.
4886    pub fn commit_group_nosync(
4887        &mut self,
4888        groups: Vec<Vec<BatchOp>>,
4889    ) -> Vec<Result<(usize, usize)>> {
4890        let mut results = Vec::with_capacity(groups.len());
4891        for ops in groups {
4892            results.push(self.commit_batch_nosync(ops));
4893        }
4894        results
4895    }
4896
4897    pub fn insert_node(
4898        &mut self,
4899        label: &str,
4900        key: &str,
4901        props: Vec<(String, Value)>,
4902    ) -> Result<()> {
4903        if self.read_only {
4904            return Err(GraphError::ReadOnly);
4905        }
4906        MutPreview::new(self).check_insert_node(key)?;
4907        self.log_dense(vec![WalRecord::InsertNode {
4908            label: label.into(),
4909            key: key.into(),
4910            props,
4911        }])
4912    }
4913
4914    pub fn insert_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) -> Result<bool> {
4915        if self.read_only {
4916            return Err(GraphError::ReadOnly);
4917        }
4918        if !MutPreview::new(self).prepare_insert_edge(edge_type, src_key, dst_key)? {
4919            return Ok(false);
4920        }
4921        self.log_dense(vec![WalRecord::InsertEdge {
4922            edge_type: edge_type.into(),
4923            src_key: src_key.into(),
4924            dst_key: dst_key.into(),
4925        }])?;
4926        Ok(true)
4927    }
4928
4929    pub fn set_prop(&mut self, key: &str, field: &str, value: Value) -> Result<()> {
4930        if self.read_only {
4931            return Err(GraphError::ReadOnly);
4932        }
4933        if let Some(view_name) = self.view_store.view_for_prop(field) {
4934            return Err(GraphError::ViewPropReadOnly {
4935                view_name: view_name.to_string(),
4936            });
4937        }
4938        MutPreview::new(self).check_live_key(key)?;
4939        self.log_dense(vec![WalRecord::SetProp {
4940            key: key.into(),
4941            field: field.into(),
4942            value,
4943        }])
4944    }
4945
4946    /// Remove a property. Returns `Ok(false)` (and does not log) if the field
4947    /// is already absent. Unknown or tombstoned keys are `Err(KeyNotFound)`.
4948    pub fn remove_prop(&mut self, key: &str, field: &str) -> Result<bool> {
4949        if self.read_only {
4950            return Err(GraphError::ReadOnly);
4951        }
4952        if let Some(view_name) = self.view_store.view_for_prop(field) {
4953            return Err(GraphError::ViewPropReadOnly {
4954                view_name: view_name.to_string(),
4955            });
4956        }
4957        if !MutPreview::new(self).prepare_remove_prop(key, field)? {
4958            return Ok(false);
4959        }
4960        self.log_then_apply(WalRecord::RemoveProp {
4961            key: key.into(),
4962            field: field.into(),
4963        })?;
4964        Ok(true)
4965    }
4966
4967    /// Delete a user edge. Returns `Ok(false)` (and does not log) if the edge
4968    /// is absent. Unknown keys are `Err(KeyNotFound)`. Rule-owned edges — in
4969    /// provenance, or a pair a live rule would derive — are `Err(RuleOwned)`
4970    /// (the rule would just put the edge back; delete or change the rule).
4971    pub fn delete_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) -> Result<bool> {
4972        if self.read_only {
4973            return Err(GraphError::ReadOnly);
4974        }
4975        if !MutPreview::new(self).prepare_delete_edge(edge_type, src_key, dst_key)? {
4976            return Ok(false);
4977        }
4978        self.log_then_apply(WalRecord::DeleteEdge {
4979            edge_type: edge_type.into(),
4980            src_key: src_key.into(),
4981            dst_key: dst_key.into(),
4982        })?;
4983        Ok(true)
4984    }
4985
4986    /// Delete a live node. Unknown or already-tombstoned keys are
4987    /// `Err(KeyNotFound)` and are not logged. Validation runs before the WAL
4988    /// write; `apply` of a logged `DeleteNode` for an already-tombstoned key
4989    /// (crash window) is a clean no-op.
4990    ///
4991    /// Returns a [`DeleteReport`] with counts of manual and derived edges
4992    /// removed (computed from live state before the deletion is applied).
4993    pub fn delete_node(&mut self, key: &str) -> Result<DeleteReport> {
4994        if self.read_only {
4995            return Err(GraphError::ReadOnly);
4996        }
4997        // Provenance must be loaded before we query provenance_touching.
4998        self.engine.ensure_provenance_loaded_mut();
4999        let id = self
5000            .ids
5001            .get(key)
5002            .ok_or_else(|| GraphError::KeyNotFound { key: key.into() })?;
5003
5004        // Count edges before the delete is applied so we can report counts.
5005        let derived_set: BTreeSet<(u32, u32, u32)> = self
5006            .engine
5007            .provenance_touching(id)
5008            .map(|(_, etype, src, dst)| (etype, src, dst))
5009            .collect();
5010        let derived_edges = derived_set.len() as u64;
5011
5012        let mut total_topo = 0u64;
5013        let tv = self.topo_view();
5014        for et in tv.etypes() {
5015            total_topo += tv.neighbors(et, Direction::Out, id).len() as u64
5016                + tv.neighbors(et, Direction::In, id).len() as u64;
5017        }
5018        // For symmetric rules (e.g. Overlap), a→b and b→a are two separate directed
5019        // triples in both the topo scan (Out and In from id) and in provenance_touching.
5020        // The subtraction remains correct because both counts include both directions.
5021        let manual_edges = total_topo.saturating_sub(derived_edges);
5022
5023        self.log_then_apply(WalRecord::DeleteNode { key: key.into() })?;
5024        Ok(DeleteReport {
5025            manual_edges,
5026            derived_edges,
5027        })
5028    }
5029
5030    /// Rename a live node's key.  The dense id (and therefore all edges,
5031    /// props, history, and last-change tracking) is unaffected.
5032    ///
5033    /// Returns `Err(KeyNotFound)` if `old` is not a live key.
5034    /// Returns `Err(DuplicateKey)` if `new` is already live.
5035    pub fn rename_node(&mut self, old: &str, new: &str) -> Result<()> {
5036        if self.read_only {
5037            return Err(GraphError::ReadOnly);
5038        }
5039        MutPreview::new(self).check_rename_node(old, new)?;
5040        self.log_then_apply(WalRecord::RenameNode {
5041            old_key: old.into(),
5042            new_key: new.into(),
5043        })
5044    }
5045
5046    /// Return the IVF drift counter for the dst-side candidate index of `rule`.
5047    /// `None` if the rule does not exist or is not approximate.
5048    ///
5049    /// The drift counter increments on IVF insert/remove after the last fit.
5050    /// When dst-side drift exceeds [`core_rules::IVF_DRIFT_REBUILD`], apply
5051    /// WAL-logs `RebuildRule` as a second commit (rebuild resets the counter).
5052    pub fn ivf_dst_drift(&self, rule: &str) -> Option<u64> {
5053        // SideIvfExport = (centroids, node→cluster, drift)
5054        self.engine
5055            .export_ivf_state()
5056            .remove(rule)
5057            .map(|(_src, dst)| dst.2)
5058    }
5059
5060    /// Validate and WAL-log a new rule, then backfill derived edges inside apply.
5061    /// Validation and duplicate-name check run before logging so invalid rules
5062    /// never enter the WAL.
5063    pub fn create_rule(&mut self, def: RuleDef) -> Result<()> {
5064        if self.read_only {
5065            return Err(GraphError::ReadOnly);
5066        }
5067        MutPreview::new(self).check_create_rule(&def)?;
5068        let def_bytes = bincode::serialize(&def).map_err(|e| GraphError::Corrupt {
5069            detail: format!("serialize rule: {e}"),
5070        })?;
5071        self.log_then_apply(WalRecord::CreateRule { def_bytes })
5072    }
5073
5074    /// WAL-log rule deletion. Returns RuleNotFound if the rule does not exist.
5075    pub fn delete_rule(&mut self, name: &str) -> Result<()> {
5076        if self.read_only {
5077            return Err(GraphError::ReadOnly);
5078        }
5079        MutPreview::new(self).check_delete_rule(name)?;
5080        self.log_then_apply(WalRecord::DeleteRule { name: name.into() })
5081    }
5082
5083    /// Return a snapshot of all registered rules.
5084    pub fn rules(&self) -> Vec<RuleDef> {
5085        self.engine.rules().cloned().collect()
5086    }
5087
5088    // -----------------------------------------------------------------------
5089    // Rule suggestion API
5090    // -----------------------------------------------------------------------
5091
5092    /// Profile the database and suggest linking rules with previewed edge counts.
5093    ///
5094    /// Uses the default seed ([`core_rules::SUGGEST_DEFAULT_SEED`]) for deterministic
5095    /// sampling. Suggestions are sorted by estimated edge count (descending).
5096    /// **NO auto-accept** — call [`GraphDb::create_rule`] explicitly to apply.
5097    pub fn suggest_rules(&self) -> Vec<core_rules::RuleSuggestion> {
5098        self.suggest_rules_seeded(core_rules::SUGGEST_DEFAULT_SEED)
5099    }
5100
5101    /// Like [`suggest_rules`] but with a caller-supplied RNG seed for
5102    /// reproducibility. Same seed + same data = identical output.
5103    pub fn suggest_rules_seeded(&self, seed: u64) -> Vec<core_rules::RuleSuggestion> {
5104        self.suggest_rules_with_config(&core_rules::suggest::SuggestConfig::default(), seed)
5105            .suggestions
5106    }
5107
5108    /// [`suggest_rules_seeded`] with a fully custom [`SuggestConfig`].
5109    ///
5110    /// Returns a [`core_rules::SuggestReport`] that includes both the candidate list
5111    /// and a `truncated` flag indicating whether the global budget fired before all
5112    /// candidates were evaluated.
5113    pub fn suggest_rules_with_config(
5114        &self,
5115        config: &core_rules::suggest::SuggestConfig,
5116        seed: u64,
5117    ) -> core_rules::SuggestReport {
5118        use std::collections::BTreeMap;
5119
5120        // Collect (node_id, key) pairs per label, skipping tombstoned nodes.
5121        let mut label_nodes: BTreeMap<String, Vec<(u32, String)>> = BTreeMap::new();
5122        for id in 0..self.ids.len() as u32 {
5123            let Some(key) = self.ids.key_of(id) else {
5124                continue;
5125            };
5126            let Some(&sym) = self.labels.get(id as usize) else {
5127                continue;
5128            };
5129            if sym == u32::MAX {
5130                continue; // tombstoned
5131            }
5132            let Some(label) = self.syms.resolve(sym) else {
5133                continue;
5134            };
5135            label_nodes
5136                .entry(label.to_string())
5137                .or_default()
5138                .push((id, key.to_string()));
5139        }
5140
5141        let existing = self.rules();
5142        let pv = build_props_view(&self.props, &self.base);
5143        let all_fields: Vec<String> = pv.field_names();
5144
5145        core_rules::suggest::suggest_rules(
5146            &label_nodes,
5147            &|id, field| pv.get(id, field).map(|vr| vr.into_value()),
5148            &all_fields,
5149            &existing,
5150            config,
5151            seed,
5152        )
5153    }
5154
5155    /// Recompute a rule's derived edges from scratch. WAL-logged so un-trip
5156    /// plus later mutations replay identically (rebuild is a pure function
5157    /// of state).
5158    ///
5159    /// Only exit from the tripped latch: if the full desired set fits the
5160    /// budget, it is applied completely and `tripped` clears; if it still
5161    /// exceeds the budget, provenance is left untouched and `tripped` stays
5162    /// true. Counts as a fire evaluation (see [`RuleStats::fires`]).
5163    /// Unknown rule → `RuleNotFound`, nothing logged.
5164    pub fn rebuild_rule(&mut self, name: &str) -> Result<()> {
5165        if self.read_only {
5166            return Err(GraphError::ReadOnly);
5167        }
5168        if !self.engine.rules().any(|r| r.name == name) {
5169            return Err(GraphError::RuleNotFound { name: name.into() });
5170        }
5171        self.log_then_apply(WalRecord::RebuildRule { name: name.into() })
5172    }
5173
5174    // -----------------------------------------------------------------------
5175    // Materialized view API
5176    // -----------------------------------------------------------------------
5177
5178    /// Register a new materialized property view, backfill its values for all
5179    /// existing nodes, and WAL-log the definition.
5180    ///
5181    /// # Errors
5182    /// - `ReadOnly`: called on an as-of instance.
5183    /// - `RuleInvalid`: name collision, view_prop collision, or invalid def.
5184    pub fn create_view(&mut self, def: ViewDef) -> Result<()> {
5185        if self.read_only {
5186            return Err(GraphError::ReadOnly);
5187        }
5188        // Pre-validate before WAL write.
5189        def.validate()
5190            .map_err(|e| GraphError::RuleInvalid { detail: e })?;
5191        if self.view_store.has_view(&def.name) {
5192            return Err(GraphError::RuleInvalid {
5193                detail: format!("view {:?} already exists", def.name),
5194            });
5195        }
5196        if let Some(existing) = self.view_store.view_for_prop(&def.view_prop) {
5197            return Err(GraphError::RuleInvalid {
5198                detail: format!(
5199                    "view_prop {:?} is already used by view {:?}",
5200                    def.view_prop, existing
5201                ),
5202            });
5203        }
5204        let def_bytes = bincode::serialize(&def).map_err(|e| GraphError::Corrupt {
5205            detail: format!("serialize view: {e}"),
5206        })?;
5207        // Enable delta accumulation before the view is registered so subsequent
5208        // incremental edge events reach view maintenance from this point onward.
5209        // (The backfill inside create_view reads topo directly; it does not rely
5210        // on pending deltas.)
5211        self.engine.set_emit_deltas(true);
5212        self.log_then_apply(WalRecord::CreateView { def_bytes })
5213    }
5214
5215    /// Remove a named view and delete its values from every node.
5216    ///
5217    /// # Errors
5218    /// - `ReadOnly`: called on an as-of instance.
5219    /// - `RuleNotFound`: view does not exist.
5220    pub fn delete_view(&mut self, name: &str) -> Result<()> {
5221        if self.read_only {
5222            return Err(GraphError::ReadOnly);
5223        }
5224        if !self.view_store.has_view(name) {
5225            return Err(GraphError::RuleNotFound { name: name.into() });
5226        }
5227        let result = self.log_then_apply(WalRecord::DeleteView { name: name.into() });
5228        // After deletion, disable accumulation if no listeners remain.
5229        if !self.needs_emit_deltas() {
5230            self.engine.set_emit_deltas(false);
5231        }
5232        result
5233    }
5234
5235    /// Snapshot of all registered view definitions.
5236    pub fn views(&self) -> Vec<ViewDef> {
5237        self.view_store.views().cloned().collect()
5238    }
5239
5240    // -----------------------------------------------------------------------
5241    // Full-text-lite API
5242    // -----------------------------------------------------------------------
5243
5244    /// Enable full-text indexing for all nodes of `label` on property `field`.
5245    ///
5246    /// After this call, every subsequent write to `(label, field)` is reflected
5247    /// in the index incrementally.  Existing nodes are backfilled immediately.
5248    /// The declaration is persisted as a WAL record; the index itself is rebuilt
5249    /// from scratch on re-open (no snapshot format changes).
5250    ///
5251    /// # Errors
5252    /// - [`GraphError::ReadOnly`]: called on an as-of instance.
5253    /// - [`GraphError::RuleInvalid`]: `(label, field)` is already indexed.
5254    pub fn enable_fulltext(&mut self, label: &str, field: &str) -> Result<()> {
5255        if self.read_only {
5256            return Err(GraphError::ReadOnly);
5257        }
5258        if self.fulltext.is_enabled(label, field) {
5259            return Err(GraphError::RuleInvalid {
5260                detail: format!("full-text index for ({label:?}, {field:?}) already enabled"),
5261            });
5262        }
5263        self.log_then_apply(WalRecord::EnableFulltext {
5264            label: label.into(),
5265            field: field.into(),
5266        })
5267    }
5268
5269    /// Disable full-text indexing for `(label, field)` and drop its postings.
5270    ///
5271    /// # Errors
5272    /// - [`GraphError::ReadOnly`]: called on an as-of instance.
5273    /// - [`GraphError::RuleNotFound`]: `(label, field)` is not currently indexed.
5274    pub fn disable_fulltext(&mut self, label: &str, field: &str) -> Result<()> {
5275        if self.read_only {
5276            return Err(GraphError::ReadOnly);
5277        }
5278        if !self.fulltext.is_enabled(label, field) {
5279            return Err(GraphError::RuleNotFound {
5280                name: format!("fulltext({label},{field})"),
5281            });
5282        }
5283        self.log_then_apply(WalRecord::DisableFulltext {
5284            label: label.into(),
5285            field: field.into(),
5286        })
5287    }
5288
5289    /// Whether `(label, field)` is currently indexed for full-text search.
5290    pub fn is_fulltext_enabled(&self, label: &str, field: &str) -> bool {
5291        self.fulltext.is_enabled(label, field)
5292    }
5293
5294    /// Every `(label, field)` pair with a live full-text index, sorted.
5295    ///
5296    /// Note that [`GraphDb::search`] is keyed by field alone — a pair only
5297    /// declares which nodes are *indexed*, so callers that want to search
5298    /// everything indexed should query each distinct field once.
5299    pub fn fulltext_pairs(&self) -> Vec<(String, String)> {
5300        let mut v: Vec<(String, String)> = self.fulltext.enabled_pairs().cloned().collect();
5301        v.sort();
5302        v
5303    }
5304
5305    /// Enable an equality index for all nodes of `label` on scalar property
5306    /// `field`. Subsequent `WHERE n.field = value` lookups become O(matches)
5307    /// instead of an O(N_label) scan. Existing nodes are backfilled; the
5308    /// declaration persists via WAL and the postings rebuild on re-open.
5309    ///
5310    /// # Errors
5311    /// - [`GraphError::ReadOnly`]: called on an as-of instance.
5312    /// - [`GraphError::RuleInvalid`]: `(label, field)` is already indexed.
5313    pub fn enable_index(&mut self, label: &str, field: &str) -> Result<()> {
5314        if self.read_only {
5315            return Err(GraphError::ReadOnly);
5316        }
5317        if self.prop_index.is_enabled(label, field) {
5318            return Err(GraphError::RuleInvalid {
5319                detail: format!("property index for ({label:?}, {field:?}) already enabled"),
5320            });
5321        }
5322        self.log_then_apply(WalRecord::EnableIndex {
5323            label: label.into(),
5324            field: field.into(),
5325        })
5326    }
5327
5328    /// Disable the equality index for `(label, field)` and drop its postings.
5329    ///
5330    /// # Errors
5331    /// - [`GraphError::ReadOnly`]: called on an as-of instance.
5332    /// - [`GraphError::RuleNotFound`]: `(label, field)` is not currently indexed.
5333    pub fn disable_index(&mut self, label: &str, field: &str) -> Result<()> {
5334        if self.read_only {
5335            return Err(GraphError::ReadOnly);
5336        }
5337        if !self.prop_index.is_enabled(label, field) {
5338            return Err(GraphError::RuleNotFound {
5339                name: format!("index({label},{field})"),
5340            });
5341        }
5342        self.log_then_apply(WalRecord::DisableIndex {
5343            label: label.into(),
5344            field: field.into(),
5345        })
5346    }
5347
5348    /// Whether `(label, field)` currently has an equality index.
5349    pub fn is_index_enabled(&self, label: &str, field: &str) -> bool {
5350        self.prop_index.is_enabled(label, field)
5351    }
5352
5353    /// Search a full-text-indexed field.
5354    ///
5355    /// Returns `(node_key, match_count)` pairs sorted by match_count descending,
5356    /// ties broken by key (lexicographic).  Tombstoned nodes are excluded.
5357    ///
5358    /// **Query syntax:**
5359    /// - Space-separated terms are AND'd: `"foo bar"` requires both.
5360    /// - `OR` between terms forms disjunction: `"foo OR bar"` matches either.
5361    /// - Trailing `*` on a term is a prefix match: `"rust*"` matches `rustlang`, `rusty`.
5362    /// - `AND` keyword is accepted explicitly and is the default.
5363    /// - Tokenization is unicode-alphanumeric (same as index time); case-insensitive.
5364    ///
5365    /// **Unindexed field:** returns `Ok(vec![])` if `field` is not indexed.
5366    /// Pin: this is the documented, tested, stable behavior for v1.
5367    ///
5368    /// **Memory / performance:** O(postings) lookup; no scan.  The index is
5369    /// in-memory and proportional to total indexed text across all enabled fields.
5370    ///
5371    /// **v2 grammar:** supports `"phrase"`, `-negation`, `prefix*`, `OR`, `AND`.
5372    /// Results are BM25-scored (k1=1.2, b=0.75) and sorted by score descending,
5373    /// key ascending for deterministic tiebreaking.
5374    pub fn search(&self, field: &str, query: &str) -> Vec<(String, f64)> {
5375        // Resolve node_ids to keys (excluding tombstones) then re-sort by
5376        // (score DESC, key ASC) to give a deterministic, key-lexicographic
5377        // tiebreak.  FulltextIndex::search sorts by (score DESC, node_id ASC)
5378        // which diverges from key order when nodes were not inserted in key-lex order.
5379        let mut results: Vec<(String, f64)> = self
5380            .fulltext
5381            .search(field, query, 0)
5382            .into_iter()
5383            .filter_map(|(id, score)| self.ids.key_of(id).map(|key| (key.to_string(), score)))
5384            .collect();
5385        results.sort_by(|a, b| {
5386            b.1.partial_cmp(&a.1)
5387                .unwrap_or(std::cmp::Ordering::Equal)
5388                .then(a.0.cmp(&b.0))
5389        });
5390        results
5391    }
5392
5393    /// Hybrid search: Reciprocal Rank Fusion (RRF) over fulltext + vector results.
5394    ///
5395    /// Takes up to `4*k` fulltext hits for `(text_field, query_text)` and up to
5396    /// `4*k` vector hits for `(vector_field, query_vec, min=0.0)`, then fuses
5397    /// them with RRF using a fixed constant of 60.
5398    ///
5399    /// ```text
5400    /// score(d) = Σ  1 / (60 + rank_i(d))    (rank 1-based per list)
5401    /// ```
5402    ///
5403    /// Returns the top `k` nodes by fused score, ties broken by node key
5404    /// ascending (deterministic).
5405    ///
5406    /// # Vector leg fallback
5407    ///
5408    /// When `query_vec` is empty the vector leg is skipped entirely and
5409    /// results are ranked by the text list alone through the same RRF path
5410    /// (each text result scores `1/(60 + rank)` from that single list).
5411    ///
5412    /// When `label` is `None`, the vector leg **always** returns empty results.
5413    /// Internally `label` is mapped to `""`, which does not match any rule-created
5414    /// HNSW index (all such indexes are keyed to a specific non-empty label), and
5415    /// the brute-force fallback finds no nodes with an empty label.  The fused
5416    /// ranking is therefore text-only in this case.
5417    pub fn search_hybrid(
5418        &self,
5419        text_field: &str,
5420        query_text: &str,
5421        vector_field: &str,
5422        query_vec: &[f64],
5423        label: Option<&str>,
5424        k: usize,
5425    ) -> Vec<(String, f64)> {
5426        use std::collections::HashMap;
5427
5428        const RRF_K: f64 = 60.0;
5429        let pool = 4 * k;
5430
5431        // Accumulate per-node RRF scores.
5432        let mut scores: HashMap<String, f64> = HashMap::new();
5433
5434        // Text leg.
5435        let text_hits = self.search(text_field, query_text);
5436        for (rank0, (key, _count)) in text_hits.into_iter().take(pool).enumerate() {
5437            let rank = (rank0 + 1) as f64;
5438            *scores.entry(key).or_insert(0.0) += 1.0 / (RRF_K + rank);
5439        }
5440
5441        // Vector leg (skipped when query_vec is empty).
5442        if !query_vec.is_empty() {
5443            let vec_hits = self.find_similar_vector(vector_field, label, query_vec, pool, 0.0);
5444            for (rank0, (key, _sim)) in vec_hits.into_iter().enumerate() {
5445                let rank = (rank0 + 1) as f64;
5446                *scores.entry(key).or_insert(0.0) += 1.0 / (RRF_K + rank);
5447            }
5448        }
5449
5450        // Sort: score DESC, then key ASC for deterministic tie-breaking.
5451        let mut ranked: Vec<(String, f64)> = scores.into_iter().collect();
5452        ranked.sort_by(|a, b| {
5453            b.1.partial_cmp(&a.1)
5454                .unwrap_or(std::cmp::Ordering::Equal)
5455                .then(a.0.cmp(&b.0))
5456        });
5457        ranked.truncate(k);
5458        ranked
5459    }
5460
5461    /// For DST/testing: scratch BM25 search over live nodes without the index.
5462    /// Walks every live node, re-stems field tokens, computes corpus stats, and
5463    /// returns BM25-ranked results.
5464    ///
5465    /// The oracle: the ordered key list of `search(field, q)` must equal that of
5466    /// `scratch_search(field, q)` at every quiescent state.
5467    #[doc(hidden)]
5468    pub fn scratch_search(&self, field: &str, query: &str) -> Vec<(String, f64)> {
5469        use core_storage::fulltext::{parse_query, value_tokens_stemmed_with_positions};
5470        use std::collections::BTreeMap;
5471
5472        let groups = parse_query(query);
5473        if groups.is_empty() {
5474            return vec![];
5475        }
5476
5477        // --- Pass 1: collect all live indexed nodes with stemmed token data ---
5478        struct NodeData {
5479            key: String,
5480            /// stemmed_token → positions (sorted)
5481            tokens: BTreeMap<String, Vec<u32>>,
5482            dl: u32,
5483        }
5484
5485        let mut nodes: Vec<NodeData> = Vec::new();
5486        for id in 0..self.ids.len() as u32 {
5487            let Some(key) = self.ids.key_of(id) else {
5488                continue;
5489            };
5490            let Some(&sym) = self.labels.get(id as usize) else {
5491                continue;
5492            };
5493            if sym == u32::MAX {
5494                continue;
5495            }
5496            let label = match self.syms.resolve(sym) {
5497                Some(l) => l,
5498                None => continue,
5499            };
5500            if !self.fulltext.is_enabled(label, field) {
5501                continue;
5502            }
5503            let Some(value) = self.props_view().get(id, field).map(|vr| vr.into_value()) else {
5504                continue;
5505            };
5506            // Use value_tokens_stemmed_with_positions so list elements are
5507            // separated by POSITION_GAP — identical to the index path, which
5508            // prevents phrase queries from matching across element boundaries.
5509            let stemmed_with_pos = match &value {
5510                Value::Str(_) | Value::List(_) => value_tokens_stemmed_with_positions(&value),
5511                _ => continue,
5512            };
5513            let dl = stemmed_with_pos.len() as u32;
5514            let mut tok_map: BTreeMap<String, Vec<u32>> = BTreeMap::new();
5515            for (tok, pos) in stemmed_with_pos {
5516                tok_map.entry(tok).or_default().push(pos);
5517            }
5518            nodes.push(NodeData {
5519                key: key.to_string(),
5520                tokens: tok_map,
5521                dl,
5522            });
5523        }
5524
5525        if nodes.is_empty() {
5526            return vec![];
5527        }
5528
5529        // --- BM25 corpus stats ---
5530        let n = nodes.len() as f64;
5531        let avg_dl: f64 = nodes.iter().map(|nd| nd.dl as f64).sum::<f64>() / n;
5532        // df per stemmed token across all live indexed nodes.
5533        let mut df_map: BTreeMap<&str, f64> = BTreeMap::new();
5534        for nd in &nodes {
5535            for tok in nd.tokens.keys() {
5536                *df_map.entry(tok.as_str()).or_insert(0.0) += 1.0;
5537            }
5538        }
5539
5540        const K1: f64 = 1.2;
5541        const B: f64 = 0.75;
5542
5543        // --- Pass 2: score each node against each OR-group ---
5544        let mut results: Vec<(String, f64)> = Vec::new();
5545        for nd in &nodes {
5546            let dl = nd.dl as f64;
5547            let mut total_score = 0.0f64;
5548
5549            'group: for group in &groups {
5550                let mut group_score = 0.0f64;
5551
5552                for term in group {
5553                    if term.negated {
5554                        // Negated: if doc has this stemmed token → group fails.
5555                        let present = if term.prefix {
5556                            nd.tokens.keys().any(|t| t.starts_with(term.token.as_str()))
5557                        } else {
5558                            nd.tokens.contains_key(term.token.as_str())
5559                        };
5560                        if present {
5561                            continue 'group;
5562                        }
5563                        continue;
5564                    }
5565                    if term.prefix {
5566                        // Prefix: sum BM25 for all matching stemmed tokens.
5567                        let mut prefix_matched = false;
5568                        for (tok, positions) in &nd.tokens {
5569                            if tok.starts_with(term.token.as_str()) {
5570                                let tf = positions.len() as f64;
5571                                let df = df_map.get(tok.as_str()).copied().unwrap_or(1.0);
5572                                let idf = ((n - df + 0.5) / (df + 0.5) + 1.0).ln();
5573                                let tf_norm =
5574                                    tf * (K1 + 1.0) / (tf + K1 * (1.0 - B + B * dl / avg_dl));
5575                                group_score += idf * tf_norm;
5576                                prefix_matched = true;
5577                            }
5578                        }
5579                        if !prefix_matched {
5580                            continue 'group;
5581                        }
5582                    } else {
5583                        // term.token is already stemmed by parse_query; use directly.
5584                        match nd.tokens.get(term.token.as_str()) {
5585                            None => continue 'group,
5586                            Some(positions) => {
5587                                let tf = positions.len() as f64;
5588                                let df = df_map.get(term.token.as_str()).copied().unwrap_or(1.0);
5589                                let idf = ((n - df + 0.5) / (df + 0.5) + 1.0).ln();
5590                                let tf_norm =
5591                                    tf * (K1 + 1.0) / (tf + K1 * (1.0 - B + B * dl / avg_dl));
5592                                group_score += idf * tf_norm;
5593                            }
5594                        }
5595                    }
5596                }
5597
5598                if group_score > 0.0 {
5599                    total_score += group_score;
5600                }
5601            }
5602
5603            if total_score > 0.0 {
5604                results.push((nd.key.clone(), total_score));
5605            }
5606        }
5607
5608        results.sort_by(|a, b| {
5609            b.1.partial_cmp(&a.1)
5610                .unwrap_or(std::cmp::Ordering::Equal)
5611                .then(a.0.cmp(&b.0))
5612        });
5613        results
5614    }
5615
5616    /// Return the current view-maintained value of `view_prop` for node `key`.
5617    /// Equivalent to `get_prop` but documents that it reads a view-managed column.
5618    pub fn get_view_prop(&self, key: &str, view_prop: &str) -> Option<Value> {
5619        let id = self.ids.get(key)?;
5620        self.props_view()
5621            .get(id, view_prop)
5622            .map(|vr| vr.into_value())
5623    }
5624
5625    /// For testing / DST oracle: scratch recompute of a view value for one node.
5626    ///
5627    /// Returns `None` if the node does not exist, the view does not exist, or
5628    /// the view has no result for the node (e.g. Avg with no qualifying neighbors).
5629    #[doc(hidden)]
5630    pub fn scratch_view_value(&self, key: &str, view_name: &str) -> Option<Value> {
5631        let node = self.ids.get(key)?;
5632        let def = self.view_store.views().find(|v| v.name == view_name)?;
5633        // Use TopologyView so that NeighborAgg sees base + overlay edges
5634        // without materialising a temporary Topology (I1).
5635        let topo_view = self.topo_view();
5636        core_rules::views::compute_view_value(
5637            def,
5638            node,
5639            self.props_view(),
5640            &topo_view,
5641            &self.ids,
5642            &self.syms,
5643            &self.labels,
5644        )
5645    }
5646
5647    // -----------------------------------------------------------------------
5648    // Graph algorithm API
5649    // -----------------------------------------------------------------------
5650
5651    /// Run PageRank over the unified topology (manual + derived edges).
5652    ///
5653    /// Returns a [`PageRankReport`] with scores sorted descending (ties: key
5654    /// ascending).  Set `config.edge_type` to restrict to one edge type.
5655    /// `config.converged` is `true` only when the power iteration converged
5656    /// within `config.max_iters` and within any time budget.
5657    pub fn pagerank(&self, config: &crate::algo::PageRankConfig) -> crate::algo::PageRankReport {
5658        let topo = build_topo_view(&self.topo, &self.base);
5659        crate::algo::pagerank(&topo, &self.ids, &self.syms, &self.labels, config)
5660    }
5661
5662    /// Weakly-connected components over the unified topology (treated as
5663    /// undirected regardless of how edges were inserted).
5664    ///
5665    /// Component IDs are the key of the smallest member in the component
5666    /// (deterministic).  Result sorted by (component_id, key).
5667    pub fn connected_components(&self, config: &crate::algo::WccConfig) -> crate::algo::WccReport {
5668        let topo = build_topo_view(&self.topo, &self.base);
5669        crate::algo::wcc(&topo, &self.ids, &self.syms, &self.labels, config)
5670    }
5671
5672    /// Degree centrality for every live node.
5673    ///
5674    /// `direction`: `AlgoDir::Out` = out-degree, `AlgoDir::In` = in-degree,
5675    /// `AlgoDir::Both` = out + in (total directed degree).
5676    ///
5677    /// For one-shot ranking use this; for a live property updated on every
5678    /// write, create a Degree materialized view instead (see `docs/site/algorithms.md`).
5679    pub fn degree_centrality(
5680        &self,
5681        config: &crate::algo::DegreeConfig,
5682    ) -> crate::algo::DegreeReport {
5683        let topo = build_topo_view(&self.topo, &self.base);
5684        crate::algo::degree_centrality(&topo, &self.ids, &self.syms, &self.labels, config)
5685    }
5686
5687    /// Write a vector of `(node_key, score)` pairs as `prop_name` on each node,
5688    /// atomically via a single write-batch (one WAL frame, one fsync).
5689    ///
5690    /// # Errors
5691    /// - [`GraphError::ReadOnly`]: called on an as-of instance.
5692    /// - [`GraphError::RuleInvalid`]: `prop_name` is managed by an existing view
5693    ///   (collision check mirrors `create_view`).
5694    /// - [`GraphError::KeyNotFound`]: a key in `scores` does not exist as a live node.
5695    pub fn write_scores(&mut self, prop_name: &str, scores: &[(String, f64)]) -> Result<()> {
5696        if self.read_only {
5697            return Err(GraphError::ReadOnly);
5698        }
5699        // Collision check: refuse if prop_name is view-managed.
5700        if let Some(view_name) = self.view_store.view_for_prop(prop_name) {
5701            return Err(GraphError::RuleInvalid {
5702                detail: format!(
5703                    "prop {:?} is managed by view {:?} and cannot be written as scores",
5704                    prop_name, view_name
5705                ),
5706            });
5707        }
5708        // Refuse if prop_name is a view name itself (confusing namespace collision).
5709        if self.view_store.has_view(prop_name) {
5710            return Err(GraphError::RuleInvalid {
5711                detail: format!(
5712                    "prop_name {:?} collides with an existing view name",
5713                    prop_name
5714                ),
5715            });
5716        }
5717        // Write all scores in a single crash-atomic batch.
5718        self.write_batch(|b| {
5719            for (key, score) in scores {
5720                b.set_prop(key, prop_name, Value::Float(*score));
5721            }
5722        })?;
5723        Ok(())
5724    }
5725
5726    /// Return the value of `field` for the node with key `key`, or `None` if
5727    /// the node or field is absent.  Reads through the overlay-over-base
5728    /// `ColumnsView`, materialising base values on demand (zero heap cost for
5729    /// overlay hits; one clone per base hit).
5730    pub fn get_prop(&self, key: &str, field: &str) -> Option<Value> {
5731        let id = self.ids.get(key)?;
5732        self.props_view().get(id, field).map(|vr| vr.into_value())
5733    }
5734
5735    pub fn has_node(&self, key: &str) -> bool {
5736        self.ids.get(key).is_some()
5737    }
5738
5739    /// Borrow the raw id map. Used by `NodeMask::from_keys` to resolve keys.
5740    pub(crate) fn ids(&self) -> &IdMap {
5741        &self.ids
5742    }
5743
5744    // -----------------------------------------------------------------------
5745    // RBAC role resolution
5746    // -----------------------------------------------------------------------
5747
5748    /// Parse `roles.json` bytes from `fs`.
5749    ///
5750    /// Return values:
5751    ///   `Ok(Some(roles))` — file absent (returns `vec![]`) **or** file present
5752    ///                       and valid; in both cases `mask_for_role` uses the
5753    ///                       list normally (an absent file means no roles defined).
5754    ///   `Ok(None)`        — file present but corrupt or unrecognised version
5755    ///                       → poisoned state; `mask_for_role` returns `Err` for
5756    ///                       any role name until the file is fixed and the DB
5757    ///                       re-opened (or `apply_schema` is called to repair it).
5758    ///
5759    /// Note: `None` signals corruption, not absence — the opposite of what an
5760    /// optional "file missing" convention would suggest.  The open path stores
5761    /// this result on `db.roles` directly.
5762    fn load_roles_from_fs(fs: &F) -> Result<Option<Vec<RoleDef>>> {
5763        let bytes = fs.read(FileId::Roles).map_err(GraphError::Io)?;
5764        if bytes.is_empty() {
5765            // Empty bytes means either the file is absent or zero-byte — both
5766            // are treated identically as "no roles defined".  A zero-byte
5767            // roles.json does NOT widen access: an absent file and a zero-byte
5768            // file both resolve to an empty role list (sees nothing by default).
5769            return Ok(Some(vec![]));
5770        }
5771        match serde_json::from_slice::<RolesFile>(&bytes) {
5772            Ok(f) if f.version == 1 || f.version == 2 => Ok(Some(f.roles)),
5773            // Corrupt or unrecognised version (>2): poison the roles state.
5774            _ => Ok(None),
5775        }
5776    }
5777
5778    /// Resolve a role to a node-visibility mask against the current graph state.
5779    ///
5780    /// Returns `Err` when:
5781    /// - `roles.json` was present but corrupt at open (poisoned state), or
5782    /// - `role` does not match any defined role name.
5783    ///
5784    /// The mask union is: explicit `keys` (unknown keys silently ignored) plus
5785    /// all live nodes carrying any label in `labels`.  Label resolution is live
5786    /// — new nodes of an allowed label are visible without re-applying the
5787    /// schema.  An empty union = empty mask = sees nothing.
5788    pub fn mask_for_role(&self, role: &str) -> Result<crate::mask::NodeMask> {
5789        let roles = self.roles.as_ref().ok_or_else(|| GraphError::Corrupt {
5790            detail:
5791                "roles.json was corrupt at open; fix the file and re-open to restore role access"
5792                    .into(),
5793        })?;
5794        let def = roles
5795            .iter()
5796            .find(|r| r.name == role)
5797            .ok_or_else(|| GraphError::KeyNotFound {
5798                key: format!("role:{role}"),
5799            })?;
5800
5801        let mut visible = std::collections::HashSet::new();
5802
5803        // Key leg: resolve explicit keys to dense ids (unknown keys ignored).
5804        for key in &def.keys {
5805            if let Some(id) = self.ids.get(key) {
5806                visible.insert(id);
5807            }
5808        }
5809
5810        // Label leg: live scan — iterate labels vec for matching symbol.
5811        for label_name in &def.labels {
5812            if let Some(sym) = self.syms.get(label_name) {
5813                for (i, &s) in self.labels.iter().enumerate() {
5814                    if s == sym {
5815                        visible.insert(i as u32);
5816                    }
5817                }
5818            }
5819        }
5820
5821        Ok(crate::mask::NodeMask::from_ids(visible))
5822    }
5823
5824    /// Return the current list of role definitions.
5825    ///
5826    /// Returns an empty list when no roles are defined or when `roles.json`
5827    /// was corrupt at open (check [`mask_for_role`](Self::mask_for_role) for
5828    /// the fail-loud error in that case).
5829    pub fn roles(&self) -> Vec<RoleDef> {
5830        self.roles.as_deref().unwrap_or(&[]).to_vec()
5831    }
5832
5833    // ── Role-scoped write authz ───────────────────────────────────────────────
5834
5835    /// Execute `ops` with optional role-scoped write authorization.
5836    ///
5837    /// - `None` → full authority, identical to [`write_batch`](Self::write_batch)
5838    ///   (zero-cost bypass of all authz checks).
5839    /// - `Some(authz)` → the decision table is evaluated per-op BEFORE any WAL
5840    ///   record is built.  A denial returns an error with no WAL frame written
5841    ///   (all-or-nothing at the authz boundary, then at the MutPreview boundary).
5842    ///
5843    /// See the plan's "authz decision table" section for the full semantics.
5844    pub fn write_batch_authz(
5845        &mut self,
5846        authz: Option<&WriteAuthz>,
5847        ops: Vec<BatchOp>,
5848    ) -> Result<(usize, usize)> {
5849        // Thread authz as a direct parameter — never touches pending_write_authz.
5850        self.commit_logged_batch(ops, None, authz.cloned())
5851    }
5852
5853    /// Execute a Cypher write statement with role-scoped write authorization.
5854    ///
5855    /// Resolves scope + mask from `self.roles` inside the call (same write-guard
5856    /// lifetime as execution, satisfying §5 lock discipline).  The resolved
5857    /// `WriteAuthz` is stored as `pending_write_authz` for the duration of the
5858    /// call so that all inner `batch.commit()` calls are authz-checked.
5859    ///
5860    /// MERGE is handled specially: the MERGE scope precondition (§3.3) is
5861    /// checked in `exec_merge` BEFORE `has_node` to close the §6.2
5862    /// timing-oracle item (hidden ≡ absent for unscoped roles).
5863    ///
5864    /// Roles with `write: None` (v1 behavior) → `RoleWriteDenied` with
5865    /// "this endpoint is not permitted".
5866    pub fn query_write_authz(
5867        &mut self,
5868        role: &str,
5869        cypher: &str,
5870        params: &BTreeMap<String, Value>,
5871    ) -> Result<ResultSet> {
5872        // Resolve scope (fails fast if role has no write scope).
5873        // write:None → byte-identical v1 blanket-403 body (plan §v1-sidecar mandate).
5874        let scope =
5875            {
5876                let roles = self.roles.as_deref().ok_or_else(|| GraphError::Corrupt {
5877                    detail: "roles.json was corrupt at open; re-open to restore role access".into(),
5878                })?;
5879                let def = roles.iter().find(|r| r.name == role).ok_or_else(|| {
5880                    GraphError::KeyNotFound {
5881                        key: format!("role:{role}"),
5882                    }
5883                })?;
5884                def.write
5885                    .clone()
5886                    .ok_or_else(|| GraphError::RoleWriteDenied {
5887                        reason: "role-bound token: writes are not permitted".into(),
5888                    })?
5889            };
5890        // Resolve mask inside the call (same guard, §5 coherence).
5891        let mask = self.mask_for_role(role)?;
5892        self.pending_write_authz = Some(WriteAuthz {
5893            role: role.into(),
5894            scope,
5895            mask,
5896        });
5897        // RAII guard: always clears pending_write_authz on scope exit, including
5898        // on panic or early-return, mirroring the RestoreEmitDeltas precedent.
5899        struct ClearPendingAuthzOnDrop(*mut Option<WriteAuthz>);
5900        impl Drop for ClearPendingAuthzOnDrop {
5901            fn drop(&mut self) {
5902                // SAFETY: pointer into the owning GraphDb; guard is dropped
5903                // within this function's frame before it returns.
5904                unsafe { *self.0 = None };
5905            }
5906        }
5907        // SAFETY: raw pointer into self; guard dropped before this fn returns.
5908        let _authz_guard = ClearPendingAuthzOnDrop(&mut self.pending_write_authz as *mut _);
5909        let tokens = lex(cypher).map_err(|e| GraphError::QueryError {
5910            detail: format!("lex: {e}"),
5911        })?;
5912        let stmt = parse_write(&tokens).map_err(|e| GraphError::QueryError {
5913            detail: format!("parse: {e}"),
5914        })?;
5915        self.exec_write_stmt(stmt, params)
5916    }
5917
5918    /// Execute `ops` with optional role-scoped write authorization, suppressing
5919    /// fsync (for use inside the group-commit drain thread, which performs one
5920    /// group fsync after releasing the write lock).
5921    ///
5922    /// Identical to [`write_batch_authz`] except the fsync policy is temporarily
5923    /// forced to `Relaxed` for the duration of the call, matching the drain-thread
5924    /// contract established by [`commit_batch_nosync`].
5925    pub(crate) fn write_batch_authz_nosync(
5926        &mut self,
5927        authz: Option<&WriteAuthz>,
5928        ops: Vec<BatchOp>,
5929    ) -> Result<(usize, usize)> {
5930        let saved = self.fsync;
5931        struct RestoreFsync(*mut FsyncPolicy, FsyncPolicy);
5932        impl Drop for RestoreFsync {
5933            fn drop(&mut self) {
5934                // SAFETY: pointer into the owning GraphDb; guard is dropped
5935                // within the enclosing function's frame before it returns.
5936                unsafe { *self.0 = self.1 };
5937            }
5938        }
5939        // SAFETY: raw pointer into self; guard dropped before this fn returns.
5940        let _g = RestoreFsync(&mut self.fsync as *mut FsyncPolicy, saved);
5941        self.fsync = FsyncPolicy::Relaxed;
5942        self.commit_logged_batch(ops, None, authz.cloned())
5943    }
5944
5945    /// Execute a `/ingest` request with role-scoped write authorization.
5946    ///
5947    /// Resolves the role's `WriteScope` and `NodeMask` inside this call (same
5948    /// write-guard lifetime as the mutation, satisfying §5 lock discipline).
5949    /// Sets `pending_write_authz` for the duration of the call so that the
5950    /// `commit_ingest` → `commit_logged_batch` path picks up the authz context
5951    /// and evaluates the decision table per-op before any WAL write.
5952    ///
5953    /// §7.3: roles with empty `create_labels` will see every `InsertNode` op
5954    /// denied by the decision table with the appropriate §4.3 scope reason;
5955    /// no special HTTP-layer check is needed.
5956    ///
5957    /// Roles with `write: None` return `RoleWriteDenied` with
5958    /// "writes are not permitted" (byte-identical to v1 blanket 403).
5959    pub fn ingest_with_edges_authz(
5960        &mut self,
5961        role: &str,
5962        label: &str,
5963        rows: Vec<std::collections::BTreeMap<String, Value>>,
5964        opts: &crate::ingest::IngestOptions,
5965        edges: &[(String, String, String)],
5966    ) -> Result<crate::ingest::IngestReport> {
5967        // Resolve scope (fails fast if role has no write scope).
5968        // write:None → byte-identical v1 blanket-403 body (plan §v1-sidecar mandate).
5969        let scope =
5970            {
5971                let roles = self.roles.as_deref().ok_or_else(|| GraphError::Corrupt {
5972                    detail: "roles.json was corrupt at open; re-open to restore role access".into(),
5973                })?;
5974                let def = roles.iter().find(|r| r.name == role).ok_or_else(|| {
5975                    GraphError::KeyNotFound {
5976                        key: format!("role:{role}"),
5977                    }
5978                })?;
5979                def.write
5980                    .clone()
5981                    .ok_or_else(|| GraphError::RoleWriteDenied {
5982                        reason: "role-bound token: writes are not permitted".into(),
5983                    })?
5984            };
5985        let mask = self.mask_for_role(role)?;
5986        self.pending_write_authz = Some(WriteAuthz {
5987            role: role.into(),
5988            scope,
5989            mask,
5990        });
5991        // RAII guard: always clears pending_write_authz on scope exit, including
5992        // on panic or early-return, mirroring the RestoreEmitDeltas precedent.
5993        struct ClearPendingAuthzOnDrop(*mut Option<WriteAuthz>);
5994        impl Drop for ClearPendingAuthzOnDrop {
5995            fn drop(&mut self) {
5996                // SAFETY: pointer into the owning GraphDb; guard is dropped
5997                // within this function's frame before it returns.
5998                unsafe { *self.0 = None };
5999            }
6000        }
6001        // SAFETY: raw pointer into self; guard dropped before this fn returns.
6002        let _authz_guard = ClearPendingAuthzOnDrop(&mut self.pending_write_authz as *mut _);
6003        self.ingest_with_edges(label, rows, opts, edges)
6004    }
6005
6006    /// Evaluate the write-authz decision table for one `BatchOp`.
6007    ///
6008    /// Called by `commit_logged_batch` for each op when `pending_write_authz`
6009    /// is `Some`, BEFORE MutPreview.  A denial returns an error immediately;
6010    /// the remaining ops are not evaluated and no WAL frame is written.
6011    ///
6012    /// `batch_created` carries the key→label pairs of nodes that earlier ops in
6013    /// THIS batch will create.  Used by `InsertEdgeUpsert` to count same-batch
6014    /// placeholder nodes as visible (spec: "a placeholder endpoint the SAME
6015    /// batch creates counts as visible if its label passed the create-class gate").
6016    fn check_single_op_authz(
6017        &self,
6018        authz: &WriteAuthz,
6019        op: &BatchOp,
6020        batch_created: &BTreeMap<String, String>,
6021    ) -> Result<()> {
6022        // Helper: 3-way node status under the authz mask.
6023        //
6024        // Batch-created nodes (from earlier InsertNode in THIS batch) are treated
6025        // as Visible with their recorded label — their create gate already passed
6026        // and they are not yet in self.ids (not committed).  This fixes the
6027        // MERGE+ON CREATE SET case where InsertNode + SetProp arrive together:
6028        // the SetProp must not see the node as Absent.
6029        let node_status = |key: &str| -> NodeAuthzStatus {
6030            if let Some(label) = batch_created.get(key) {
6031                return NodeAuthzStatus::Visible(label.clone());
6032            }
6033            match self.ids.get(key) {
6034                None => NodeAuthzStatus::Absent,
6035                Some(id) if !authz.mask.contains_id(id) => NodeAuthzStatus::Hidden,
6036                Some(id) => {
6037                    let label = self
6038                        .labels
6039                        .get(id as usize)
6040                        .and_then(|&sym| {
6041                            if sym == u32::MAX {
6042                                None
6043                            } else {
6044                                self.syms.resolve(sym).map(str::to_string)
6045                            }
6046                        })
6047                        .unwrap_or_default();
6048                    NodeAuthzStatus::Visible(label)
6049                }
6050            }
6051        };
6052
6053        // Helper: is an InsertEdgeUpsert endpoint visible?
6054        // A same-batch placeholder counts as visible if its label passed
6055        // the create-class gate (spec "upsert placeholder-counts-as-visible").
6056        let upsert_ep_visible = |ep_key: &str, placeholder_label: &str| -> bool {
6057            // In store and visible?
6058            if let Some(id) = self.ids.get(ep_key) {
6059                return authz.mask.contains_id(id);
6060            }
6061            // Created by an earlier op in this batch?
6062            if let Some(created_label) = batch_created.get(ep_key) {
6063                return authz.scope.create_labels.contains(created_label);
6064            }
6065            // Will be created by THIS InsertEdgeUpsert: placeholder_label
6066            // must pass the create-class gate.
6067            authz
6068                .scope
6069                .create_labels
6070                .contains(&placeholder_label.to_string())
6071        };
6072
6073        match op {
6074            // RenameNode / CreateRule / DeleteRule: defense-in-depth gate.
6075            // These ops are never routed to role-scoped paths by the HTTP layer,
6076            // but we 403 them here to close any future bypass route.
6077            BatchOp::RenameNode { .. } | BatchOp::CreateRule(_) | BatchOp::DeleteRule { .. } => {
6078                return Err(GraphError::RoleWriteDenied {
6079                    reason: "role-bound token: this endpoint is not permitted".into(),
6080                });
6081            }
6082
6083            // ── CREATE-class: InsertNode ─────────────────────────────────────
6084            //
6085            // Decision table row 1 (scope-before-lookup): check label in
6086            // create_labels BEFORE any key lookup.  This is the structural
6087            // closure of the §6.2 timing-oracle item — the denial fires even
6088            // when the store is EMPTY (see test_create_scope_denied_empty_store).
6089            BatchOp::InsertNode { label, key, .. } => {
6090                if !authz.scope.create_labels.contains(label) {
6091                    return Err(GraphError::RoleWriteDenied {
6092                        reason: format!(
6093                            "role-bound token: label '{}' not in write scope (create_labels)",
6094                            label
6095                        ),
6096                    });
6097                }
6098                // Row 2/3: key lookup.
6099                match self.ids.get(key.as_str()) {
6100                    Some(id) if authz.mask.contains_id(id) => {
6101                        // Visible: DuplicateKey — let MutPreview handle this.
6102                    }
6103                    Some(_) => {
6104                        // Hidden: indistinguishable from absent to the role.
6105                        return Err(GraphError::RoleWriteDenied {
6106                            reason: "role-bound token: target node not visible".into(),
6107                        });
6108                    }
6109                    None => {
6110                        // Absent: proceed (create).
6111                    }
6112                }
6113            }
6114
6115            // ── UPDATE-class: SetProp, RemoveProp ────────────────────────────
6116            BatchOp::SetProp { key, .. } | BatchOp::RemoveProp { key, .. } => {
6117                if batch_created.contains_key(key.as_str()) {
6118                    // Batch-created node: create gate already passed this batch.
6119                    // Updating it in the same batch is always allowed, regardless
6120                    // of update_labels (ruling §3.5: "writer just created it").
6121                } else {
6122                    let label = match node_status(key) {
6123                        NodeAuthzStatus::Visible(lbl) => lbl,
6124                        _ => {
6125                            return Err(GraphError::RoleWriteDenied {
6126                                reason: "role-bound token: target node not visible".into(),
6127                            });
6128                        }
6129                    };
6130                    if !authz.scope.update_labels.contains(&label) {
6131                        return Err(GraphError::RoleWriteDenied {
6132                            reason: format!(
6133                                "role-bound token: label '{}' not in write scope (update_labels)",
6134                                label
6135                            ),
6136                        });
6137                    }
6138                }
6139            }
6140
6141            // ── DELETE-class: DeleteNode ─────────────────────────────────────
6142            BatchOp::DeleteNode { key } => {
6143                let label = match node_status(key) {
6144                    NodeAuthzStatus::Visible(lbl) => lbl,
6145                    _ => {
6146                        return Err(GraphError::RoleWriteDenied {
6147                            reason: "role-bound token: target node not visible".into(),
6148                        });
6149                    }
6150                };
6151                if !authz.scope.delete_labels.contains(&label) {
6152                    return Err(GraphError::RoleWriteDenied {
6153                        reason: format!(
6154                            "role-bound token: label '{}' not in write scope (delete_labels)",
6155                            label
6156                        ),
6157                    });
6158                }
6159            }
6160
6161            // ── DELETE-class: DeleteEdge ─────────────────────────────────────
6162            //
6163            // Derived-edge rejection runs BEFORE the delete_edge_types scope
6164            // check (spec §3.5: "existing derived-edge rejection precedes
6165            // delete_edge_types check").
6166            BatchOp::DeleteEdge {
6167                edge_type,
6168                src_key,
6169                dst_key,
6170            } => {
6171                // Check provenance ownership BEFORE scope (spec §3.5 ordering).
6172                if let (Some(src_id), Some(dst_id), Some(et_sym)) = (
6173                    self.ids.get(src_key.as_str()),
6174                    self.ids.get(dst_key.as_str()),
6175                    self.syms.get(edge_type.as_str()),
6176                ) {
6177                    if self.engine.is_owned(et_sym, src_id, dst_id) {
6178                        return Err(GraphError::RuleOwned {
6179                            detail: format!(
6180                                "edge {edge_type} {src_key}→{dst_key} is rule-owned; \
6181                                 delete or change the owning rule"
6182                            ),
6183                        });
6184                    }
6185                    // Also check would_derive via MutPreview (empty overlay, pre-batch).
6186                    let preview = MutPreview::new(self);
6187                    if preview.would_derive(edge_type, src_key, dst_key) {
6188                        return Err(GraphError::RuleOwned {
6189                            detail: format!(
6190                                "edge {edge_type} {src_key}→{dst_key} is rule-owned; \
6191                                 delete or change the owning rule, or a live rule would \
6192                                 re-derive it"
6193                            ),
6194                        });
6195                    }
6196                }
6197                // Scope check (AFTER derived-edge check, BEFORE endpoint visibility).
6198                if !authz.scope.delete_edge_types.contains(edge_type) {
6199                    return Err(GraphError::RoleWriteDenied {
6200                        reason: format!(
6201                            "role-bound token: edge type '{}' not in write scope (delete_edge_types)",
6202                            edge_type
6203                        ),
6204                    });
6205                }
6206                // Both endpoints must be visible.
6207                for ep_key in [src_key.as_str(), dst_key.as_str()] {
6208                    match self.ids.get(ep_key) {
6209                        None => {
6210                            return Err(GraphError::RoleWriteDenied {
6211                                reason: "role-bound token: edge endpoint not visible".into(),
6212                            });
6213                        }
6214                        Some(id) if !authz.mask.contains_id(id) => {
6215                            return Err(GraphError::RoleWriteDenied {
6216                                reason: "role-bound token: edge endpoint not visible".into(),
6217                            });
6218                        }
6219                        _ => {}
6220                    }
6221                }
6222            }
6223
6224            // ── EDGE-CREATE: InsertEdge ──────────────────────────────────────
6225            //
6226            // Scope check BEFORE endpoint lookup (preserves timing symmetry).
6227            BatchOp::InsertEdge {
6228                edge_type,
6229                src_key,
6230                dst_key,
6231            } => {
6232                if !authz.scope.create_edge_types.contains(edge_type) {
6233                    return Err(GraphError::RoleWriteDenied {
6234                        reason: format!(
6235                            "role-bound token: edge type '{}' not in write scope (create_edge_types)",
6236                            edge_type
6237                        ),
6238                    });
6239                }
6240                // Both endpoints must be visible. A node created by an earlier
6241                // InsertNode in the same batch (tracked in batch_created) counts
6242                // as visible if its label passed the create-class gate.
6243                for ep_key in [src_key.as_str(), dst_key.as_str()] {
6244                    if batch_created.contains_key(ep_key) {
6245                        // Created earlier this batch — already scope-checked.
6246                        continue;
6247                    }
6248                    match self.ids.get(ep_key) {
6249                        None => {
6250                            return Err(GraphError::RoleWriteDenied {
6251                                reason: "role-bound token: edge endpoint not visible".into(),
6252                            });
6253                        }
6254                        Some(id) if !authz.mask.contains_id(id) => {
6255                            return Err(GraphError::RoleWriteDenied {
6256                                reason: "role-bound token: edge endpoint not visible".into(),
6257                            });
6258                        }
6259                        _ => {}
6260                    }
6261                }
6262            }
6263
6264            // ── EDGE-CREATE: InsertEdgeUpsert ────────────────────────────────
6265            //
6266            // Scope check first; then endpoint visibility using same-batch
6267            // placeholder awareness (spec: "a placeholder endpoint the SAME
6268            // batch creates counts as visible if its label passed the
6269            // create-class gate").
6270            BatchOp::InsertEdgeUpsert {
6271                edge_type,
6272                src_key,
6273                dst_key,
6274                placeholder_label,
6275            } => {
6276                if !authz.scope.create_edge_types.contains(edge_type) {
6277                    return Err(GraphError::RoleWriteDenied {
6278                        reason: format!(
6279                            "role-bound token: edge type '{}' not in write scope (create_edge_types)",
6280                            edge_type
6281                        ),
6282                    });
6283                }
6284                // Check placeholder label against create_labels (create-class gate).
6285                // This ensures the auto-created endpoints are scope-allowed.
6286                for ep_key in [src_key.as_str(), dst_key.as_str()] {
6287                    if !upsert_ep_visible(ep_key, placeholder_label) {
6288                        return Err(GraphError::RoleWriteDenied {
6289                            reason: "role-bound token: edge endpoint not visible".into(),
6290                        });
6291                    }
6292                }
6293            }
6294        }
6295        Ok(())
6296    }
6297
6298    /// Write `roles` to `roles.json` atomically and update the in-memory list.
6299    ///
6300    /// Called by `apply_schema` when roles change. Never called on unchanged
6301    /// re-apply — this preserves byte-identical idempotency.
6302    pub(crate) fn commit_roles(&mut self, roles: Vec<RoleDef>) -> Result<()> {
6303        let file = RolesFile::new_versioned(roles.clone());
6304        let bytes = serde_json::to_vec(&file).map_err(|e| GraphError::Corrupt {
6305            detail: format!("roles serialization: {e}"),
6306        })?;
6307        self.fs
6308            .write_atomic(FileId::Roles, &bytes)
6309            .map_err(GraphError::Io)?;
6310        self.roles = Some(roles);
6311        // Refresh the MVCC frozen overlay so that reader() immediately sees the
6312        // updated role definitions without waiting for the next K-commit fold.
6313        self.fold_now();
6314        Ok(())
6315    }
6316
6317    fn view(&self) -> GraphView<'_> {
6318        GraphView {
6319            ids: &self.ids,
6320            syms: &self.syms,
6321            labels: &self.labels,
6322            props: self.props_view(),
6323            topo: self.topo_view(),
6324            edge_props: self.edge_props_view(),
6325            mask: None,
6326            prop_index: Some(&self.prop_index),
6327        }
6328    }
6329
6330    fn view_masked<'a>(&'a self, mask: &'a crate::mask::NodeMask) -> GraphView<'a> {
6331        GraphView {
6332            ids: &self.ids,
6333            syms: &self.syms,
6334            labels: &self.labels,
6335            props: self.props_view(),
6336            topo: self.topo_view(),
6337            edge_props: self.edge_props_view(),
6338            mask: Some(&mask.visible),
6339            prop_index: Some(&self.prop_index),
6340        }
6341    }
6342
6343    /// Execute a read-only Cypher query with a node visibility mask.
6344    ///
6345    /// Only nodes whose key is in `mask` are accessible: label scans, key
6346    /// lookups, and neighbor expansions all respect the mask. Edges where
6347    /// either endpoint is hidden are silently dropped.
6348    ///
6349    /// Returns `Err` with a "masked queries are read-only" message when
6350    /// `cypher` is a write statement (CREATE / MERGE / MATCH…SET / DELETE).
6351    pub fn query_masked(
6352        &self,
6353        cypher: &str,
6354        params: &std::collections::BTreeMap<String, Value>,
6355        mask: &crate::mask::NodeMask,
6356    ) -> Result<ResultSet> {
6357        // Reject write statements up front.
6358        let tokens = lex(cypher).map_err(|e| GraphError::QueryError {
6359            detail: format!("lex: {e}"),
6360        })?;
6361        if is_write_tokens(&tokens) {
6362            return Err(GraphError::MaskedReadOnly);
6363        }
6364        let union = parse_read(&tokens).map_err(|e| GraphError::QueryError {
6365            detail: format!("parse: {e}"),
6366        })?;
6367        // Each UNION part executes against the same masked view, so the mask
6368        // applies uniformly across the chain.
6369        execute_union(&self.view_masked(mask), &union, &Params(params)).map_err(|e| {
6370            GraphError::QueryError {
6371                detail: format!("execute: {e}"),
6372            }
6373        })
6374    }
6375
6376    pub fn node_ref(&self, key: &str) -> Option<NodeRef<'_, F>> {
6377        let id = self.ids.get(key)?;
6378        Some(NodeRef { db: self, id })
6379    }
6380
6381    /// BFS neighborhood expansion restricted to visible nodes in `mask`.
6382    ///
6383    /// Hidden nodes are never used as traversal intermediaries in either
6384    /// [`MaskMode::Omit`] or [`MaskMode::Stub`] — a visible node reachable
6385    /// only through a hidden node will not appear in results.
6386    ///
6387    /// In [`MaskMode::Stub`] mode, hidden nodes that are direct neighbours of
6388    /// a visited visible node are appended to the result as stub rows
6389    /// (`label` column is `null`, same key+depth columns as visible rows).
6390    /// They are NOT added to the BFS frontier.
6391    ///
6392    /// Returns `None` when `key` does not exist (caller should 404).
6393    ///
6394    /// **SECURITY**: role-token callers always pass an Omit-mode mask, so
6395    /// stub rows are never produced on the role path.
6396    pub fn neighborhood_masked(
6397        &self,
6398        key: &str,
6399        depth: u32,
6400        edge_types: Option<&[&str]>,
6401        dir: Dir,
6402        mask: &crate::mask::NodeMask,
6403    ) -> Option<ResultSet> {
6404        let start_id = self.ids.get(key)?;
6405        let view = self.view_masked(mask);
6406        let resolved: Option<Vec<u32>> = edge_types.map(|names| {
6407            names
6408                .iter()
6409                .filter_map(|name| view.syms.get(name))
6410                .collect()
6411        });
6412        let nb = neighborhood(&view, start_id, depth, resolved.as_deref(), dir);
6413        let mut rs = ResultSet::new(vec!["key".into(), "label".into(), "depth".into()]);
6414        // Collect visible BFS results (start_id at depth 0, BFS nodes after).
6415        let mut visited: Vec<(u32, u32)> = Vec::with_capacity(nb.nodes.len() + 1);
6416        visited.push((start_id, 0));
6417        for (nid, d) in &nb.nodes {
6418            let k = view.key_of(*nid);
6419            let label = view
6420                .label_of(*nid)
6421                .expect("real nodes always have a label; u32::MAX sentinel cannot occur");
6422            rs.push_row(vec![
6423                Some(Value::Str(k.to_string())),
6424                Some(Value::Str(label.to_string())),
6425                Some(Value::Int(*d as i64)),
6426            ]);
6427            visited.push((*nid, *d));
6428        }
6429        // Stub mode: add hidden direct neighbours of each visited node as stubs.
6430        // Hidden nodes are edge-endpoints only — they are not added to the BFS
6431        // frontier, so the BFS never expands through them.
6432        if mask.mode() == crate::mask::MaskMode::Stub {
6433            let raw_view = self.view();
6434            let mut seen: std::collections::HashSet<u32> =
6435                visited.iter().map(|(id, _)| *id).collect();
6436            for (node_id, node_depth) in &visited {
6437                if *node_depth >= depth {
6438                    continue;
6439                }
6440                for e in expand(&raw_view, *node_id, resolved.as_deref(), dir) {
6441                    let nbr = if e.src == *node_id { e.dst } else { e.src };
6442                    if !mask.contains_id(nbr) && seen.insert(nbr) {
6443                        if let Some(k) = self.ids.key_of(nbr) {
6444                            rs.push_row(vec![
6445                                Some(Value::Str(k.to_string())),
6446                                None,
6447                                Some(Value::Int((*node_depth + 1) as i64)),
6448                            ]);
6449                        }
6450                    }
6451                }
6452            }
6453        }
6454        Some(rs)
6455    }
6456
6457    /// Live node's key, label, and columnar props. Unknown or tombstoned → `None`.
6458    pub fn node_info(&self, key: &str) -> Option<NodeInfo> {
6459        let n = self.node_ref(key)?;
6460        Some(NodeInfo {
6461            key: n.key().to_string(),
6462            label: n.label().to_string(),
6463            props: n.props(),
6464        })
6465    }
6466
6467    /// Look up a node with mask awareness.
6468    ///
6469    /// | Key state         | Omit mode       | Stub mode              |
6470    /// |-------------------|-----------------|------------------------|
6471    /// | does not exist    | `None` (→ 404)  | `None` (→ 404)         |
6472    /// | exists, visible   | `Some(Visible)` | `Some(Visible)`        |
6473    /// | exists, hidden    | `None` (→ 404)  | `Some(Restricted)`     |
6474    ///
6475    /// **SECURITY**: only call from client-mask (full-token) paths.
6476    /// Role-token paths must use [`node_info`] after an explicit visibility check.
6477    pub fn node_info_masked(
6478        &self,
6479        key: &str,
6480        mask: &crate::mask::NodeMask,
6481    ) -> Option<MaskedNodeResult> {
6482        let id = self.ids.get(key)?;
6483        if mask.contains_id(id) {
6484            Some(MaskedNodeResult::Visible(self.node_info(key)?))
6485        } else {
6486            match mask.mode() {
6487                crate::mask::MaskMode::Stub => Some(MaskedNodeResult::Restricted),
6488                crate::mask::MaskMode::Omit => None,
6489            }
6490        }
6491    }
6492
6493    /// Get edges for `key` with mask-aware hidden-endpoint handling.
6494    ///
6495    /// - Omit mode: edges to hidden endpoints are excluded (same as role-path filtering).
6496    /// - Stub mode: edges to hidden endpoints are included; `src_restricted`/`dst_restricted`
6497    ///   is `true` for each hidden endpoint.
6498    ///
6499    /// Unknown key → [`GraphError::KeyNotFound`].
6500    ///
6501    /// **SECURITY**: only call from client-mask (full-token) paths.
6502    pub fn node_edges_masked(
6503        &self,
6504        key: &str,
6505        mask: &crate::mask::NodeMask,
6506    ) -> Result<Vec<MaskedEdge>> {
6507        self.ensure_v8_base_sections_loaded();
6508        let id = self
6509            .ids
6510            .get(key)
6511            .ok_or_else(|| GraphError::KeyNotFound { key: key.into() })?;
6512        let derived: BTreeSet<(u32, u32, u32)> = self
6513            .engine
6514            .provenance_touching(id)
6515            .map(|(_rule, etype, src, dst)| (etype, src, dst))
6516            .collect();
6517        let mut edges = Vec::new();
6518        let tv = self.topo_view();
6519        for etype in tv.etypes() {
6520            // etype comes from the archived CSR (access_unchecked, no eager CRC).
6521            // A bit-flip in the large TOPOLOGY section can produce an etype id
6522            // that is not in the interner.  Return Corrupt rather than panic.
6523            let edge_type = self
6524                .syms
6525                .resolve(etype)
6526                .ok_or_else(|| GraphError::Corrupt {
6527                    detail: format!("v8: topology etype {etype} not in interner"),
6528                })?
6529                .to_string();
6530            for dir in [Direction::Out, Direction::In] {
6531                for &nbr in tv.neighbors(etype, dir, id).as_ref() {
6532                    let nbr_restricted = !mask.contains_id(nbr);
6533                    if nbr_restricted && mask.mode() == crate::mask::MaskMode::Omit {
6534                        continue;
6535                    }
6536                    let nbr_key = self
6537                        .ids
6538                        .key_of(nbr)
6539                        .ok_or_else(|| GraphError::Corrupt {
6540                            detail: format!("topology id {nbr} has no key"),
6541                        })?
6542                        .to_string();
6543                    let (src_id, dst_id, src_key, dst_key, src_restricted, dst_restricted) =
6544                        match dir {
6545                            Direction::Out => {
6546                                (id, nbr, key.to_string(), nbr_key, false, nbr_restricted)
6547                            }
6548                            Direction::In => {
6549                                (nbr, id, nbr_key, key.to_string(), nbr_restricted, false)
6550                            }
6551                        };
6552                    edges.push(MaskedEdge {
6553                        edge_type: edge_type.clone(),
6554                        src_key,
6555                        src_restricted,
6556                        dst_key,
6557                        dst_restricted,
6558                        derived: derived.contains(&(etype, src_id, dst_id)),
6559                    });
6560                }
6561            }
6562        }
6563        edges.sort_by(|a, b| {
6564            a.edge_type
6565                .cmp(&b.edge_type)
6566                .then(a.src_key.cmp(&b.src_key))
6567                .then(a.dst_key.cmp(&b.dst_key))
6568        });
6569        edges.dedup_by(|a, b| {
6570            a.edge_type == b.edge_type && a.src_key == b.src_key && a.dst_key == b.dst_key
6571        });
6572        Ok(edges)
6573    }
6574
6575    /// Every directed edge incident on `key`, both directions, every etype.
6576    ///
6577    /// Walk is `topology.etypes()` × `{Out, In}` × `neighbors()`. `derived` is
6578    /// membership in [`RuleEngine::provenance_touching`] (O(degree) via the
6579    /// Plan-8 `by_node` index). Sorted by `(edge_type, src_key, dst_key)`.
6580    /// Unknown key → [`GraphError::KeyNotFound`].
6581    pub fn node_edges(&self, key: &str) -> Result<Vec<EdgeInfo>> {
6582        self.ensure_v8_base_sections_loaded();
6583        let id = self
6584            .ids
6585            .get(key)
6586            .ok_or_else(|| GraphError::KeyNotFound { key: key.into() })?;
6587        let derived: BTreeSet<(u32, u32, u32)> = self
6588            .engine
6589            .provenance_touching(id)
6590            .map(|(_rule, etype, src, dst)| (etype, src, dst))
6591            .collect();
6592        let mut edges = Vec::new();
6593        let tv = self.topo_view();
6594        for etype in tv.etypes() {
6595            // Same guard as node_edges_masked: etype from unchecked-CRC CSR.
6596            let edge_type = self
6597                .syms
6598                .resolve(etype)
6599                .ok_or_else(|| GraphError::Corrupt {
6600                    detail: format!("v8: topology etype {etype} not in interner"),
6601                })?
6602                .to_string();
6603            for dir in [Direction::Out, Direction::In] {
6604                for &nbr in tv.neighbors(etype, dir, id).as_ref() {
6605                    let (src, dst, src_key, dst_key) = match dir {
6606                        Direction::Out => (
6607                            id,
6608                            nbr,
6609                            key.to_string(),
6610                            self.ids
6611                                .key_of(nbr)
6612                                .ok_or_else(|| GraphError::Corrupt {
6613                                    detail: format!("topology id {nbr} has no key"),
6614                                })?
6615                                .to_string(),
6616                        ),
6617                        Direction::In => (
6618                            nbr,
6619                            id,
6620                            self.ids
6621                                .key_of(nbr)
6622                                .ok_or_else(|| GraphError::Corrupt {
6623                                    detail: format!("topology id {nbr} has no key"),
6624                                })?
6625                                .to_string(),
6626                            key.to_string(),
6627                        ),
6628                    };
6629                    edges.push(EdgeInfo {
6630                        edge_type: edge_type.clone(),
6631                        src_key,
6632                        dst_key,
6633                        derived: derived.contains(&(etype, src, dst)),
6634                    });
6635                }
6636            }
6637        }
6638        edges.sort_by(|a, b| {
6639            a.edge_type
6640                .cmp(&b.edge_type)
6641                .then(a.src_key.cmp(&b.src_key))
6642                .then(a.dst_key.cmp(&b.dst_key))
6643        });
6644        // Self-loops appear in both Out and In; sort makes the pair adjacent
6645        // (sort key matches PartialEq for this case) so one pass drops the dup.
6646        edges.dedup();
6647        Ok(edges)
6648    }
6649
6650    // ── Backup ────────────────────────────────────────────────────────────────
6651
6652    /// Copy this store to `dest` as a consistent, verified snapshot.
6653    ///
6654    /// Copies every durable file in the database directory — `snapshot.bin`,
6655    /// `wal.bin`, all `wal.<N>.archive` files, `wal.floor`, `wal.genesis`, and
6656    /// `roles.json` — into a freshly created `dest` directory using OS-level
6657    /// `copy` calls (no large in-process buffers).
6658    ///
6659    /// # Consistency guarantee
6660    ///
6661    /// The guarantee is **process-local**: the caller holds `&self`, which
6662    /// prevents any concurrent writer in the **same process** from modifying
6663    /// the files during the copy.  Running `mushroomdb backup` against a
6664    /// directory that is **concurrently being written by another process** (e.g.
6665    /// `mushroomdb serve`) is **unsafe** — the copy can be torn.  The post-copy
6666    /// `verified: true` result reduces but does not eliminate the risk of a
6667    /// silent corrupt backup (CRC catches many bit-flips; it cannot catch a
6668    /// consistent mid-write snapshot).
6669    ///
6670    /// **The safe path for a live-served store is `POST /backup` on the HTTP
6671    /// server.** That handler acquires the read lock on the shared database
6672    /// before calling this method, which is the correct cross-process
6673    /// synchronisation point because the server is the single process writing
6674    /// the files.
6675    ///
6676    /// After copying, opens the destination read-only and runs the CRC section
6677    /// verifier (`verify_snapshot`) to confirm byte-for-byte integrity.
6678    /// `BackupReport::verified` reflects whether both checks passed.
6679    ///
6680    /// Returns `Err` when `self` is not backed by a `RealFs` (e.g. `SimFs`).
6681    pub fn backup_to(&self, dest: &std::path::Path) -> Result<BackupReport> {
6682        // Derive source directory from snapshot_path (RealFs only).
6683        let src_dir = match self.fs.snapshot_path() {
6684            Some(p) => p.parent().map(|d| d.to_path_buf()).ok_or_else(|| {
6685                GraphError::Io(std::io::Error::other("snapshot has no parent dir"))
6686            })?,
6687            None => {
6688                return Err(GraphError::Io(std::io::Error::other(
6689                    "backup_to requires a real filesystem (RealFs)",
6690                )))
6691            }
6692        };
6693
6694        std::fs::create_dir_all(dest)?;
6695
6696        let mut files: Vec<String> = Vec::new();
6697        let mut bytes: u64 = 0;
6698
6699        // Helper: copy src_dir/name → dest/name if the file exists.
6700        let mut try_copy = |name: &str| -> std::io::Result<()> {
6701            let src_path = src_dir.join(name);
6702            if src_path.exists() {
6703                let n = std::fs::copy(&src_path, dest.join(name))?;
6704                bytes += n;
6705                files.push(name.to_string());
6706            }
6707            Ok(())
6708        };
6709
6710        try_copy("snapshot.bin")?;
6711        try_copy("snapshot.bin.bak")?;
6712        try_copy("wal.bin")?;
6713        try_copy("wal.floor")?;
6714        try_copy("wal.genesis")?;
6715        try_copy("roles.json")?;
6716
6717        // Copy WAL archives.
6718        let archives = self.fs.list_archives()?;
6719        for n in &archives {
6720            let name = format!("wal.{n}.archive");
6721            let n_bytes = std::fs::copy(src_dir.join(&name), dest.join(&name))?;
6722            bytes += n_bytes;
6723            files.push(name);
6724        }
6725
6726        files.sort();
6727
6728        // Post-copy verification: open dest and run CRC checks.
6729        let snap_in_dest = dest.join("snapshot.bin").exists();
6730        let crc_ok = if snap_in_dest {
6731            crate::verify_snapshot(dest)
6732                .map(|results| results.iter().all(|(_, _, _, r)| r.is_ok()))
6733                .unwrap_or(false)
6734        } else {
6735            true // WAL-only store: nothing to CRC-check in snapshot
6736        };
6737        let opens_ok = GraphDb::<core_storage::fs::RealFs>::open(dest).is_ok();
6738        let verified = crc_ok && opens_ok;
6739
6740        Ok(BackupReport {
6741            files,
6742            bytes,
6743            verified,
6744        })
6745    }
6746
6747    // ── Export helpers ────────────────────────────────────────────────────────
6748
6749    /// All live nodes, sorted by key (deterministic).
6750    ///
6751    /// Reads base + WAL overlay. Tombstoned nodes are excluded.
6752    pub fn all_nodes_for_export(&self) -> Vec<NodeInfo> {
6753        self.ensure_v8_base_sections_loaded();
6754        let pv = self.props_view();
6755        let mut nodes = Vec::new();
6756        for id in 0..self.ids.len() as u32 {
6757            let Some(key) = self.ids.key_of(id) else {
6758                continue;
6759            };
6760            let Some(&sym) = self.labels.get(id as usize) else {
6761                continue;
6762            };
6763            if sym == u32::MAX {
6764                continue; // tombstoned
6765            }
6766            let Some(label) = self.syms.resolve(sym) else {
6767                continue;
6768            };
6769            let mut props = BTreeMap::new();
6770            for field in pv.field_names() {
6771                if let Some(vr) = pv.get(id, &field) {
6772                    props.insert(field, vr.into_value());
6773                }
6774            }
6775            nodes.push(NodeInfo {
6776                key: key.to_string(),
6777                label: label.to_string(),
6778                props,
6779            });
6780        }
6781        nodes.sort_by(|a, b| a.key.cmp(&b.key));
6782        nodes
6783    }
6784
6785    /// All directed edges, sorted by `(edge_type, src, dst)`. Each edge appears once.
6786    ///
6787    /// Derived edges carry `derived: true` and the creating rule's name in `rule`.
6788    /// Manual edges carry `derived: false` and `rule: None`.
6789    /// Deterministic across runs on the same store state.
6790    pub fn all_edges_for_export(&self) -> Vec<ExportEdge> {
6791        self.ensure_v8_base_sections_loaded();
6792
6793        // Build (etype_sym, src_id, dst_id) → rule_name for O(1) derivation lookup.
6794        let mut prov: HashMap<(u32, u32, u32), String> = HashMap::new();
6795        for (rule_name, triples) in self.engine.provenance() {
6796            for &(etype, src, dst) in triples {
6797                prov.insert((etype, src, dst), rule_name.clone());
6798            }
6799        }
6800
6801        let tv = self.topo_view();
6802        let mut edges = Vec::new();
6803
6804        for id in 0..self.ids.len() as u32 {
6805            let Some(key) = self.ids.key_of(id) else {
6806                continue;
6807            };
6808            let Some(&lsym) = self.labels.get(id as usize) else {
6809                continue;
6810            };
6811            if lsym == u32::MAX {
6812                continue; // tombstoned
6813            }
6814
6815            for etype_sym in tv.etypes() {
6816                // etype from archived CSR (access_unchecked, no eager CRC).
6817                // Skip edges whose etype is not in the interner; this can only
6818                // occur with a corrupt large TOPOLOGY section (bit-flip on an
6819                // etype field in the archived data).  The function returns Vec,
6820                // not Result, so we continue rather than propagate.
6821                let Some(edge_type) = self.syms.resolve(etype_sym) else {
6822                    continue;
6823                };
6824                let edge_type = edge_type.to_string();
6825                for &nbr in tv.neighbors(etype_sym, Direction::Out, id).as_ref() {
6826                    let Some(dst_key) = self.ids.key_of(nbr) else {
6827                        continue; // skip corrupt entries
6828                    };
6829                    let prov_key = (etype_sym, id, nbr);
6830                    let rule = prov.get(&prov_key).cloned();
6831                    let derived = rule.is_some();
6832                    edges.push(ExportEdge {
6833                        edge_type: edge_type.clone(),
6834                        src: key.to_string(),
6835                        dst: dst_key.to_string(),
6836                        derived,
6837                        rule,
6838                    });
6839                }
6840            }
6841        }
6842
6843        edges.sort_by(|a, b| {
6844            a.edge_type
6845                .cmp(&b.edge_type)
6846                .then(a.src.cmp(&b.src))
6847                .then(a.dst.cmp(&b.dst))
6848        });
6849        edges
6850    }
6851
6852    pub fn nodes_with_label(&self, label: &str) -> Vec<NodeRef<'_, F>> {
6853        self.view()
6854            .nodes_with_label(label)
6855            .into_iter()
6856            .map(|id| NodeRef { db: self, id })
6857            .collect()
6858    }
6859
6860    pub fn find_nodes(&self, label: &str, filter: &Filter) -> Vec<NodeRef<'_, F>> {
6861        let view = self.view();
6862        view.nodes_with_label(label)
6863            .into_iter()
6864            .filter(|&id| {
6865                eval_filter(filter, &|field| {
6866                    view.prop(id, field).map(|vr| vr.into_value())
6867                })
6868            })
6869            .map(|id| NodeRef { db: self, id })
6870            .collect()
6871    }
6872
6873    /// Returns `true` if any approximate (HNSW) VectorSimilar rule covers
6874    /// `field`.  Use as a capability probe: when `true`, `find_similar_vector`
6875    /// with `label = None` will use the native ANN path rather than the O(n)
6876    /// brute-force scan.
6877    pub fn has_vector_rule(&self, field: &str) -> bool {
6878        self.engine.hnsw_has_rule(field)
6879    }
6880
6881    /// Find nodes whose `field` vector is most similar to `q` (cosine
6882    /// similarity), returning up to `k` results with similarity ≥ `min`,
6883    /// sorted descending.
6884    ///
6885    /// When `label` is `None` the search spans all labels (via
6886    /// `hnsw_search_any_dst` or a full brute-force scan); when `label` is
6887    /// `Some(lbl)` it restricts to nodes with that label.
6888    ///
6889    /// Uses the HNSW index when one is available (fast path); otherwise falls
6890    /// back to an O(n) brute-force scan.
6891    pub fn find_similar_vector(
6892        &self,
6893        field: &str,
6894        label: Option<&str>,
6895        q: &[f64],
6896        k: usize,
6897        min: f64,
6898    ) -> Vec<(String, f64)> {
6899        // Ensure any HNSW blobs retained from the snapshot are deserialized
6900        // before the first ANN query on a clean-open (no-WAL) path.
6901        self.engine.ensure_hnsw_loaded();
6902        // L2-normalise query for cosine via dot product.
6903        let norm: f64 = q.iter().map(|x| x * x).sum::<f64>().sqrt();
6904        if norm == 0.0 {
6905            return vec![];
6906        }
6907        let q_unit: Vec<f64> = q.iter().map(|x| x / norm).collect();
6908
6909        // Try HNSW fast path.
6910        // `None` label searches across all VectorSimilar rules covering `field`
6911        // (merging their results); `Some(lbl)` restricts to rules whose
6912        // dst_label matches.  Returns `None` when no populated HNSW index
6913        // covers the request — the O(n) brute-force fallback handles that case.
6914        let hnsw_hits = match label {
6915            Some(lbl) => self.engine.hnsw_search_dst(field, lbl, &q_unit, k),
6916            None => self.engine.hnsw_search_any_dst(field, &q_unit, k),
6917        };
6918        if let Some(hits) = hnsw_hits {
6919            let mut out: Vec<(String, f64)> = hits
6920                .into_iter()
6921                .filter(|&(_, sim)| sim >= min)
6922                .filter_map(|(id, sim)| self.ids.key_of(id).map(|key| (key.to_string(), sim)))
6923                .collect();
6924            out.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
6925            out.truncate(k);
6926            return out;
6927        }
6928
6929        // Brute-force fallback: O(n) scan (only reached when no HNSW index
6930        // covers the request).
6931        let view = self.view();
6932        let candidate_ids: Vec<u32> = match label {
6933            Some(lbl) => view.nodes_with_label(lbl),
6934            None => view.nodes_all(),
6935        };
6936        let mut scored: Vec<(String, f64)> = candidate_ids
6937            .into_iter()
6938            .filter_map(|id| {
6939                let v = view.prop(id, field)?;
6940                let v_owned = v.into_value();
6941                let xs = value_as_float_list(&v_owned)?;
6942                let v_norm: f64 = xs.iter().map(|x| x * x).sum::<f64>().sqrt();
6943                if v_norm == 0.0 {
6944                    return None;
6945                }
6946                let dot: f64 = q_unit
6947                    .iter()
6948                    .zip(xs.iter())
6949                    .map(|(a, b)| a * (b / v_norm))
6950                    .sum();
6951                if dot < min {
6952                    return None;
6953                }
6954                let key = self.ids.key_of(id)?.to_string();
6955                Some((key, dot))
6956            })
6957            .collect();
6958        scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
6959        scored.truncate(k);
6960        scored
6961    }
6962
6963    /// Like [`find_similar_vector`] but restricts results to nodes visible in
6964    /// `mask`. Hidden nodes never appear in results; the mask is applied
6965    /// **before** k-truncation so a caller still receives up to `k` visible
6966    /// hits.
6967    ///
6968    /// # HNSW path (over-fetch policy)
6969    ///
6970    /// When an HNSW index covers the request, this function fetches `4 * k`
6971    /// candidates from the index and discards hidden nodes in the post-filter
6972    /// step.  If fewer than `k` visible nodes remain after filtering the caller
6973    /// receives whatever is available — we do not re-query the index.  The 4×
6974    /// multiplier is a heuristic suited for sparsely masked graphs; callers
6975    /// operating under a very selective mask should register a VectorSimilar
6976    /// rule with a non-approximate index, or use the brute-force path (no HNSW
6977    /// rule) which exhaustively filters through the masked [`GraphView`].
6978    ///
6979    /// # Brute-force path
6980    ///
6981    /// When no HNSW index covers the request the function builds a masked
6982    /// [`GraphView`] so that `nodes_all` / `nodes_with_label` return only
6983    /// visible nodes, guaranteeing exact `k` results (or all visible nodes if
6984    /// fewer than `k` exist).
6985    pub fn find_similar_vector_masked(
6986        &self,
6987        field: &str,
6988        label: Option<&str>,
6989        q: &[f64],
6990        k: usize,
6991        min: f64,
6992        mask: &crate::mask::NodeMask,
6993    ) -> Vec<(String, f64)> {
6994        self.engine.ensure_hnsw_loaded();
6995        let norm: f64 = q.iter().map(|x| x * x).sum::<f64>().sqrt();
6996        if norm == 0.0 {
6997            return vec![];
6998        }
6999        let q_unit: Vec<f64> = q.iter().map(|x| x / norm).collect();
7000
7001        // HNSW fast path — over-fetch 4×k so post-masking still yields up to k
7002        // visible hits.  See doc comment above for the policy rationale.
7003        let over_k = k.saturating_mul(4).max(k + 1);
7004        let hnsw_hits = match label {
7005            Some(lbl) => self.engine.hnsw_search_dst(field, lbl, &q_unit, over_k),
7006            None => self.engine.hnsw_search_any_dst(field, &q_unit, over_k),
7007        };
7008        if let Some(hits) = hnsw_hits {
7009            let mut out: Vec<(String, f64)> = hits
7010                .into_iter()
7011                .filter(|&(id, sim)| sim >= min && mask.visible.contains(&id))
7012                .filter_map(|(id, sim)| self.ids.key_of(id).map(|key| (key.to_string(), sim)))
7013                .collect();
7014            out.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
7015            out.truncate(k);
7016            return out;
7017        }
7018
7019        // Brute-force fallback — masked view ensures only visible nodes are
7020        // enumerated by nodes_all(); nodes_with_label() does not filter by
7021        // mask so we apply view.visible() explicitly for the labeled case.
7022        let view = self.view_masked(mask);
7023        let candidate_ids: Vec<u32> = match label {
7024            Some(lbl) => view
7025                .nodes_with_label(lbl)
7026                .into_iter()
7027                .filter(|&id| view.visible(id))
7028                .collect(),
7029            None => view.nodes_all(),
7030        };
7031        let mut scored: Vec<(String, f64)> = candidate_ids
7032            .into_iter()
7033            .filter_map(|id| {
7034                let v = view.prop(id, field)?;
7035                let v_owned = v.into_value();
7036                let xs = value_as_float_list(&v_owned)?;
7037                let v_norm: f64 = xs.iter().map(|x| x * x).sum::<f64>().sqrt();
7038                if v_norm == 0.0 {
7039                    return None;
7040                }
7041                let dot: f64 = q_unit
7042                    .iter()
7043                    .zip(xs.iter())
7044                    .map(|(a, b)| a * (b / v_norm))
7045                    .sum();
7046                if dot < min {
7047                    return None;
7048                }
7049                let key = self.ids.key_of(id)?.to_string();
7050                Some((key, dot))
7051            })
7052            .collect();
7053        scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
7054        scored.truncate(k);
7055        scored
7056    }
7057
7058    /// Read a single property from an edge.
7059    ///
7060    /// Returns `None` when the edge does not exist, the field is absent, or any
7061    /// of the string keys cannot be resolved to interned ids.  Only edge props
7062    /// written by rules (weight fields) are accessible without a `set_edge_prop`
7063    /// binding; topology-only edges (no props set) return `None` for every field.
7064    pub fn get_edge_prop(
7065        &self,
7066        edge_type: &str,
7067        src_key: &str,
7068        dst_key: &str,
7069        field: &str,
7070    ) -> Option<Value> {
7071        let etype = self.syms.get(edge_type)?;
7072        let src = self.ids.get(src_key)?;
7073        let dst = self.ids.get(dst_key)?;
7074        self.edge_props_view().get(etype, src, dst, field)
7075    }
7076
7077    /// Lex → parse → plan → execute `cypher` over a read-only view.
7078    /// Every pipeline `Err(String)` becomes `GraphError::QueryError` with a
7079    /// stage prefix (`lex:` / `parse:` / `plan:` / `execute:`).
7080    pub fn query(&self, cypher: &str, params: &BTreeMap<String, Value>) -> Result<ResultSet> {
7081        let tokens = lex(cypher).map_err(|e| GraphError::QueryError {
7082            detail: format!("lex: {e}"),
7083        })?;
7084        let union = parse_read(&tokens).map_err(|e| GraphError::QueryError {
7085            detail: format!("parse: {e}"),
7086        })?;
7087        let t0 = std::time::Instant::now();
7088        let result = execute_union(&self.view(), &union, &Params(params)).map_err(|e| {
7089            GraphError::QueryError {
7090                detail: format!("execute: {e}"),
7091            }
7092        });
7093        let elapsed_ms = t0.elapsed().as_millis() as u64;
7094        let threshold = self.slow_query_threshold_ms;
7095        if threshold > 0 && elapsed_ms >= threshold {
7096            eprintln!("[mushroomdb] slow query ({elapsed_ms}ms): {cypher}");
7097            let entry = SlowQueryEntry {
7098                ms: elapsed_ms,
7099                query: cypher.to_string(),
7100                at_commit: self.commit_seq,
7101            };
7102            if let Ok(mut log) = self.slow_queries.lock() {
7103                if log.entries.len() == SLOW_QUERY_RING_CAP {
7104                    log.entries.pop_front();
7105                }
7106                log.entries.push_back(entry);
7107                log.total += 1;
7108            }
7109        }
7110        result
7111    }
7112
7113    /// Convenience entry-point that accepts a slice of `(name, value)` pairs
7114    /// instead of a pre-built `BTreeMap`.  Equivalent to building the map and
7115    /// calling [`GraphDb::query`].
7116    pub fn query_with_params(&self, cypher: &str, params: &[(&str, Value)]) -> Result<ResultSet> {
7117        let map: BTreeMap<String, Value> = params
7118            .iter()
7119            .map(|(k, v)| (k.to_string(), v.clone()))
7120            .collect();
7121        self.query(cypher, &map)
7122    }
7123
7124    /// Execute a Cypher write statement (CREATE / MATCH…SET / MATCH…DELETE / MERGE).
7125    ///
7126    /// All mutations flow through the same `insert_node` / `set_prop` /
7127    /// `delete_edge` / `insert_edge` path as the Rust API so the rule engine
7128    /// fires and the WAL captures everything with one fsync per statement.
7129    ///
7130    /// Returns a one-row [`ResultSet`] with columns `created`, `properties_set`,
7131    /// and `deleted` matching the write-result contract.
7132    ///
7133    /// **Mutation routing**: mutations are collected into a single
7134    /// [`BatchBuilder`] and committed atomically (one WAL `Batch` frame, one
7135    /// fsync). The MATCH phase for SET/DELETE uses a read-only `execute` call
7136    /// over `self.view()` — the borrow is dropped before the batch is opened.
7137    ///
7138    /// **Limitations (v1)**:
7139    /// - SET RHS must be a literal, `$param`, or arithmetic; bare property copy → named error.
7140    /// - `DETACH DELETE n` → calls `delete_node` for each matched node (removes all edges).
7141    /// - Bare `DELETE n` → error if n has any incident edges; succeeds for isolated nodes.
7142    /// - MERGE supports `ON CREATE SET` / `ON MATCH SET` in the same write batch.
7143    /// - Deleting a derived edge → named error "cannot delete derived edge".
7144    pub fn query_write(
7145        &mut self,
7146        cypher: &str,
7147        params: &BTreeMap<String, Value>,
7148    ) -> Result<ResultSet> {
7149        let tokens = lex(cypher).map_err(|e| GraphError::QueryError {
7150            detail: format!("lex: {e}"),
7151        })?;
7152        let stmt = parse_write(&tokens).map_err(|e| GraphError::QueryError {
7153            detail: format!("parse: {e}"),
7154        })?;
7155        self.exec_write_stmt(stmt, params)
7156    }
7157
7158    fn exec_write_stmt(
7159        &mut self,
7160        stmt: WriteStatement,
7161        params: &BTreeMap<String, Value>,
7162    ) -> Result<ResultSet> {
7163        match stmt {
7164            WriteStatement::Create(s) => self.exec_create(s, params),
7165            WriteStatement::MatchSet(s) => self.exec_match_set(s, params),
7166            WriteStatement::MatchDelete(s) => self.exec_match_delete(s, params),
7167            WriteStatement::MatchDeleteNode(s) => self.exec_match_delete_node(s, params),
7168            WriteStatement::Merge(s) => self.exec_merge(s, params),
7169        }
7170    }
7171
7172    fn exec_create(
7173        &mut self,
7174        stmt: core_query::cypher::CreateStmt,
7175        params: &BTreeMap<String, Value>,
7176    ) -> Result<ResultSet> {
7177        // Extract the node key from props: require a string-valued `id` field.
7178        let mut var_to_key: BTreeMap<String, String> = BTreeMap::new();
7179        for node in &stmt.nodes {
7180            let var = node.var.as_deref().unwrap_or("_cn0");
7181            let key = node
7182                .props
7183                .iter()
7184                .find(|(f, _)| f == "id")
7185                .and_then(|(_, v)| {
7186                    if let Value::Str(s) = v {
7187                        Some(s.clone())
7188                    } else {
7189                        None
7190                    }
7191                })
7192                .ok_or_else(|| GraphError::QueryError {
7193                    detail: format!(
7194                        "CREATE node ({}:{}) requires a string 'id' property",
7195                        var, node.label
7196                    ),
7197                })?;
7198            var_to_key.insert(var.to_string(), key);
7199        }
7200
7201        let mut batch = self.batch();
7202        let mut created: usize = 0;
7203        for node in &stmt.nodes {
7204            let var = node.var.as_deref().unwrap_or("_cn0");
7205            let key = &var_to_key[var];
7206            batch.insert_node(&node.label, key, node.props.clone());
7207            created += 1;
7208        }
7209        for edge in &stmt.edges {
7210            let src_key = var_to_key
7211                .get(&edge.src_var)
7212                .ok_or_else(|| GraphError::QueryError {
7213                    detail: format!("CREATE edge src variable '{}' is not bound", edge.src_var),
7214                })?;
7215            let dst_key = var_to_key
7216                .get(&edge.dst_var)
7217                .ok_or_else(|| GraphError::QueryError {
7218                    detail: format!("CREATE edge dst variable '{}' is not bound", edge.dst_var),
7219                })?;
7220            batch.insert_edge(&edge.etype, src_key, dst_key);
7221        }
7222        batch.commit()?;
7223
7224        // Optional RETURN clause: project created bindings as a read result.
7225        if let Some(returns) = stmt.returns {
7226            // Each created node is looked up by its key via a separate MATCH pattern.
7227            // Multiple single-node patterns cross-join to produce 1 output row with
7228            // all variables bound (each pattern returns exactly 1 row).
7229            let patterns: Vec<Pattern> = stmt
7230                .nodes
7231                .iter()
7232                .map(|node| {
7233                    let var = node.var.as_deref().unwrap_or("_cn0");
7234                    let key = var_to_key[var].clone();
7235                    Pattern {
7236                        start: NodePat {
7237                            var: Some(var.to_string()),
7238                            label: Some(node.label.clone()),
7239                            props: vec![("id".to_string(), Operand::Lit(Value::Str(key)))],
7240                        },
7241                        chain: vec![],
7242                        shortest: false,
7243                    }
7244                })
7245                .collect();
7246            let q = Query {
7247                matches: patterns,
7248                optional_clauses: vec![],
7249                where_expr: None,
7250                unwinds: vec![],
7251                post_unwind_where: None,
7252                stages: vec![],
7253                returns,
7254                distinct: false,
7255                order_by: vec![],
7256                skip: None,
7257                limit: None,
7258            };
7259            let ops = plan(&q).map_err(|e| GraphError::QueryError {
7260                detail: format!("plan: {e}"),
7261            })?;
7262            return execute(&self.view(), &ops, &Params(params)).map_err(|e| {
7263                GraphError::QueryError {
7264                    detail: format!("execute: {e}"),
7265                }
7266            });
7267        }
7268
7269        let mut rs = write_result_set();
7270        rs.push_row(vec![
7271            Some(Value::Int(created as i64)),
7272            Some(Value::Int(0)),
7273            Some(Value::Int(0)),
7274        ]);
7275        Ok(rs)
7276    }
7277
7278    fn exec_match_set(
7279        &mut self,
7280        stmt: core_query::cypher::MatchSetStmt,
7281        params: &BTreeMap<String, Value>,
7282    ) -> Result<ResultSet> {
7283        let project_returns = stmt.returns.clone();
7284        // Collect unique node vars targeted by SET clauses, plus RETURN bindings
7285        // so the post-write projection can look them up by key.
7286        let mut set_vars: Vec<String> = Vec::new();
7287        for s in &stmt.sets {
7288            if !set_vars.contains(&s.var) {
7289                set_vars.push(s.var.clone());
7290            }
7291        }
7292        let rel_vars = pattern_rel_vars(&stmt.matches);
7293        let mut lookup_vars = set_vars.clone();
7294        for v in pattern_node_vars(&stmt.matches) {
7295            add_var(&mut lookup_vars, &v);
7296        }
7297        if let Some(ref returns) = project_returns {
7298            for v in ret_node_vars(returns) {
7299                if !rel_vars.iter().any(|r| r == &v) {
7300                    add_var(&mut lookup_vars, &v);
7301                }
7302            }
7303        }
7304
7305        // Synthesize a read query: MATCH … WHERE … RETURN <lookup_vars>, <set_values…>
7306        // SET values are projected as ScalarExpr items so that arithmetic expressions
7307        // (e.g. `SET n.score = n.score * 1.5`) are evaluated in the matched-row context.
7308        let mut set_returns: Vec<RetItem> = lookup_vars
7309            .iter()
7310            .map(|v| RetItem {
7311                value: RetVal::Var(v.clone()),
7312                alias: None,
7313            })
7314            .collect();
7315        // One computed column per SET clause; alias is `__sv_<i>`.
7316        let set_val_cols: Vec<String> = stmt
7317            .sets
7318            .iter()
7319            .enumerate()
7320            .map(|(i, _)| format!("__sv_{i}"))
7321            .collect();
7322        for (sc, col) in stmt.sets.iter().zip(&set_val_cols) {
7323            set_returns.push(RetItem {
7324                value: RetVal::ScalarExpr(sc.value.clone()),
7325                alias: Some(col.clone()),
7326            });
7327        }
7328        // Capture relationship types while r is bound; SET does not change them.
7329        for r in &rel_vars {
7330            set_returns.push(RetItem {
7331                value: RetVal::FuncCall {
7332                    name: "type".into(),
7333                    args: vec![Operand::Var(r.clone())],
7334                },
7335                alias: Some(rel_type_alias(r)),
7336            });
7337        }
7338
7339        let read_q = Query {
7340            matches: stmt.matches.clone(),
7341            optional_clauses: vec![],
7342            where_expr: stmt.where_expr.clone(),
7343            unwinds: vec![],
7344            post_unwind_where: None,
7345            stages: vec![],
7346            returns: set_returns,
7347            distinct: false,
7348            order_by: vec![],
7349            skip: None,
7350            limit: None,
7351        };
7352        let ops = plan(&read_q).map_err(|e| GraphError::QueryError {
7353            detail: format!("plan: {e}"),
7354        })?;
7355        // MATCH phase is read-only; borrow ends before batch opens.
7356        //
7357        // When a role-scoped write is in flight, run the MATCH read through
7358        // view_masked so hidden nodes are invisible → hidden ≡ absent ≡
7359        // zero-rows (no SetProp ops generated, no existence-oracle 403).
7360        // Full-authority writes (pending_write_authz=None) keep view().
7361        let match_rs = {
7362            let mask_opt = self.pending_write_authz.as_ref().map(|a| a.mask.clone());
7363            if let Some(ref mask) = mask_opt {
7364                execute(&self.view_masked(mask), &ops, &Params(params))
7365            } else {
7366                execute(&self.view(), &ops, &Params(params))
7367            }
7368        }
7369        .map_err(|e| GraphError::QueryError {
7370            detail: format!("execute: {e}"),
7371        })?;
7372
7373        // Collect (key, field, value) for each matched row × each SET clause.
7374        let mut set_ops: Vec<(String, String, Value)> = Vec::new();
7375        for row_i in 0..match_rs.len() {
7376            for (sc, col) in stmt.sets.iter().zip(&set_val_cols) {
7377                let key = match match_rs.get(row_i, &sc.var) {
7378                    Some(Value::Str(k)) => k.clone(),
7379                    _ => {
7380                        return Err(GraphError::QueryError {
7381                            detail: format!(
7382                                "SET variable '{}' did not resolve to a node key",
7383                                sc.var
7384                            ),
7385                        })
7386                    }
7387                };
7388                // The SET value was already evaluated by the executor.
7389                let value = match match_rs.get(row_i, col) {
7390                    Some(v) => v.clone(),
7391                    None => {
7392                        return Err(GraphError::QueryError {
7393                            detail: format!(
7394                                "SET value for {}.{} evaluated to null",
7395                                sc.var, sc.field
7396                            ),
7397                        })
7398                    }
7399                };
7400                set_ops.push((key, sc.field.clone(), value));
7401            }
7402        }
7403
7404        // Apply as one atomic batch.
7405        let props_set = set_ops.len();
7406        let mut batch = self.batch();
7407        for (key, field, value) in set_ops {
7408            batch.set_prop(&key, &field, value);
7409        }
7410        batch.commit()?;
7411
7412        if let Some(returns) = project_returns {
7413            return project_set_return_rows(self, &rel_vars, &match_rs, &returns, params);
7414        }
7415
7416        let mut rs = write_result_set();
7417        rs.push_row(vec![
7418            Some(Value::Int(0)),
7419            Some(Value::Int(props_set as i64)),
7420            Some(Value::Int(0)),
7421        ]);
7422        Ok(rs)
7423    }
7424
7425    fn exec_match_delete(
7426        &mut self,
7427        stmt: core_query::cypher::MatchDeleteStmt,
7428        params: &BTreeMap<String, Value>,
7429    ) -> Result<ResultSet> {
7430        // Collect unique node vars needed to identify edge endpoints.
7431        let mut node_vars: Vec<String> = Vec::new();
7432        for ed in &stmt.deletes {
7433            if !node_vars.contains(&ed.src_var) {
7434                node_vars.push(ed.src_var.clone());
7435            }
7436            if !node_vars.contains(&ed.dst_var) {
7437                node_vars.push(ed.dst_var.clone());
7438            }
7439        }
7440
7441        // Synthesize read query.
7442        let returns: Vec<RetItem> = node_vars
7443            .iter()
7444            .map(|v| RetItem {
7445                value: RetVal::Var(v.clone()),
7446                alias: None,
7447            })
7448            .collect();
7449        let read_q = Query {
7450            matches: stmt.matches,
7451            optional_clauses: vec![],
7452            where_expr: stmt.where_expr,
7453            unwinds: vec![],
7454            post_unwind_where: None,
7455            stages: vec![],
7456            returns,
7457            distinct: false,
7458            order_by: vec![],
7459            skip: None,
7460            limit: None,
7461        };
7462        let ops = plan(&read_q).map_err(|e| GraphError::QueryError {
7463            detail: format!("plan: {e}"),
7464        })?;
7465        // Role-scoped writes: mask the MATCH read phase so hidden nodes are
7466        // invisible → hidden ≡ absent ≡ zero-rows (spec §3.1, hidden ≡ absent).
7467        let match_rs = {
7468            let mask_opt = self.pending_write_authz.as_ref().map(|a| a.mask.clone());
7469            if let Some(ref mask) = mask_opt {
7470                execute(&self.view_masked(mask), &ops, &Params(params))
7471            } else {
7472                execute(&self.view(), &ops, &Params(params))
7473            }
7474        }
7475        .map_err(|e| GraphError::QueryError {
7476            detail: format!("execute: {e}"),
7477        })?;
7478
7479        // Collect (etype, src_key, dst_key) for each row × each delete target.
7480        let mut del_ops: Vec<(String, String, String)> = Vec::new();
7481        for row_i in 0..match_rs.len() {
7482            for ed in &stmt.deletes {
7483                let src_key = match match_rs.get(row_i, &ed.src_var) {
7484                    Some(Value::Str(k)) => k.clone(),
7485                    _ => {
7486                        return Err(GraphError::QueryError {
7487                            detail: format!(
7488                                "DELETE src variable '{}' did not resolve to a node key",
7489                                ed.src_var
7490                            ),
7491                        })
7492                    }
7493                };
7494                let dst_key = match match_rs.get(row_i, &ed.dst_var) {
7495                    Some(Value::Str(k)) => k.clone(),
7496                    _ => {
7497                        return Err(GraphError::QueryError {
7498                            detail: format!(
7499                                "DELETE dst variable '{}' did not resolve to a node key",
7500                                ed.dst_var
7501                            ),
7502                        })
7503                    }
7504                };
7505                del_ops.push((ed.etype.clone(), src_key, dst_key));
7506            }
7507        }
7508
7509        // Apply as one atomic batch.
7510        let deleted = del_ops.len();
7511        let mut batch = self.batch();
7512        for (etype, src_key, dst_key) in del_ops {
7513            batch.delete_edge(&etype, &src_key, &dst_key);
7514        }
7515        batch.commit().map_err(|e| match e {
7516            GraphError::RuleOwned { .. } => GraphError::QueryError {
7517                detail: "cannot delete derived edge; retract via the rule or change the property"
7518                    .to_string(),
7519            },
7520            other => other,
7521        })?;
7522
7523        let mut rs = write_result_set();
7524        rs.push_row(vec![
7525            Some(Value::Int(0)),
7526            Some(Value::Int(0)),
7527            Some(Value::Int(deleted as i64)),
7528        ]);
7529        Ok(rs)
7530    }
7531
7532    /// Execute `MATCH … [DETACH] DELETE <node_var> [, …]`.
7533    ///
7534    /// Collects the matching node keys via an ephemeral read query, then calls
7535    /// `delete_node` on each one.  When `stmt.detach` is `false` (bare DELETE)
7536    /// the executor first checks that the node has no incident edges; if any
7537    /// remain it returns a named error matching openCypher semantics.
7538    fn exec_match_delete_node(
7539        &mut self,
7540        stmt: MatchDeleteNodeStmt,
7541        params: &BTreeMap<String, Value>,
7542    ) -> Result<ResultSet> {
7543        // Build a read query returning only the node keys we need.
7544        let returns: Vec<RetItem> = stmt
7545            .node_vars
7546            .iter()
7547            .map(|v| RetItem {
7548                value: RetVal::Var(v.clone()),
7549                alias: None,
7550            })
7551            .collect();
7552        let read_q = Query {
7553            matches: stmt.matches,
7554            optional_clauses: vec![],
7555            where_expr: stmt.where_expr,
7556            unwinds: vec![],
7557            post_unwind_where: None,
7558            stages: vec![],
7559            returns,
7560            distinct: false,
7561            order_by: vec![],
7562            skip: None,
7563            limit: None,
7564        };
7565        let ops = plan(&read_q).map_err(|e| GraphError::QueryError {
7566            detail: format!("plan: {e}"),
7567        })?;
7568        // Role-scoped writes: mask the MATCH read phase so hidden nodes are
7569        // invisible → hidden ≡ absent ≡ zero-rows (spec §3.1, hidden ≡ absent).
7570        let match_rs = {
7571            let mask_opt = self.pending_write_authz.as_ref().map(|a| a.mask.clone());
7572            if let Some(ref mask) = mask_opt {
7573                execute(&self.view_masked(mask), &ops, &Params(params))
7574            } else {
7575                execute(&self.view(), &ops, &Params(params))
7576            }
7577        }
7578        .map_err(|e| GraphError::QueryError {
7579            detail: format!("execute: {e}"),
7580        })?;
7581
7582        // Collect unique node keys to delete (deduplicate across rows × vars).
7583        let mut keys: Vec<String> = Vec::new();
7584        for row_i in 0..match_rs.len() {
7585            for var in &stmt.node_vars {
7586                if let Some(Value::Str(k)) = match_rs.get(row_i, var) {
7587                    if !keys.contains(k) {
7588                        keys.push(k.clone());
7589                    }
7590                }
7591            }
7592        }
7593
7594        if !stmt.detach {
7595            // openCypher bare DELETE: error if any matched node has incident edges.
7596            for key in &keys {
7597                if let Some(id) = self.ids.get(key) {
7598                    let tv = self.topo_view();
7599                    let has_edges = tv.etypes().any(|et| {
7600                        !tv.neighbors(et, Direction::Out, id).is_empty()
7601                            || !tv.neighbors(et, Direction::In, id).is_empty()
7602                    });
7603                    if has_edges {
7604                        return Err(GraphError::QueryError {
7605                            detail: format!(
7606                                "Cannot delete node `{key}` because it still has incident edges. \
7607                                 Use DETACH DELETE to remove the node and all its edges."
7608                            ),
7609                        });
7610                    }
7611                }
7612            }
7613        }
7614
7615        let mut nodes_deleted = 0i64;
7616        let mut edges_deleted = 0i64;
7617        for key in keys {
7618            match self.delete_node(&key) {
7619                Ok(report) => {
7620                    nodes_deleted += 1;
7621                    edges_deleted += (report.manual_edges + report.derived_edges) as i64;
7622                }
7623                Err(GraphError::KeyNotFound { .. }) => {
7624                    // Node may have been deleted by an earlier iteration (e.g., via
7625                    // multiple MATCH rows for the same node).  Safe to skip.
7626                }
7627                Err(e) => return Err(e),
7628            }
7629        }
7630
7631        let mut rs = write_result_set();
7632        rs.push_row(vec![
7633            Some(Value::Int(0)),
7634            Some(Value::Int(0)),
7635            Some(Value::Int(nodes_deleted + edges_deleted)),
7636        ]);
7637        Ok(rs)
7638    }
7639
7640    fn exec_merge(
7641        &mut self,
7642        stmt: core_query::cypher::MergeStmt,
7643        params: &BTreeMap<String, Value>,
7644    ) -> Result<ResultSet> {
7645        // MERGE: check if a node with the given key already exists.
7646        let key = match &stmt.key_value {
7647            Value::Str(s) => s.clone(),
7648            _ => {
7649                return Err(GraphError::QueryError {
7650                    detail: format!(
7651                        "MERGE key value must be a string (got {:?})",
7652                        stmt.key_value
7653                    ),
7654                })
7655            }
7656        };
7657
7658        if let Some(var) = stmt.var.as_deref() {
7659            for sc in stmt.on_create.iter().chain(&stmt.on_match) {
7660                if sc.var != var {
7661                    return Err(GraphError::QueryError {
7662                        detail: format!(
7663                            "SET variable '{}' does not match MERGE variable '{var}'",
7664                            sc.var
7665                        ),
7666                    });
7667                }
7668            }
7669        }
7670
7671        // ── MERGE authz pre-check (when role-scoped) ─────────────────────────
7672        //
7673        // MERGE scope precondition: check create OR update scope for the
7674        // declared label BEFORE calling `has_node` (timing-oracle closure,
7675        // spec §6.2 "MERGE visibility oracle" item: hidden ≡ absent for
7676        // unscoped roles — the scope denial fires without touching the key store).
7677        //
7678        // Clone to avoid holding a borrow on `self.pending_write_authz` while
7679        // also calling `self.ids.get(key)`.
7680        let merge_existed: bool = if let Some(authz) = self.pending_write_authz.clone() {
7681            let has_create = authz.scope.create_labels.contains(&stmt.label);
7682            let has_update = authz.scope.update_labels.contains(&stmt.label);
7683            if !has_create && !has_update {
7684                // Scope-before-lookup: 403 without has_node call (timing oracle
7685                // closure — see test_merge_unscoped_no_key_lookup).
7686                return Err(GraphError::RoleWriteDenied {
7687                    reason: format!(
7688                        "role-bound token: label '{}' not in write scope (create_labels)",
7689                        stmt.label
7690                    ),
7691                });
7692            }
7693            // Key lookup under mask.
7694            match self.ids.get(key.as_str()) {
7695                Some(id) if authz.mask.contains_id(id) => {
7696                    // Visible: must have update scope to proceed to match arm.
7697                    if !has_update {
7698                        return Err(GraphError::RoleWriteDenied {
7699                            reason: format!(
7700                                "role-bound token: label '{}' not in write scope (update_labels)",
7701                                stmt.label
7702                            ),
7703                        });
7704                    }
7705                    true // existed = true → match arm
7706                }
7707                Some(_) => {
7708                    // Hidden: same error as absent to the role (spec §3.1/§3.3).
7709                    return Err(GraphError::RoleWriteDenied {
7710                        reason: "role-bound token: target node not visible".into(),
7711                    });
7712                }
7713                None => {
7714                    // Absent: must have create scope to proceed to the create arm.
7715                    //
7716                    // Update-only roles (create_labels empty, update_labels set):
7717                    // return the SAME "not visible" error as the hidden-key branch
7718                    // so hidden ≡ absent — no distinguishing oracle (spec §6.1
7719                    // "confirm existence of hidden nodes: No").
7720                    //
7721                    // Create-scoped roles (has_create=true): absent → create arm
7722                    // as before.  The accepted structural key-existence disclosure
7723                    // (§THREAT-MODEL) applies only when the role holds create scope.
7724                    if !has_create {
7725                        return Err(GraphError::RoleWriteDenied {
7726                            reason: "role-bound token: target node not visible".into(),
7727                        });
7728                    }
7729                    false // existed = false → create arm
7730                }
7731            }
7732        } else {
7733            // Full authority: use the existing non-masked has_node check.
7734            self.has_node(&key)
7735        };
7736
7737        let existed = merge_existed;
7738        let mut created = 0i64;
7739        if !existed || !stmt.on_match.is_empty() {
7740            let mut batch = self.batch();
7741            if !existed {
7742                let props = vec![(stmt.key_field.clone(), stmt.key_value.clone())];
7743                batch.insert_node(&stmt.label, &key, props);
7744                for sc in &stmt.on_create {
7745                    let value = resolve_merge_set_value(&sc.value, params)?;
7746                    batch.set_prop(&key, &sc.field, value);
7747                }
7748                created = 1;
7749            } else {
7750                for sc in &stmt.on_match {
7751                    let value = resolve_merge_set_value(&sc.value, params)?;
7752                    batch.set_prop(&key, &sc.field, value);
7753                }
7754            }
7755            batch.commit()?;
7756        }
7757
7758        // Refresh the role mask so the just-created node is visible to this
7759        // statement's RETURN (read-after-write). Safe: create_labels ⊆ read labels
7760        // (apply_schema subset rule), so the new node's label is already in the
7761        // role's read scope — this never widens beyond the role's declared labels.
7762        if !existed {
7763            if let Some(role) = self.pending_write_authz.as_ref().map(|a| a.role.clone()) {
7764                let new_mask = self.mask_for_role(&role)?;
7765                if let Some(a) = self.pending_write_authz.as_mut() {
7766                    a.mask = new_mask;
7767                }
7768            }
7769        }
7770
7771        // Optional RETURN clause: project the node (created or matched) as a read result.
7772        if let Some(returns) = stmt.returns {
7773            let var = stmt.var.as_deref().unwrap_or("_mn0");
7774            let q = Query {
7775                matches: vec![Pattern {
7776                    start: NodePat {
7777                        var: Some(var.to_string()),
7778                        label: Some(stmt.label.clone()),
7779                        props: vec![("id".to_string(), Operand::Lit(stmt.key_value.clone()))],
7780                    },
7781                    chain: vec![],
7782                    shortest: false,
7783                }],
7784                optional_clauses: vec![],
7785                where_expr: None,
7786                unwinds: vec![],
7787                post_unwind_where: None,
7788                stages: vec![],
7789                returns,
7790                distinct: false,
7791                order_by: vec![],
7792                skip: None,
7793                limit: None,
7794            };
7795            let ops = plan(&q).map_err(|e| GraphError::QueryError {
7796                detail: format!("plan: {e}"),
7797            })?;
7798            // Use view_masked when a role-scoped write is in flight so the
7799            // post-merge projection is consistent with the masked read phase.
7800            let mask_opt = self.pending_write_authz.as_ref().map(|a| a.mask.clone());
7801            return (if let Some(ref mask) = mask_opt {
7802                execute(&self.view_masked(mask), &ops, &Params(params))
7803            } else {
7804                execute(&self.view(), &ops, &Params(params))
7805            })
7806            .map_err(|e| GraphError::QueryError {
7807                detail: format!("execute: {e}"),
7808            });
7809        }
7810
7811        let mut rs = write_result_set();
7812        rs.push_row(vec![
7813            Some(Value::Int(created)),
7814            Some(Value::Int(0)),
7815            Some(Value::Int(0)),
7816        ]);
7817        Ok(rs)
7818    }
7819
7820    /// Return all rule-owned edges between `key_a` and `key_b` (either direction),
7821    /// annotated with rule name, edge type, direction, and weight.
7822    /// Results are sorted by (rule, edge_type).
7823    /// Returns `Err(KeyNotFound)` if either key is unknown.
7824    pub fn explain(&self, key_a: &str, key_b: &str) -> Result<Vec<Explanation>> {
7825        self.ensure_v8_base_sections_loaded();
7826        let id_a = self
7827            .ids
7828            .get(key_a)
7829            .ok_or_else(|| GraphError::KeyNotFound { key: key_a.into() })?;
7830        let id_b = self
7831            .ids
7832            .get(key_b)
7833            .ok_or_else(|| GraphError::KeyNotFound { key: key_b.into() })?;
7834
7835        let mut results = Vec::new();
7836
7837        // Walk the smaller incident set so explain is O(min(deg(a), deg(b)))
7838        // rather than O(total provenance).
7839        let scan = if self.engine.provenance_touching_len(id_a)
7840            <= self.engine.provenance_touching_len(id_b)
7841        {
7842            id_a
7843        } else {
7844            id_b
7845        };
7846        for (rule_name, etype, src, dst) in self.engine.provenance_touching(scan) {
7847            if !((src == id_a && dst == id_b) || (src == id_b && dst == id_a)) {
7848                continue;
7849            }
7850            let Some(rule_def) = self.engine.rules().find(|r| r.name == rule_name) else {
7851                continue;
7852            };
7853            let edge_type = match self.syms.resolve(etype) {
7854                Some(s) => s.to_string(),
7855                None => continue,
7856            };
7857            // Provenance (src, dst) ids come from the archived PROVENANCE section
7858            // (large, no eager CRC).  A corrupt section can produce ids that are
7859            // out of range; return Corrupt rather than panic.
7860            let src_key = self
7861                .ids
7862                .key_of(src)
7863                .ok_or_else(|| GraphError::Corrupt {
7864                    detail: format!("v8: provenance src id {src} not in id table"),
7865                })?
7866                .to_string();
7867            let dst_key = self
7868                .ids
7869                .key_of(dst)
7870                .ok_or_else(|| GraphError::Corrupt {
7871                    detail: format!("v8: provenance dst id {dst} not in id table"),
7872                })?
7873                .to_string();
7874            let stored = rule_def.weight_prop.as_deref().and_then(|prop| {
7875                self.edge_props_view()
7876                    .get(etype, src, dst, prop)
7877                    .and_then(|v| {
7878                        if let Value::Float(f) = v {
7879                            Some(f)
7880                        } else {
7881                            None
7882                        }
7883                    })
7884            });
7885            // Rules that store no weight (KeyMatch/FieldEqual defaults, auto-FK)
7886            // still have a score: recompute it from the predicate so explain
7887            // never reports "no score" for an edge the engine scored.  Via-hop
7888            // rules score over their via set, not over (src, dst), so leave
7889            // those None rather than report a number the rule did not produce.
7890            let weight = stored.or_else(|| {
7891                if rule_def.via_edge.is_some() {
7892                    return None;
7893                }
7894                let props_view = build_props_view(&self.props, &self.base);
7895                let src_get = |field: &str| props_view.get(src, field).map(|vr| vr.into_value());
7896                let dst_get = |field: &str| props_view.get(dst, field).map(|vr| vr.into_value());
7897                let src_view = NodeView {
7898                    key: &src_key,
7899                    props: &src_get,
7900                };
7901                let dst_view = NodeView {
7902                    key: &dst_key,
7903                    props: &dst_get,
7904                };
7905                evaluate(&rule_def.predicate, &src_view, &dst_view)
7906            });
7907            results.push(Explanation {
7908                rule: rule_name.to_string(),
7909                edge_type,
7910                src_key,
7911                dst_key,
7912                weight,
7913                predicate: PredicateSummary {
7914                    approximate: rule_def.approximate,
7915                    ..PredicateSummary::from(&rule_def.predicate)
7916                },
7917                via_edge: rule_def.via_edge.clone(),
7918            });
7919        }
7920
7921        results.sort_by(|a, b| a.rule.cmp(&b.rule).then(a.edge_type.cmp(&b.edge_type)));
7922        Ok(results)
7923    }
7924
7925    pub fn neighbors(&self, key: &str, edge_type: &str, dir: Direction) -> Result<Vec<String>> {
7926        let id = self
7927            .ids
7928            .get(key)
7929            .ok_or_else(|| GraphError::KeyNotFound { key: key.into() })?;
7930        let Some(sym) = self.syms.get(edge_type) else {
7931            return Ok(Vec::new());
7932        };
7933        self.topo_view()
7934            .neighbors(sym, dir, id)
7935            .iter()
7936            .map(|&n| {
7937                self.ids
7938                    .key_of(n)
7939                    .map(|k| k.to_string())
7940                    .ok_or_else(|| GraphError::Corrupt {
7941                        detail: format!("topology id {n} has no key"),
7942                    })
7943            })
7944            .collect::<Result<Vec<_>>>()
7945    }
7946
7947    /// Return the last-change commit sequence for `key`, or `None` if the node
7948    /// does not exist or has never been mutated since the last V5-V7 snapshot
7949    /// (horizon-bounded for legacy stores).
7950    ///
7951    /// The returned sequence is a monotonically increasing counter that starts
7952    /// at 1 for the first commit after `open` and increments with every
7953    /// successful write.  WAL replay at open also assigns sequences (1..N for N
7954    /// replayed frames), so sequences are consistent across snapshot+WAL cycles.
7955    ///
7956    /// For V5-V7 stores opened without a V8 snapshot, nodes that were present
7957    /// in the snapshot but not touched by any WAL frame will return `None`
7958    /// (horizon-bounded: CAS against such nodes is only safe after the first
7959    /// V8 snapshot or after the node is next mutated).
7960    pub fn last_changed(&self, key: &str) -> Option<u64> {
7961        let id = self.ids.get(key)?;
7962        self.last_change.get(&id).copied()
7963    }
7964
7965    /// The current commit sequence (number of successful commits since open,
7966    /// including WAL replay frames).  Useful for recording a baseline before
7967    /// a read-modify-write cycle.
7968    pub fn commit_seq(&self) -> u64 {
7969        self.commit_seq
7970    }
7971
7972    /// Check that all `preconds` are satisfied against the current db state.
7973    /// Returns `Err(GraphError::CasConflict)` on the first failing precondition.
7974    pub(crate) fn check_preconditions(&self, preconds: &[Precondition]) -> Result<()> {
7975        for precond in preconds {
7976            match precond {
7977                Precondition::NodeUnchangedSince { key, expected } => {
7978                    // Missing entry means the node predates the WAL window or
7979                    // does not exist; treat as 0 (before any commit).
7980                    let actual = self.last_changed(key).unwrap_or_default();
7981                    if actual != *expected {
7982                        return Err(GraphError::CasConflict {
7983                            key: key.clone(),
7984                            expected: *expected,
7985                            actual,
7986                        });
7987                    }
7988                }
7989                Precondition::NodeAbsent { key } => {
7990                    // Node must not exist (not live).
7991                    if self.ids.get(key).is_some() {
7992                        let actual = self.last_changed(key).unwrap_or(0);
7993                        return Err(GraphError::CasConflict {
7994                            key: key.clone(),
7995                            expected: u64::MAX,
7996                            actual,
7997                        });
7998                    }
7999                }
8000            }
8001        }
8002        Ok(())
8003    }
8004
8005    /// Apply a batch of mutations with compare-and-set preconditions.
8006    ///
8007    /// All preconditions are checked atomically before any operation is applied.
8008    /// If any precondition fails, the entire batch is rejected with
8009    /// [`GraphError::CasConflict`] and no WAL frame is written.
8010    ///
8011    /// # Returns
8012    /// `(nodes_inserted, edges_inserted)` on success, same as [`write_batch`].
8013    ///
8014    /// # Errors
8015    /// - [`GraphError::CasConflict`] if any precondition is not satisfied.
8016    /// - Any error that [`write_batch`] would return for the ops themselves.
8017    pub fn write_batch_cas(
8018        &mut self,
8019        preconds: Vec<Precondition>,
8020        ops: Vec<BatchOp>,
8021    ) -> Result<(usize, usize)> {
8022        self.check_preconditions(&preconds)?;
8023        self.commit_logged_batch(ops, None, None)
8024    }
8025
8026    /// Update the per-node last-change map for a WAL record at commit `seq`.
8027    ///
8028    /// Called after a successful apply to record which nodes were touched.
8029    /// For replay, called with the WAL-frame's replayed seq.
8030    ///
8031    /// Touch definition (see [`Precondition`] doc):
8032    /// - InsertNode / InsertNodeId / SetProp / SetPropId / RemoveProp → the node.
8033    /// - InsertEdge / InsertEdgeId / DeleteEdge → both src and dst.
8034    /// - DeleteNode → node tombstoned; last_changed() returns None so no update needed.
8035    /// - DerivedEdge markers, Intern, rule/view records → no-ops.
8036    /// - Batch → recurse into inner records.
8037    fn update_last_change_from_rec(&mut self, rec: &WalRecord, seq: u64) {
8038        match rec {
8039            WalRecord::InsertNode { key, .. }
8040            | WalRecord::SetProp { key, .. }
8041            | WalRecord::RemoveProp { key, .. } => {
8042                if let Some(id) = self.ids.get(key) {
8043                    self.last_change.insert(id, seq);
8044                }
8045            }
8046            WalRecord::InsertNodeId { key, .. } => {
8047                if let Some(id) = self.ids.get(key) {
8048                    self.last_change.insert(id, seq);
8049                }
8050            }
8051            WalRecord::SetPropId { id, .. } => {
8052                self.last_change.insert(*id, seq);
8053            }
8054            WalRecord::InsertEdge {
8055                src_key, dst_key, ..
8056            }
8057            | WalRecord::DeleteEdge {
8058                src_key, dst_key, ..
8059            } => {
8060                if let Some(src_id) = self.ids.get(src_key) {
8061                    self.last_change.insert(src_id, seq);
8062                }
8063                if let Some(dst_id) = self.ids.get(dst_key) {
8064                    self.last_change.insert(dst_id, seq);
8065                }
8066            }
8067            WalRecord::InsertEdgeId { src, dst, .. } => {
8068                self.last_change.insert(*src, seq);
8069                self.last_change.insert(*dst, seq);
8070            }
8071            // DeleteNode: node is tombstoned; last_changed(key) returns None for
8072            // deleted keys (ids.get() returns None post-tombstone), so no update needed.
8073            // History markers: state no-ops; the underlying mutation already
8074            // touched the relevant nodes' last_change entries.
8075            WalRecord::DeleteNode { .. }
8076            | WalRecord::DerivedEdgeAdded { .. }
8077            | WalRecord::DerivedEdgeRetracted { .. }
8078            | WalRecord::Intern { .. }
8079            | WalRecord::CreateRule { .. }
8080            | WalRecord::DeleteRule { .. }
8081            | WalRecord::RebuildRule { .. }
8082            | WalRecord::CreateView { .. }
8083            | WalRecord::DeleteView { .. }
8084            | WalRecord::EnableFulltext { .. }
8085            | WalRecord::DisableFulltext { .. }
8086            | WalRecord::EnableIndex { .. }
8087            | WalRecord::DisableIndex { .. } => {}
8088            // RenameNode: node id is stable; update last_change via the new key.
8089            // Called after apply(), so ids already reflects new_key.
8090            WalRecord::RenameNode { new_key, .. } => {
8091                if let Some(id) = self.ids.get(new_key) {
8092                    self.last_change.insert(id, seq);
8093                }
8094            }
8095            WalRecord::Batch(inner) => {
8096                for inner_rec in inner {
8097                    self.update_last_change_from_rec(inner_rec, seq);
8098                }
8099            }
8100        }
8101    }
8102
8103    pub fn node_count(&self) -> usize {
8104        self.ids.len()
8105    }
8106
8107    /// Configure archive retention: keep the `N` newest WAL archives at each
8108    /// [`snapshot_with`] call when `archive_wal: true`.
8109    ///
8110    /// `Some(N)` where N > 0 → prune oldest archives keeping the newest N.
8111    /// `Some(0)` or `None` → unlimited (no pruning).
8112    ///
8113    /// Pruning only ever happens inside [`snapshot_with`]; this method only
8114    /// stores the policy.  Archives below the retention limit are deleted
8115    /// oldest-first.  The horizon floor is updated so that
8116    /// [`was_linked`] / history APIs return `CommitOutOfRange` for commits
8117    /// in pruned archives rather than silently returning wrong data.
8118    pub fn set_wal_archive_retention(&mut self, keep: Option<u32>) {
8119        self.wal_archive_retention = keep;
8120    }
8121
8122    /// Delete any WAL archives that are fully below the current horizon floor.
8123    ///
8124    /// Orphaned archives arise when the floor is written first during retention
8125    /// pruning and then a crash interrupts the archive-delete sequence.  The
8126    /// opening cleanup ensures no subsequent read path sees stale data.
8127    ///
8128    /// Under the monotonic naming scheme, the archive name N equals the
8129    /// cumulative end-frame index of the archive in global commit space (i.e.
8130    /// the archive covers global frames `[prev_n, N)`).  An archive is
8131    /// fully orphaned when `N <= wal_horizon_floor`: all of its frames fall
8132    /// below the floor and have already been counted in it.
8133    fn cleanup_orphaned_archives(&mut self) -> Result<()> {
8134        if self.wal_horizon_floor == 0 {
8135            // Floor at 0 means no pruning has ever occurred; nothing to clean.
8136            return Ok(());
8137        }
8138        let archive_ns = self.fs.list_archives()?;
8139        for n in archive_ns {
8140            if n <= self.wal_horizon_floor {
8141                // Archive N ends at global frame N; all its frames are below
8142                // the floor (floor already accounts for them) → orphaned.
8143                self.fs.delete_archive(n).map_err(GraphError::Io)?;
8144            } else {
8145                // Archives are sorted ascending; first one above floor stops scan.
8146                break;
8147            }
8148        }
8149        Ok(())
8150    }
8151
8152    /// Collect all WAL frames from surviving archives (oldest-first) then the
8153    /// live WAL into one flat list, and return the total along with the number
8154    /// of archive frames at the front of the list.
8155    ///
8156    /// Commit indices into the returned list are LOCAL (0 = first frame of
8157    /// oldest surviving archive).  To obtain the GLOBAL index add
8158    /// `self.wal_horizon_floor`.
8159    fn all_frames(&self) -> Result<(Vec<WalRecord>, u64)> {
8160        let archive_ns = self.fs.list_archives()?;
8161        let mut all: Vec<WalRecord> = Vec::new();
8162        for n in archive_ns {
8163            let bytes = self.fs.read_archive(n)?;
8164            let (frames, _) = decode_all(&bytes);
8165            all.extend(frames);
8166        }
8167        let archive_count = all.len() as u64;
8168        let live_bytes = self.fs.read(FileId::Wal)?;
8169        let (live_frames, _) = decode_all(&live_bytes);
8170        all.extend(live_frames);
8171        Ok((all, archive_count))
8172    }
8173
8174    /// Return the total number of committed WAL frames visible in the current
8175    /// horizon window, including frames in surviving WAL archives.
8176    ///
8177    /// This is the exclusive upper bound for valid `at_commit` indices in
8178    /// `was_linked`. Valid indices are `wal_horizon_floor()..wal_total_commits()`.
8179    ///
8180    /// Returns the horizon floor when all surviving history is empty.
8181    pub fn wal_total_commits(&self) -> Result<u64> {
8182        let (frames, _) = self.all_frames()?;
8183        Ok(self.wal_horizon_floor + frames.len() as u64)
8184    }
8185
8186    /// The global frame index of the first commit reachable through surviving
8187    /// archives (0 when no archives have been pruned).
8188    pub fn wal_horizon_floor(&self) -> u64 {
8189        self.wal_horizon_floor
8190    }
8191
8192    /// Return the per-node change history for `key` by scanning the on-disk WAL.
8193    ///
8194    /// ## Horizon
8195    ///
8196    /// History reaches back only to the last WAL-truncating snapshot, exactly like `open_at`.
8197    /// Snapshots written with `keep_wal: true` preserve deeper history. This is the honest,
8198    /// zero-cost contract; a durable history log is out of scope.
8199    ///
8200    /// ## Derived edges
8201    ///
8202    /// Rule-created (derived) edges are **not** in the WAL and therefore do not appear in
8203    /// history. Only edges written directly by the application are recorded.
8204    ///
8205    /// ## Deleted nodes
8206    ///
8207    /// For nodes that have been deleted, dense-id records (SetPropId, InsertEdgeId) that
8208    /// predate the deletion may not resolve (the id is tombstoned in the live map). The
8209    /// string-keyed `DeleteNode` record still matches and produces a `NodeDeleted` entry.
8210    /// Prop/edge history of a deleted node may therefore be partially unresolvable.
8211    ///
8212    /// ## Dense-id edge entries and tombstoned partners
8213    ///
8214    /// Edge entries from dense-id WAL records (`InsertEdgeId`) are omitted when the partner
8215    /// endpoint's dense id is tombstoned. As a result, a live node's history can contain an
8216    /// `EdgeRemoved` (string-keyed, always resolves) without a corresponding `EdgeAdded`.
8217    /// Build commit-bounded alias intervals for `queried_key`.
8218    ///
8219    /// Returns a list of `(key, valid_from_inclusive, valid_until_exclusive)` tuples.
8220    /// A record written under `key` at commit `c` matches the queried identity iff
8221    /// `c >= valid_from && (valid_until.is_none() || c < valid_until)`.
8222    ///
8223    /// Each alias entry carries both a lower and an upper bound so that key-reuse
8224    /// after a rename is handled correctly: if "a" is renamed to "b" at commit 5,
8225    /// then a NEW node is created as "a" at commit 7 and renamed to "c" at commit 10,
8226    /// querying "c" must NOT surface identity-1's events (commits 0–4 under "a");
8227    /// only identity-2's events (commits 7–9 under "a") are in scope.
8228    ///
8229    /// Only **forward aliasing**: querying the *new* key surfaces events written
8230    /// under the *old* key.  The reverse direction is not supported.
8231    fn build_key_alias_intervals(
8232        &self,
8233        frames: &[core_storage::wal::WalRecord],
8234        queried_key: &str,
8235    ) -> Vec<(String, u64, Option<u64>)> {
8236        use core_storage::wal::WalRecord;
8237
8238        // Pre-pass: build reverse_rename and key_starts maps.
8239        let mut reverse_rename: HashMap<String, (String, u64)> = HashMap::new();
8240        let mut key_starts: HashMap<String, Vec<u64>> = HashMap::new();
8241
8242        for (local_i, frame) in frames.iter().enumerate() {
8243            let commit = self.wal_horizon_floor + local_i as u64;
8244            let records: &[WalRecord] = match frame {
8245                WalRecord::Batch(inner) => inner.as_slice(),
8246                single => std::slice::from_ref(single),
8247            };
8248            for rec in records {
8249                match rec {
8250                    WalRecord::InsertNode { key, .. } | WalRecord::InsertNodeId { key, .. } => {
8251                        key_starts.entry(key.clone()).or_default().push(commit);
8252                    }
8253                    WalRecord::RenameNode { old_key, new_key } => {
8254                        // new_key came into existence at this commit.
8255                        key_starts.entry(new_key.clone()).or_default().push(commit);
8256                        // Record the reverse rename: new_key was introduced by renaming old_key.
8257                        reverse_rename.insert(new_key.clone(), (old_key.clone(), commit));
8258                    }
8259                    _ => {}
8260                }
8261            }
8262        }
8263
8264        // Build alias intervals by following the reverse rename chain.
8265        let mut result: Vec<(String, u64, Option<u64>)> = Vec::new();
8266        let mut current_key = queried_key.to_string();
8267        let mut current_valid_until: Option<u64> = None;
8268
8269        loop {
8270            // valid_from: the most recent commit where current_key was assigned to this
8271            // identity.  For aliases (valid_until = Some(vu)), find the last start event
8272            // for the key strictly before vu — this is where the alias's occupancy by
8273            // this identity began, correctly excluding prior identities that reused the key.
8274            let valid_from = if let Some(vu) = current_valid_until {
8275                key_starts
8276                    .get(&current_key)
8277                    .and_then(|starts| starts.iter().rev().find(|&&s| s < vu).copied())
8278                    .unwrap_or(self.wal_horizon_floor)
8279            } else {
8280                // Queried key — no upper bound; may have been introduced at any commit.
8281                self.wal_horizon_floor
8282            };
8283
8284            result.push((current_key.clone(), valid_from, current_valid_until));
8285
8286            match reverse_rename.get(&current_key) {
8287                Some((old_key, rename_commit)) => {
8288                    current_valid_until = Some(*rename_commit);
8289                    current_key = old_key.clone();
8290                }
8291                None => break,
8292            }
8293        }
8294
8295        result
8296    }
8297
8298    /// Returns true if `record_key` matches any alias interval that covers `commit`.
8299    fn aliases_match(
8300        intervals: &[(String, u64, Option<u64>)],
8301        record_key: &str,
8302        commit: u64,
8303    ) -> bool {
8304        intervals
8305            .iter()
8306            .any(|(k, vf, vu)| k == record_key && commit >= *vf && vu.is_none_or(|u| commit < u))
8307    }
8308
8309    pub fn node_history(&self, key: &str) -> Result<Vec<crate::history::HistoryEntry>> {
8310        use crate::history::{HistoryChange, HistoryEntry};
8311        use core_storage::wal::WalRecord;
8312
8313        let (frames, _) = self.all_frames()?;
8314
8315        // Resolve commit-bounded alias intervals for `key` (handles renames in the WAL).
8316        let alias_intervals = self.build_key_alias_intervals(&frames, key);
8317
8318        let mut out: Vec<HistoryEntry> = Vec::new();
8319
8320        for (local_i, frame) in frames.iter().enumerate() {
8321            let commit = self.wal_horizon_floor + local_i as u64;
8322            // Collect the inner records to process — Batch is one commit, single records are one commit.
8323            let records: &[WalRecord] = match frame {
8324                WalRecord::Batch(inner) => inner.as_slice(),
8325                single => std::slice::from_ref(single),
8326            };
8327
8328            for rec in records {
8329                let change = match rec {
8330                    WalRecord::InsertNode { label, key: k, .. }
8331                        if Self::aliases_match(&alias_intervals, k, commit) =>
8332                    {
8333                        Some(HistoryChange::NodeInserted {
8334                            label: label.clone(),
8335                        })
8336                    }
8337                    WalRecord::InsertNodeId { label, key: k, .. }
8338                        if Self::aliases_match(&alias_intervals, k, commit) =>
8339                    {
8340                        let label_str = match self.syms.resolve(*label) {
8341                            Some(s) => s.to_string(),
8342                            None => continue,
8343                        };
8344                        Some(HistoryChange::NodeInserted { label: label_str })
8345                    }
8346                    WalRecord::SetProp {
8347                        key: k,
8348                        field,
8349                        value,
8350                    } if Self::aliases_match(&alias_intervals, k, commit) => {
8351                        Some(HistoryChange::PropSet {
8352                            field: field.clone(),
8353                            value: value.clone(),
8354                        })
8355                    }
8356                    WalRecord::SetPropId { id, field, value } => match self.ids.key_of(*id) {
8357                        // key_of returns the current (post-rename) key; compare to queried key.
8358                        Some(resolved) if resolved == key => {
8359                            let field_str = match self.syms.resolve(*field) {
8360                                Some(s) => s.to_string(),
8361                                None => continue,
8362                            };
8363                            Some(HistoryChange::PropSet {
8364                                field: field_str,
8365                                value: value.clone(),
8366                            })
8367                        }
8368                        _ => None,
8369                    },
8370                    WalRecord::RemoveProp { key: k, field }
8371                        if Self::aliases_match(&alias_intervals, k, commit) =>
8372                    {
8373                        Some(HistoryChange::PropRemoved {
8374                            field: field.clone(),
8375                        })
8376                    }
8377                    WalRecord::InsertEdge {
8378                        edge_type,
8379                        src_key,
8380                        dst_key,
8381                    } => {
8382                        if Self::aliases_match(&alias_intervals, src_key, commit) {
8383                            Some(HistoryChange::EdgeAdded {
8384                                edge_type: edge_type.clone(),
8385                                other: dst_key.clone(),
8386                                outgoing: true,
8387                            })
8388                        } else if Self::aliases_match(&alias_intervals, dst_key, commit) {
8389                            Some(HistoryChange::EdgeAdded {
8390                                edge_type: edge_type.clone(),
8391                                other: src_key.clone(),
8392                                outgoing: false,
8393                            })
8394                        } else {
8395                            None
8396                        }
8397                    }
8398                    WalRecord::InsertEdgeId { etype, src, dst } => {
8399                        let etype_str = match self.syms.resolve(*etype) {
8400                            Some(s) => s.to_string(),
8401                            None => continue,
8402                        };
8403                        let src_key = self.ids.key_of(*src);
8404                        let dst_key = self.ids.key_of(*dst);
8405                        if src_key == Some(key) {
8406                            let other = match dst_key {
8407                                Some(s) => s.to_string(),
8408                                None => continue,
8409                            };
8410                            Some(HistoryChange::EdgeAdded {
8411                                edge_type: etype_str,
8412                                other,
8413                                outgoing: true,
8414                            })
8415                        } else if dst_key == Some(key) {
8416                            let other = match src_key {
8417                                Some(s) => s.to_string(),
8418                                None => continue,
8419                            };
8420                            Some(HistoryChange::EdgeAdded {
8421                                edge_type: etype_str,
8422                                other,
8423                                outgoing: false,
8424                            })
8425                        } else {
8426                            None
8427                        }
8428                    }
8429                    WalRecord::DeleteEdge {
8430                        edge_type,
8431                        src_key,
8432                        dst_key,
8433                    } => {
8434                        if Self::aliases_match(&alias_intervals, src_key, commit) {
8435                            Some(HistoryChange::EdgeRemoved {
8436                                edge_type: edge_type.clone(),
8437                                other: dst_key.clone(),
8438                                outgoing: true,
8439                            })
8440                        } else if Self::aliases_match(&alias_intervals, dst_key, commit) {
8441                            Some(HistoryChange::EdgeRemoved {
8442                                edge_type: edge_type.clone(),
8443                                other: src_key.clone(),
8444                                outgoing: false,
8445                            })
8446                        } else {
8447                            None
8448                        }
8449                    }
8450                    WalRecord::DeleteNode { key: k }
8451                        if Self::aliases_match(&alias_intervals, k, commit) =>
8452                    {
8453                        Some(HistoryChange::NodeDeleted)
8454                    }
8455                    // Skip: rule/view/fulltext/intern metadata; Batch wrapper handled above.
8456                    _ => None,
8457                };
8458
8459                if let Some(change) = change {
8460                    out.push(HistoryEntry { commit, change });
8461                }
8462            }
8463        }
8464
8465        Ok(out)
8466    }
8467
8468    /// Return the per-edge change history between nodes `a` and `b` by scanning
8469    /// the on-disk WAL.
8470    ///
8471    /// ## Horizon
8472    ///
8473    /// History reaches back only to the last WAL-truncating snapshot, exactly
8474    /// like `node_history` and `open_at`. The returned [`HistoryResult`] carries
8475    /// `total_commits` (= number of WAL frames), which is the exclusive upper
8476    /// bound for valid commit indices.
8477    ///
8478    /// ## Derived edges
8479    ///
8480    /// Rule-derived edges appear via `DerivedEdgeAdded` / `DerivedEdgeRetracted`
8481    /// WAL markers written by `log_then_apply_with` after each rule-firing
8482    /// mutation. The `rule` field of those events carries the rule name.
8483    ///
8484    /// ## DeleteNode
8485    ///
8486    /// When a node is deleted, its manual incident edges are swept inline without
8487    /// individual `DeleteEdge` WAL records. `edge_history` detects `DeleteNode`
8488    /// events for either endpoint and synthesises `Retracted(rule:None)` events
8489    /// for each manual edge that was active at that point. Derived edges active at
8490    /// the time of deletion are handled by the `DerivedEdgeRetracted` marker that
8491    /// the engine appends immediately after the `DeleteNode` record; those events
8492    /// carry correct rule attribution and are emitted by the marker arm, not the
8493    /// synthetic sweep.
8494    ///
8495    /// ## Masks
8496    ///
8497    /// Like `node_history`, this method has no mask parameter and returns WAL
8498    /// history regardless of any role mask. For masked history semantics, apply
8499    /// the mask at the caller level.
8500    pub fn edge_history(
8501        &self,
8502        a: &str,
8503        b: &str,
8504    ) -> Result<crate::history::HistoryResult<crate::history::EdgeHistoryEvent>> {
8505        use crate::history::{EdgeEvent, EdgeHistoryEvent, HistoryResult};
8506        use core_storage::wal::WalRecord;
8507
8508        let (frames, _) = self.all_frames()?;
8509        let total_commits = self.wal_horizon_floor + frames.len() as u64;
8510
8511        // Resolve all historical names for a and b (handles RenameNode in the WAL).
8512        // Intervals are commit-bounded so recycled keys don't contaminate histories.
8513        let alias_a = self.build_key_alias_intervals(&frames, a);
8514        let alias_b = self.build_key_alias_intervals(&frames, b);
8515
8516        // Active edges between a and b tracked as (edge_type, src_key, dst_key, is_derived).
8517        // The is_derived flag is used by the DeleteNode sweep: manual edges are
8518        // swept with a synthetic Retracted(rule:None); derived edges are skipped
8519        // because the engine writes a DerivedEdgeRetracted marker immediately after
8520        // the DeleteNode record, which carries the correct rule attribution.
8521        let mut active: Vec<(String, String, String, bool)> = Vec::new();
8522        let mut out: Vec<EdgeHistoryEvent> = Vec::new();
8523
8524        for (local_i, frame) in frames.iter().enumerate() {
8525            let commit = self.wal_horizon_floor + local_i as u64;
8526            let records: &[WalRecord] = match frame {
8527                WalRecord::Batch(inner) => inner.as_slice(),
8528                single => std::slice::from_ref(single),
8529            };
8530
8531            for rec in records {
8532                match rec {
8533                    WalRecord::InsertEdge {
8534                        edge_type,
8535                        src_key,
8536                        dst_key,
8537                    } => {
8538                        let is_ab = Self::aliases_match(&alias_a, src_key, commit)
8539                            && Self::aliases_match(&alias_b, dst_key, commit);
8540                        let is_ba = Self::aliases_match(&alias_b, src_key, commit)
8541                            && Self::aliases_match(&alias_a, dst_key, commit);
8542                        if is_ab || is_ba {
8543                            active.push((
8544                                edge_type.clone(),
8545                                src_key.clone(),
8546                                dst_key.clone(),
8547                                false,
8548                            ));
8549                            out.push(EdgeHistoryEvent {
8550                                edge_type: edge_type.clone(),
8551                                commit,
8552                                event: EdgeEvent::Added,
8553                                rule: None,
8554                            });
8555                        }
8556                    }
8557                    WalRecord::InsertEdgeId { etype, src, dst } => {
8558                        let etype_str = match self.syms.resolve(*etype) {
8559                            Some(s) => s.to_string(),
8560                            None => continue,
8561                        };
8562                        // Use key_of_historical so tombstoned nodes (deleted
8563                        // later in the WAL) still resolve during the scan.
8564                        let src_key = self.ids.key_of_historical(*src);
8565                        let dst_key = self.ids.key_of_historical(*dst);
8566                        let is_ab = src_key == Some(a) && dst_key == Some(b);
8567                        let is_ba = src_key == Some(b) && dst_key == Some(a);
8568                        if is_ab || is_ba {
8569                            let src_str = src_key.unwrap().to_string();
8570                            let dst_str = dst_key.unwrap().to_string();
8571                            active.push((etype_str.clone(), src_str, dst_str, false));
8572                            out.push(EdgeHistoryEvent {
8573                                edge_type: etype_str,
8574                                commit,
8575                                event: EdgeEvent::Added,
8576                                rule: None,
8577                            });
8578                        }
8579                    }
8580                    WalRecord::DeleteEdge {
8581                        edge_type,
8582                        src_key,
8583                        dst_key,
8584                    } => {
8585                        let is_ab = Self::aliases_match(&alias_a, src_key, commit)
8586                            && Self::aliases_match(&alias_b, dst_key, commit);
8587                        let is_ba = Self::aliases_match(&alias_b, src_key, commit)
8588                            && Self::aliases_match(&alias_a, dst_key, commit);
8589                        if is_ab || is_ba {
8590                            // Remove the first matching active entry (flag ignored).
8591                            if let Some(pos) = active.iter().position(|(et, s, d, _)| {
8592                                et == edge_type && s == src_key && d == dst_key
8593                            }) {
8594                                active.remove(pos);
8595                            }
8596                            out.push(EdgeHistoryEvent {
8597                                edge_type: edge_type.clone(),
8598                                commit,
8599                                event: EdgeEvent::Retracted,
8600                                rule: None,
8601                            });
8602                        }
8603                    }
8604                    WalRecord::DeleteNode { key: k }
8605                        if Self::aliases_match(&alias_a, k, commit)
8606                            || Self::aliases_match(&alias_b, k, commit) =>
8607                    {
8608                        // Sweep: implicitly retract only MANUAL active edges.
8609                        // Derived active edges are skipped here because the rule
8610                        // engine appends a DerivedEdgeRetracted marker immediately
8611                        // after this DeleteNode record; that marker produces the
8612                        // single correctly-attributed Retracted event.  Derived
8613                        // entries are dropped from `active` (the marker arm's
8614                        // idempotent retain finds nothing to remove).
8615                        for (et, _, _, is_derived) in active.drain(..) {
8616                            if !is_derived {
8617                                out.push(EdgeHistoryEvent {
8618                                    edge_type: et,
8619                                    commit,
8620                                    event: EdgeEvent::Retracted,
8621                                    rule: None,
8622                                });
8623                            }
8624                            // Derived: drop silently; marker carries the Retracted event.
8625                        }
8626                    }
8627                    WalRecord::DerivedEdgeAdded {
8628                        rule,
8629                        edge_type: et,
8630                        src_key,
8631                        dst_key,
8632                    } => {
8633                        let is_ab = Self::aliases_match(&alias_a, src_key, commit)
8634                            && Self::aliases_match(&alias_b, dst_key, commit);
8635                        let is_ba = Self::aliases_match(&alias_b, src_key, commit)
8636                            && Self::aliases_match(&alias_a, dst_key, commit);
8637                        if is_ab || is_ba {
8638                            active.push((et.clone(), src_key.clone(), dst_key.clone(), true));
8639                            out.push(EdgeHistoryEvent {
8640                                edge_type: et.clone(),
8641                                commit,
8642                                event: EdgeEvent::Added,
8643                                rule: Some(rule.clone()),
8644                            });
8645                        }
8646                    }
8647                    WalRecord::DerivedEdgeRetracted {
8648                        rule,
8649                        edge_type: et,
8650                        src_key,
8651                        dst_key,
8652                    } => {
8653                        let is_ab = Self::aliases_match(&alias_a, src_key, commit)
8654                            && Self::aliases_match(&alias_b, dst_key, commit);
8655                        let is_ba = Self::aliases_match(&alias_b, src_key, commit)
8656                            && Self::aliases_match(&alias_a, dst_key, commit);
8657                        if is_ab || is_ba {
8658                            // Push unconditionally: a derived edge whose Added marker
8659                            // predates the history horizon has no `active` entry, but
8660                            // the retraction is still a real in-window event.
8661                            // Remove from active idempotently if present.
8662                            active.retain(|(aet, s, d, _)| {
8663                                !(aet == et && s == src_key && d == dst_key)
8664                            });
8665                            out.push(EdgeHistoryEvent {
8666                                edge_type: et.clone(),
8667                                commit,
8668                                event: EdgeEvent::Retracted,
8669                                rule: Some(rule.clone()),
8670                            });
8671                        }
8672                    }
8673                    // All other records (InsertNode, SetProp, CreateRule, etc.)
8674                    // do not affect edges between a and b.
8675                    _ => {}
8676                }
8677            }
8678        }
8679
8680        Ok(HistoryResult {
8681            items: out,
8682            total_commits,
8683        })
8684    }
8685
8686    /// Return `true` iff an edge of `edge_type` existed between `a` and `b`
8687    /// (in either direction) at the WAL commit `at_commit`.
8688    ///
8689    /// ## Horizon
8690    ///
8691    /// Valid commit indices are `0..total_commits` where `total_commits` is the
8692    /// number of WAL frames. An `at_commit >= total_commits` is outside the
8693    /// visible horizon and returns [`GraphError::CommitOutOfRange`].
8694    ///
8695    /// ## Derived edges
8696    ///
8697    /// Rule-derived edges are tracked via `DerivedEdgeAdded` / `DerivedEdgeRetracted`
8698    /// WAL markers appended at firing time (Task 1). `was_linked` reads these markers
8699    /// and therefore includes derived edges in its point-in-time evaluation,
8700    /// matching `edge_history`'s fidelity.
8701    pub fn was_linked(&self, a: &str, b: &str, edge_type: &str, at_commit: u64) -> Result<bool> {
8702        use core_storage::wal::WalRecord;
8703
8704        let (frames, _) = self.all_frames()?;
8705        let total_commits = self.wal_horizon_floor + frames.len() as u64;
8706
8707        // Horizon floor: commits in pruned archives are unreachable.
8708        if at_commit < self.wal_horizon_floor {
8709            return Err(GraphError::CommitOutOfRange {
8710                commit: at_commit,
8711                total: total_commits,
8712            });
8713        }
8714        if at_commit >= total_commits {
8715            return Err(GraphError::CommitOutOfRange {
8716                commit: at_commit,
8717                total: total_commits,
8718            });
8719        }
8720
8721        // Resolve all historical names for a and b (handles RenameNode in the WAL).
8722        // Intervals are commit-bounded so recycled keys don't contaminate point-in-time reads.
8723        let alias_a = self.build_key_alias_intervals(&frames, a);
8724        let alias_b = self.build_key_alias_intervals(&frames, b);
8725
8726        // Local index into surviving frames (0 = first frame of oldest archive).
8727        let local_commit = at_commit - self.wal_horizon_floor;
8728
8729        // Replay local frames 0..=local_commit, tracking active edges.
8730        let mut active: BTreeSet<(String, String, String)> = BTreeSet::new();
8731
8732        for (local_i, frame) in frames.iter().enumerate().take((local_commit + 1) as usize) {
8733            let commit = self.wal_horizon_floor + local_i as u64;
8734            let records: &[WalRecord] = match frame {
8735                WalRecord::Batch(inner) => inner.as_slice(),
8736                single => std::slice::from_ref(single),
8737            };
8738
8739            for rec in records {
8740                match rec {
8741                    WalRecord::InsertEdge {
8742                        edge_type: et,
8743                        src_key,
8744                        dst_key,
8745                    } => {
8746                        let is_ab = Self::aliases_match(&alias_a, src_key, commit)
8747                            && Self::aliases_match(&alias_b, dst_key, commit);
8748                        let is_ba = Self::aliases_match(&alias_b, src_key, commit)
8749                            && Self::aliases_match(&alias_a, dst_key, commit);
8750                        if is_ab || is_ba {
8751                            active.insert((et.clone(), src_key.clone(), dst_key.clone()));
8752                        }
8753                    }
8754                    WalRecord::InsertEdgeId { etype, src, dst } => {
8755                        let etype_str = match self.syms.resolve(*etype) {
8756                            Some(s) => s.to_string(),
8757                            None => continue,
8758                        };
8759                        // Use key_of_historical so tombstoned nodes resolve.
8760                        let src_key = self.ids.key_of_historical(*src);
8761                        let dst_key = self.ids.key_of_historical(*dst);
8762                        let is_ab = src_key == Some(a) && dst_key == Some(b);
8763                        let is_ba = src_key == Some(b) && dst_key == Some(a);
8764                        if is_ab || is_ba {
8765                            active.insert((
8766                                etype_str,
8767                                src_key.unwrap().to_string(),
8768                                dst_key.unwrap().to_string(),
8769                            ));
8770                        }
8771                    }
8772                    WalRecord::DeleteEdge {
8773                        edge_type: et,
8774                        src_key,
8775                        dst_key,
8776                    } => {
8777                        let is_ab = Self::aliases_match(&alias_a, src_key, commit)
8778                            && Self::aliases_match(&alias_b, dst_key, commit);
8779                        let is_ba = Self::aliases_match(&alias_b, src_key, commit)
8780                            && Self::aliases_match(&alias_a, dst_key, commit);
8781                        if is_ab || is_ba {
8782                            active.remove(&(et.clone(), src_key.clone(), dst_key.clone()));
8783                        }
8784                    }
8785                    WalRecord::DeleteNode { key: k }
8786                        if Self::aliases_match(&alias_a, k, commit)
8787                            || Self::aliases_match(&alias_b, k, commit) =>
8788                    {
8789                        // All edges touching the deleted node are gone.
8790                        active.retain(|(_, s, d)| s != k && d != k);
8791                    }
8792                    WalRecord::DerivedEdgeAdded {
8793                        edge_type: et,
8794                        src_key,
8795                        dst_key,
8796                        ..
8797                    } => {
8798                        let is_ab = Self::aliases_match(&alias_a, src_key, commit)
8799                            && Self::aliases_match(&alias_b, dst_key, commit);
8800                        let is_ba = Self::aliases_match(&alias_b, src_key, commit)
8801                            && Self::aliases_match(&alias_a, dst_key, commit);
8802                        if is_ab || is_ba {
8803                            active.insert((et.clone(), src_key.clone(), dst_key.clone()));
8804                        }
8805                    }
8806                    WalRecord::DerivedEdgeRetracted {
8807                        edge_type: et,
8808                        src_key,
8809                        dst_key,
8810                        ..
8811                    } => {
8812                        let is_ab = Self::aliases_match(&alias_a, src_key, commit)
8813                            && Self::aliases_match(&alias_b, dst_key, commit);
8814                        let is_ba = Self::aliases_match(&alias_b, src_key, commit)
8815                            && Self::aliases_match(&alias_a, dst_key, commit);
8816                        if is_ab || is_ba {
8817                            active.remove(&(et.clone(), src_key.clone(), dst_key.clone()));
8818                        }
8819                    }
8820                    _ => {}
8821                }
8822            }
8823        }
8824
8825        Ok(active.iter().any(|(et, _, _)| et == edge_type))
8826    }
8827
8828    pub fn edge_count(&self) -> u64 {
8829        self.topo_view().edge_count()
8830    }
8831
8832    /// Live/tombstone/edge counts plus per-rule provenance size, trip latch,
8833    /// and fire counter (includes rebuild evaluations). Rules are sorted by name.
8834    pub fn stats(&self) -> Stats {
8835        self.ensure_v8_base_sections_loaded();
8836        let rules: Vec<RuleStats> = self
8837            .engine
8838            .rules()
8839            .map(|r| RuleStats {
8840                name: r.name.clone(),
8841                edges: self
8842                    .engine
8843                    .provenance()
8844                    .get(&r.name)
8845                    .map(|s| s.len() as u64)
8846                    .unwrap_or(0),
8847                tripped: self.engine.is_tripped(&r.name),
8848                fires: self.engine.fire_count(&r.name),
8849                approximate: r.approximate,
8850            })
8851            .collect();
8852        Stats {
8853            nodes_live: self.ids.live_len(),
8854            nodes_tombstoned: self.ids.len() - self.ids.live_len(),
8855            edges: self.topo_view().edge_count(),
8856            rules,
8857            chain_truncations: self.engine.chain_truncations(),
8858        }
8859    }
8860
8861    /// On-disk size of the WAL file in bytes.
8862    ///
8863    /// Reads file metadata without loading WAL contents.  Returns `Err` for
8864    /// in-memory (`SimFs`) databases where no WAL file exists on disk.
8865    pub fn wal_size_bytes(&self) -> std::io::Result<u64> {
8866        let path = self.fs.wal_path().ok_or_else(|| {
8867            std::io::Error::new(
8868                std::io::ErrorKind::Unsupported,
8869                "wal_path not available for this Fs implementation",
8870            )
8871        })?;
8872        Ok(std::fs::metadata(path)?.len())
8873    }
8874
8875    /// Set the slow-query threshold.  Queries whose execution time equals or
8876    /// exceeds `ms` milliseconds are logged.  Pass `0` to disable.
8877    ///
8878    /// Use this setter in tests — the environment variable
8879    /// `MUSHROOMDB_SLOW_QUERY_MS` is process-global and races parallel test
8880    /// threads.
8881    pub fn set_slow_query_threshold_ms(&mut self, ms: u64) {
8882        self.slow_query_threshold_ms = ms;
8883    }
8884
8885    /// Snapshot of the slow-query ring buffer and lifetime counter.
8886    pub fn slow_query_snapshot(&self) -> SlowQuerySnapshot {
8887        let log = self.slow_queries.lock().unwrap_or_else(|e| e.into_inner());
8888        SlowQuerySnapshot {
8889            threshold_ms: self.slow_query_threshold_ms,
8890            count: log.total,
8891            last: log.entries.iter().cloned().collect(),
8892        }
8893    }
8894
8895    /// Instant the database was opened.  Used by consumers (e.g. `/metrics`)
8896    /// to compute uptime.
8897    pub fn started_at(&self) -> std::time::Instant {
8898        self.started_at
8899    }
8900
8901    /// On-disk snapshot format version this binary writes and reads.
8902    pub fn format_version() -> u16 {
8903        core_storage::snapshot::VERSION
8904    }
8905
8906    /// Test-support: total bytes appended (SimFs only usage).
8907    pub fn fs_total_appended(&self) -> usize
8908    where
8909        F: FsIntrospect,
8910    {
8911        self.fs.total_appended()
8912    }
8913
8914    /// Test-support: successful `Fs::sync` calls (SimFs / counting fs).
8915    pub fn fs_sync_count(&self) -> usize
8916    where
8917        F: FsIntrospect,
8918    {
8919        self.fs.sync_count()
8920    }
8921
8922    /// Consume the db, returning its fs (for crash simulation).
8923    pub fn into_fs(self) -> F {
8924        self.fs
8925    }
8926
8927    pub fn snapshot(&mut self) -> Result<()> {
8928        self.snapshot_with(SnapshotOptions::default())
8929    }
8930
8931    /// Snapshot with explicit options.
8932    ///
8933    /// # `keep_wal`
8934    ///
8935    /// When `keep_wal` is `false` (the default, same as [`snapshot`]):
8936    ///   - The WAL is replaced with a minimal baseline containing one
8937    ///     `EnableFulltext` record per active declaration.  All pre-snapshot
8938    ///     history is discarded; `open_at` can only reach post-snapshot commits.
8939    ///
8940    /// When `keep_wal` is `true`:
8941    ///   - The WAL is left intact.  All pre-snapshot commits remain reachable
8942    ///     via `open_at`.  The existing WAL already contains the original
8943    ///     `EnableFulltext` records, so no baseline re-write is needed; the
8944    ///     recovery guards in `apply()` silently skip any duplicate records on
8945    ///     replay.
8946    ///   - Crash window: a crash after the snapshot write but before the next
8947    ///     WAL write leaves the full pre-snapshot WAL intact.  On reopen the
8948    ///     snapshot is loaded and the WAL replayed idempotently over it — safe
8949    ///     because every `apply()` arm is idempotent when replayed over an
8950    ///     already-current snapshot.
8951    pub fn snapshot_with(&mut self, opts: SnapshotOptions) -> Result<()> {
8952        if self.read_only {
8953            return Err(GraphError::ReadOnly);
8954        }
8955        // Capture whether snapshot.bin already existed BEFORE this snapshot write.
8956        // Used by the archive path's conservative genesis-chain check: if a prior
8957        // snapshot exists but wal.truncated does not, we cannot distinguish a
8958        // legacy store (may have been truncated in an older code version) from a
8959        // new store that only used keep_wal=true.  Conservative: refuse genesis in
8960        // both cases.  Must be sampled here, before the snapshot write below.
8961        let had_prior_snapshot = self.fs.snapshot_path().map(|p| p.exists()).unwrap_or(false);
8962        self.ensure_v8_base_sections_loaded();
8963        // Ensure provenance is decoded before to_persist() clones it.
8964        self.engine.ensure_provenance_loaded_mut();
8965        let (rule_defs_typed, provenance, rule_tripped, rule_fires) = self.engine.to_persist();
8966        let rule_defs = rule_defs_typed
8967            .iter()
8968            .map(|r| bincode::serialize(r).expect("RuleDef serialize cannot fail"))
8969            .collect();
8970        // Collect HNSW state and IVF state.  When indexes are not yet
8971        // populated (clean open, no mutation since open), pass the retained
8972        // raw bytes through directly so that migrate/snapshot does not
8973        // silently discard fitted approximate-rule indexes.
8974        let hnsw_state = self.engine.export_hnsw_state_passthrough();
8975        let ivf_bytes = if !self.engine.indexes_populated() {
8976            // Pass retained IVF bytes through unchanged (no re-encode).
8977            self.engine.retained_ivf_bytes_clone().unwrap_or_default()
8978        } else {
8979            // Indexes live: encode from current state.
8980            let raw_ivf = self.engine.export_ivf_state();
8981            let ivf_state_map: BTreeMap<String, core_storage::snapshot::PerRuleIvfState> = raw_ivf
8982                .into_iter()
8983                .map(|(name, ((sc, sa, sd), (dc, da, dd)))| {
8984                    (
8985                        name,
8986                        core_storage::snapshot::PerRuleIvfState {
8987                            src: core_storage::snapshot::SideIvfState {
8988                                centroids: sc,
8989                                clusters: sa,
8990                                drift: sd,
8991                            },
8992                            dst: core_storage::snapshot::SideIvfState {
8993                                centroids: dc,
8994                                clusters: da,
8995                                drift: dd,
8996                            },
8997                        },
8998                    )
8999                })
9000                .collect();
9001            if ivf_state_map.is_empty() {
9002                Vec::new()
9003            } else {
9004                bincode::serialize(&ivf_state_map).expect("IVF state serialize cannot fail")
9005            }
9006        };
9007        let view_defs: Vec<Vec<u8>> = self
9008            .view_store
9009            .views()
9010            .map(|v| bincode::serialize(v).expect("ViewDef serialize cannot fail"))
9011            .collect();
9012        if self.base.is_some() {
9013            // V8 merge-snapshot path: encode base+overlay into a new V8 snapshot,
9014            // write it atomically, remap it as the new base, then clear the overlay.
9015            let meta = V8Meta {
9016                labels: self.labels.clone(),
9017                edge_props: self.edge_props.clone(),
9018                rule_defs,
9019                provenance,
9020                rule_tripped,
9021                rule_fires,
9022                ivf_bytes,
9023                view_defs,
9024                wal_truncated: !opts.keep_wal,
9025                hnsw: hnsw_state,
9026                last_change: self.last_change.clone(),
9027            };
9028            let mut buf: Vec<u8> = Vec::new();
9029            {
9030                // Clone the Arc so the old base stays alive while we encode.
9031                // The borrow of archived_csr (into old_base's mmap) is released
9032                // at the end of this block, before we replace self.base.
9033                let old_base = self.base.clone().expect("is_some checked above");
9034                let archived_csr = old_base.topology().map_err(|e| GraphError::Corrupt {
9035                    detail: format!("v8 snapshot: topology section: {e:?}"),
9036                })?;
9037                let archived_cols = old_base.columns().map_err(|e| GraphError::Corrupt {
9038                    detail: format!("v8 snapshot: columns section: {e:?}"),
9039                })?;
9040                let archived_edge_props =
9041                    old_base
9042                        .edge_props_section()
9043                        .map_err(|e| GraphError::Corrupt {
9044                            detail: format!("v8 snapshot: edge_props section: {e:?}"),
9045                        })?;
9046                let edge_props_raw =
9047                    old_base
9048                        .edge_props_raw_bytes()
9049                        .map_err(|e| GraphError::Corrupt {
9050                            detail: format!("v8 snapshot: edge_props raw bytes: {e:?}"),
9051                        })?;
9052                let prov_raw =
9053                    old_base
9054                        .provenance_raw_bytes()
9055                        .map_err(|e| GraphError::Corrupt {
9056                            detail: format!("v8 snapshot: provenance raw bytes: {e:?}"),
9057                        })?;
9058                encode_v8(
9059                    Some(archived_csr),
9060                    Some(archived_cols),
9061                    Some((archived_edge_props, edge_props_raw)),
9062                    Some(prov_raw),
9063                    &self.topo,
9064                    &self.props,
9065                    &self.ids,
9066                    &self.syms,
9067                    &meta,
9068                    &mut buf,
9069                )?;
9070            }
9071            self.fs.write_atomic(FileId::Snapshot, &buf)?;
9072            // Remap the freshly-written snapshot as the new base.
9073            // C2: use file mmap on RealFs; fall back to from_bytes on SimFs.
9074            let new_base = if let Some(snap_path) = self.fs.snapshot_path() {
9075                core_storage::v8::MappedBase::map(&snap_path)
9076            } else {
9077                core_storage::v8::MappedBase::from_bytes(buf)
9078            }
9079            .map_err(|e| GraphError::Corrupt {
9080                detail: format!("v8 snapshot: remap new base: {e:?}"),
9081            })?;
9082            self.base = Some(Arc::new(new_base));
9083            // Clear the overlay and prop tombstones — all data is now in the new base.
9084            self.topo = Topology::new();
9085            self.props = core_storage::columns::ColumnStore::new();
9086        } else {
9087            // Legacy path (V5–V7 stores without a V8 base).
9088            //
9089            // Memory-diet path: build V8Meta directly from &self — no SnapshotState
9090            // clone and no encode_v8_from_state intermediate clones.  The big
9091            // structures (self.topo, self.props) are borrowed, not cloned.
9092            // self.edge_props is moved (not cloned) because we immediately clear it
9093            // when we remap the new V8 snapshot as self.base (see below).
9094            //
9095            // Eliminates from peak RSS vs. the old SnapshotState path:
9096            //   • self.topo.clone()      (~topology HashMap footprint)
9097            //   • self.props.clone()     (~column-store footprint)
9098            //   • encode_v8_from_state V8Meta secondary clones (labels, edge_props, …)
9099            let meta = V8Meta {
9100                labels: self.labels.clone(),
9101                wal_truncated: !opts.keep_wal,
9102                // Move edge_props out so the large overlay is freed when meta
9103                // drops at end of this block (self.edge_props is now empty; reads
9104                // after base assignment go through the mmap'd base section).
9105                edge_props: std::mem::take(&mut self.edge_props),
9106                rule_defs,
9107                provenance,
9108                rule_tripped,
9109                rule_fires,
9110                ivf_bytes,
9111                view_defs,
9112                hnsw: hnsw_state,
9113                last_change: self.last_change.clone(),
9114            };
9115            let mut buf = Vec::new();
9116            encode_v8(
9117                None,
9118                None,
9119                None,
9120                None,
9121                &self.topo,
9122                &self.props,
9123                &self.ids,
9124                &self.syms,
9125                &meta,
9126                &mut buf,
9127            )?;
9128            // meta (and the moved edge_props inside it) is no longer needed;
9129            // drop it before the write to keep the peak window narrow.
9130            drop(meta);
9131            self.fs.write_atomic(FileId::Snapshot, &buf)?;
9132            // Remap the freshly-written V8 snapshot as self.base.
9133            // On RealFs: drop the encode buffer before mmap to recover ~1.9 GiB.
9134            // On SimFs (tests): pass buf to from_bytes.
9135            let new_base = if let Some(snap_path) = self.fs.snapshot_path() {
9136                drop(buf);
9137                core_storage::v8::MappedBase::map(&snap_path)
9138            } else {
9139                core_storage::v8::MappedBase::from_bytes(buf)
9140            }
9141            .map_err(|e| GraphError::Corrupt {
9142                detail: format!("v8 snapshot: remap new base (legacy path): {e:?}"),
9143            })?;
9144            self.base = Some(Arc::new(new_base));
9145            // Free the large heap-allocated decoded state — all data is now in the
9146            // mmap'd base.  Mirrors the V8 merge-snapshot path (see above).
9147            // self.edge_props was already moved into meta and is effectively empty.
9148            self.topo = Topology::new();
9149            self.props = core_storage::columns::ColumnStore::new();
9150        }
9151
9152        if opts.archive_wal {
9153            // History-preserving snapshot (Task 4):
9154            //   1. Snapshot already written above (write_atomic → fsynced).
9155            //   2. Rename WAL → wal.<commit_seq>.archive  (atomic, same fs).
9156            //      Crash window B: crash here leaves archive present, WAL
9157            //      absent.  Reopen: snapshot loaded (full state), no WAL
9158            //      replay.  Archive is NOT replayed into live state — it is
9159            //      pre-snapshot by construction.  Safe.
9160            //   3. Optionally write genesis marker (first archive only, no
9161            //      prior WAL truncation).
9162            //   4. Prune old archives (retention), update horizon floor.
9163            //      Pruning invalidates the genesis chain; delete marker.
9164            //   5. Write new minimal baseline WAL (write_atomic).
9165            //      Crash window C: crash here leaves new archive plus no live
9166            //      WAL.  Same as window B — handled above.
9167            //
9168            // Sample existing archives BEFORE the rename so we can detect
9169            // whether this is the first archive.
9170            let existing_archives = self.fs.list_archives()?;
9171            let is_first_archive = existing_archives.is_empty();
9172
9173            // Compute a globally-monotonic archive name: the name equals the
9174            // cumulative end-frame index of the archive in global commit space.
9175            //
9176            // Using `commit_seq` directly is UNSOUND across sessions: on reopen
9177            // commit_seq is seeded from max(last_change), which underestimates
9178            // the WAL depth when trailing commits (e.g. insert_edge) do not
9179            // update last_change.  A session-2 archive could then receive a name
9180            // ≤ the session-1 archive, causing incorrect sort order or collision.
9181            //
9182            // Instead: read and decode the live WAL here (before the rename) to
9183            // get its exact frame count, then add it to the last known global
9184            // end-frame index (the name of the most recent existing archive, or
9185            // wal_horizon_floor if no archives exist).  This is O(WAL size) but
9186            // snapshot is already serialising the full graph state, so the cost
9187            // is dominated.
9188            let live_wal_bytes_for_name = self.fs.read(FileId::Wal)?;
9189            let (live_frames_for_name, _) = decode_all(&live_wal_bytes_for_name);
9190            let archive_n = existing_archives
9191                .last()
9192                .copied()
9193                .unwrap_or(self.wal_horizon_floor)
9194                + live_frames_for_name.len() as u64;
9195            self.fs.archive_wal(archive_n)?;
9196
9197            // Genesis marker: written once when the first archive is taken
9198            // from a store that has never undergone a WAL-truncating snapshot.
9199            // When present, `open_at` may replay archive-resident commits from
9200            // empty state (the archive chain covers from global index 0).
9201            //
9202            // Two conditions must ALL hold:
9203            //   1. This is the first archive (existing_archives was empty).
9204            //   2. No snapshot.bin existed before this operation (had_prior_snapshot=false).
9205            //      A WAL-truncating snapshot (keep_wal=false) always writes snapshot.bin
9206            //      before truncating the WAL, so if any prior truncating snapshot was taken
9207            //      — even in a previous session — snapshot.bin is present and this condition
9208            //      is false.  This subsumes the cross-session truncation case without
9209            //      requiring a separate wal.truncated sidecar file.
9210            //      For legacy stores (snapshot.bin written by an older code version that
9211            //      may have truncated the WAL), the same conservative refusal applies:
9212            //      we cannot prove the chain is complete, so we refuse genesis (cost =
9213            //      no as-of-through-archives; never silent wrong data).
9214            //      On SimFs (snapshot_path() == None) had_prior_snapshot is always false,
9215            //      so SimFs always passes this check.
9216            if is_first_archive && !had_prior_snapshot {
9217                self.fs.write_genesis_marker()?;
9218                self.archive_genesis_chain = true;
9219            }
9220
9221            // Retention pruning: keep newest `keep` archives; delete oldest.
9222            // Pruning is the ONLY deletion site for archives.
9223            //
9224            // Crash-safety ordering (C1 fix):
9225            //   1. Count frames in surplus archives (reads only — no mutation).
9226            //   2. Advance and PERSIST the horizon floor FIRST via write-then-
9227            //      rename (atomic).  A crash after this point leaves orphaned
9228            //      archives on disk, but the floor is correct.  The opening
9229            //      cleanup sweep (`cleanup_orphaned_archives`) removes them on
9230            //      the next open, so the store is always safe to reopen.
9231            //   3. Delete the genesis marker (floor > 0 already blocks open_at
9232            //      via the conjunctive gate; marker cleanup is belt-and-suspenders).
9233            //   4. Delete surplus archives.  A crash between any two deletes
9234            //      leaves the floor committed and orphaned archives cleaned at
9235            //      next open — never a stale floor with a missing archive prefix.
9236            if let Some(keep) = self.wal_archive_retention {
9237                if keep > 0 {
9238                    let archives = self.fs.list_archives()?;
9239                    // archives is sorted ascending (oldest first)
9240                    if archives.len() as u32 > keep {
9241                        let surplus = archives.len() - keep as usize;
9242                        // Step 1: count pruned frames (reads, no mutation).
9243                        let mut pruned_frames = 0u64;
9244                        for &n in &archives[..surplus] {
9245                            let bytes = self.fs.read_archive(n)?;
9246                            let (frames, _) = decode_all(&bytes);
9247                            pruned_frames += frames.len() as u64;
9248                        }
9249                        // Step 2: advance and persist floor FIRST.
9250                        self.wal_horizon_floor += pruned_frames;
9251                        self.fs.write_horizon_floor(self.wal_horizon_floor)?;
9252                        // Step 3: delete genesis marker (floor > 0 already
9253                        // blocks open_at; this is belt-and-suspenders cleanup).
9254                        if pruned_frames > 0 && self.archive_genesis_chain {
9255                            self.fs.delete_genesis_marker()?;
9256                            self.archive_genesis_chain = false;
9257                        }
9258                        // Step 4: delete surplus archives.  Crash here →
9259                        // orphaned archives; cleaned at next open.
9260                        for &n in &archives[..surplus] {
9261                            self.fs.delete_archive(n)?;
9262                        }
9263                    }
9264                }
9265            }
9266
9267            // Write new minimal baseline WAL (mirrors the keep_wal=false path).
9268            let mut baseline_wal: Vec<u8> = Vec::new();
9269            for (label, field) in self.fulltext.enabled_pairs() {
9270                let rec = WalRecord::EnableFulltext {
9271                    label: label.clone(),
9272                    field: field.clone(),
9273                };
9274                baseline_wal.extend_from_slice(&encode_record(&rec));
9275            }
9276            for (label, field) in self.prop_index.enabled_pairs() {
9277                let rec = WalRecord::EnableIndex {
9278                    label: label.clone(),
9279                    field: field.clone(),
9280                };
9281                baseline_wal.extend_from_slice(&encode_record(&rec));
9282            }
9283            self.fs.write_atomic(FileId::Wal, &baseline_wal)?;
9284        } else if opts.keep_wal {
9285            // keep_wal=true: WAL is left untouched.  The existing WAL already
9286            // contains the EnableFulltext records from the original enable calls;
9287            // replay is idempotent (guards in apply() skip already-live entries).
9288            // No baseline re-write is needed or safe here — the full WAL history
9289            // must remain intact for open_at to reach pre-snapshot commits.
9290        } else {
9291            // keep_wal=false (default): truncate by replacing the WAL with a
9292            // minimal baseline of one EnableFulltext record per active pair.
9293            //
9294            // Crash-ordering: write_atomic is atomic.
9295            //   • Crash before snapshot write  → WAL unchanged.  Safe.
9296            //   • Crash after snapshot write but before this WAL write → full
9297            //     pre-snapshot WAL still present; open_with replays idempotently.
9298            //   • Crash after both writes → normal post-snapshot state.
9299            //
9300            // Genesis chain: a WAL-truncating snapshot breaks the archive chain
9301            // for any archives taken AFTER this point (their WAL slices would
9302            // not start at genesis).  Delete any existing genesis marker so that
9303            // open_at refuses archive-resident commits.  Future sessions are
9304            // covered by had_prior_snapshot: snapshot.bin written here persists
9305            // across sessions and prevents a later archiving session from
9306            // incorrectly claiming a complete genesis chain.
9307            if self.archive_genesis_chain {
9308                self.fs.delete_genesis_marker()?;
9309                self.archive_genesis_chain = false;
9310            }
9311            let mut baseline_wal: Vec<u8> = Vec::new();
9312            for (label, field) in self.fulltext.enabled_pairs() {
9313                let rec = WalRecord::EnableFulltext {
9314                    label: label.clone(),
9315                    field: field.clone(),
9316                };
9317                baseline_wal.extend_from_slice(&encode_record(&rec));
9318            }
9319            for (label, field) in self.prop_index.enabled_pairs() {
9320                let rec = WalRecord::EnableIndex {
9321                    label: label.clone(),
9322                    field: field.clone(),
9323                };
9324                baseline_wal.extend_from_slice(&encode_record(&rec));
9325            }
9326            self.fs.write_atomic(FileId::Wal, &baseline_wal)?;
9327        }
9328        // After snapshot the overlay may have changed (V8 merge path clears
9329        // self.topo and self.props). Refresh the MVCC fold so future readers
9330        // see the post-snapshot state rather than stale overlay data.
9331        self.fold_now();
9332        Ok(())
9333    }
9334}
9335
9336/// Queued mutation for a [`BatchBuilder`] or [`GraphDb::commit_group`].
9337///
9338/// The `submit_batch` / `commit_group` APIs accept `Vec<BatchOp>` so that
9339/// callers can build a set of mutations without holding `&mut GraphDb` and
9340/// hand them off to the group-committing writer for durable, batched I/O.
9341pub enum BatchOp {
9342    InsertNode {
9343        label: String,
9344        key: String,
9345        props: Vec<(String, Value)>,
9346    },
9347    InsertEdge {
9348        edge_type: String,
9349        src_key: String,
9350        dst_key: String,
9351    },
9352    SetProp {
9353        key: String,
9354        field: String,
9355        value: Value,
9356    },
9357    RemoveProp {
9358        key: String,
9359        field: String,
9360    },
9361    DeleteEdge {
9362        edge_type: String,
9363        src_key: String,
9364        dst_key: String,
9365    },
9366    DeleteNode {
9367        key: String,
9368    },
9369    CreateRule(RuleDef),
9370    DeleteRule {
9371        name: String,
9372    },
9373    /// Rename a node's key. Validated: old must exist, new must not.
9374    RenameNode {
9375        old_key: String,
9376        new_key: String,
9377    },
9378    /// Insert an edge, auto-creating any missing endpoint as a plain node with
9379    /// `placeholder_label` and no props. Rules fire and last-change is updated
9380    /// for each created endpoint (normal InsertNode semantics in the batch frame).
9381    InsertEdgeUpsert {
9382        edge_type: String,
9383        src_key: String,
9384        dst_key: String,
9385        placeholder_label: String,
9386    },
9387}
9388
9389/// Three-way node visibility status used by `check_single_op_authz`.
9390enum NodeAuthzStatus {
9391    /// Node exists in the store and is in the role's read mask.
9392    Visible(String), // carries the node's label
9393    /// Node exists in the store but is NOT in the role's read mask.
9394    Hidden,
9395    /// Node does not exist in the store.
9396    Absent,
9397}
9398
9399/// Overlay of ops already accepted earlier in the same batch. Never written
9400/// back to the database — validation only.
9401#[derive(Default)]
9402struct Overlay {
9403    extra_keys: BTreeSet<String>,
9404    deleted_keys: BTreeSet<String>,
9405    extra_props: BTreeMap<(String, String), Value>,
9406    removed_props: BTreeSet<(String, String)>,
9407    extra_edges: BTreeSet<(String, String, String)>,
9408    deleted_edges: BTreeSet<(String, String, String)>,
9409    extra_rules: BTreeSet<String>,
9410    deleted_rules: BTreeSet<String>,
9411    /// `rule name → (via_edge, edge_type)` for every via-hop rule accepted
9412    /// earlier in this batch. Feeds the rule-chain cycle check, which otherwise
9413    /// sees only the rules already committed to the engine. Keyed by name so a
9414    /// later `DeleteRule` in the same batch drops the arc with the rule.
9415    extra_rule_arcs: BTreeMap<String, (String, String)>,
9416}
9417
9418/// Read-only view of live db state plus a batch overlay. Shared by single-op
9419/// public methods (empty overlay) and `commit_batch`.
9420struct MutPreview<'a, F: Fs> {
9421    db: &'a GraphDb<F>,
9422    overlay: Overlay,
9423}
9424
9425/// Shortest path from `start` to `target` following `arcs` (`from → to`), or
9426/// `None` if `target` is unreachable.
9427///
9428/// Used for rule-chain cycle detection, where an arc is "a rule hops over
9429/// `from` and writes `to`". Breadth-first over BTree-ordered adjacency, so the
9430/// reported path is stable for a given rule set, and iterative so a pathological
9431/// rule graph cannot overflow the stack.
9432fn find_cycle_through(arcs: &[(String, String)], start: &str, target: &str) -> Option<Vec<String>> {
9433    let mut adj: BTreeMap<&str, BTreeSet<&str>> = BTreeMap::new();
9434    for (from, to) in arcs {
9435        adj.entry(from.as_str()).or_default().insert(to.as_str());
9436    }
9437    let mut parent: BTreeMap<&str, &str> = BTreeMap::new();
9438    let mut visited: BTreeSet<&str> = BTreeSet::new();
9439    let mut queue: std::collections::VecDeque<&str> = std::collections::VecDeque::new();
9440    visited.insert(start);
9441    queue.push_back(start);
9442    while let Some(node) = queue.pop_front() {
9443        if node == target {
9444            let mut path = vec![node.to_string()];
9445            let mut cur = node;
9446            while let Some(&p) = parent.get(cur) {
9447                path.push(p.to_string());
9448                cur = p;
9449            }
9450            path.reverse();
9451            return Some(path);
9452        }
9453        for &next in adj.get(node).into_iter().flatten() {
9454            if visited.insert(next) {
9455                parent.insert(next, node);
9456                queue.push_back(next);
9457            }
9458        }
9459    }
9460    None
9461}
9462
9463impl<'a, F: Fs> MutPreview<'a, F> {
9464    fn new(db: &'a GraphDb<F>) -> Self {
9465        Self {
9466            db,
9467            overlay: Overlay::default(),
9468        }
9469    }
9470
9471    fn has_key(&self, key: &str) -> bool {
9472        if self.overlay.extra_keys.contains(key) {
9473            return true;
9474        }
9475        if self.overlay.deleted_keys.contains(key) {
9476            return false;
9477        }
9478        self.db.ids.get(key).is_some()
9479    }
9480
9481    fn has_prop(&self, key: &str, field: &str) -> bool {
9482        if !self.has_key(key) {
9483            return false;
9484        }
9485        let k = (key.to_string(), field.to_string());
9486        if self.overlay.removed_props.contains(&k) {
9487            return false;
9488        }
9489        if self.overlay.extra_props.contains_key(&k) {
9490            return true;
9491        }
9492        // Fresh identity (first insert in this batch, or delete+reinsert):
9493        // ignore props still sitting on the soon-to-be-tombstoned slot.
9494        if self.overlay.extra_keys.contains(key) {
9495            return false;
9496        }
9497        self.db.get_prop(key, field).is_some()
9498    }
9499
9500    fn has_edge(&self, edge_type: &str, src_key: &str, dst_key: &str) -> bool {
9501        let k = (
9502            edge_type.to_string(),
9503            src_key.to_string(),
9504            dst_key.to_string(),
9505        );
9506        if self.overlay.deleted_edges.contains(&k) {
9507            return false;
9508        }
9509        if self.overlay.extra_edges.contains(&k) {
9510            return true;
9511        }
9512        // A key created in this batch (including reinsert) has no db edges.
9513        if self.overlay.extra_keys.contains(src_key) || self.overlay.extra_keys.contains(dst_key) {
9514            return false;
9515        }
9516        if self.overlay.deleted_keys.contains(src_key)
9517            || self.overlay.deleted_keys.contains(dst_key)
9518        {
9519            return false;
9520        }
9521        let Some(src) = self.db.ids.get(src_key) else {
9522            return false;
9523        };
9524        let Some(dst) = self.db.ids.get(dst_key) else {
9525            return false;
9526        };
9527        let Some(sym) = self.db.syms.get(edge_type) else {
9528            return false;
9529        };
9530        self.db
9531            .topo_view()
9532            .neighbors(sym, Direction::Out, src)
9533            .binary_search(&dst)
9534            .is_ok()
9535    }
9536
9537    fn has_rule(&self, name: &str) -> bool {
9538        if self.overlay.extra_rules.contains(name) {
9539            return true;
9540        }
9541        if self.overlay.deleted_rules.contains(name) {
9542            return false;
9543        }
9544        self.db.engine.rules().any(|r| r.name == name)
9545    }
9546
9547    fn is_rule_owned(&self, edge_type: &str, src_key: &str, dst_key: &str) -> bool {
9548        if self.overlay.extra_keys.contains(src_key) || self.overlay.extra_keys.contains(dst_key) {
9549            return false;
9550        }
9551        if self.overlay.deleted_keys.contains(src_key)
9552            || self.overlay.deleted_keys.contains(dst_key)
9553        {
9554            return false;
9555        }
9556        let Some(src) = self.db.ids.get(src_key) else {
9557            return false;
9558        };
9559        let Some(dst) = self.db.ids.get(dst_key) else {
9560            return false;
9561        };
9562        let Some(et) = self.db.syms.get(edge_type) else {
9563            return false;
9564        };
9565        // extra_rules is deliberately not consulted: a CreateRule earlier in
9566        // this batch has not fired, so it contributes no provenance. That is
9567        // the documented rule-window gap (see GraphDb::batch).
9568        if self.overlay.deleted_rules.is_empty() {
9569            return self.db.engine.is_owned(et, src, dst);
9570        }
9571        for (rule, triples) in self.db.engine.provenance() {
9572            if self.overlay.deleted_rules.contains(rule) {
9573                continue;
9574            }
9575            if triples.contains(&(et, src, dst)) {
9576                return true;
9577            }
9578        }
9579        false
9580    }
9581
9582    fn check_insert_node(&self, key: &str) -> Result<()> {
9583        if self.has_key(key) {
9584            Err(GraphError::DuplicateKey { key: key.into() })
9585        } else {
9586            Ok(())
9587        }
9588    }
9589
9590    fn check_live_key(&self, key: &str) -> Result<()> {
9591        if self.has_key(key) {
9592            Ok(())
9593        } else {
9594            Err(GraphError::KeyNotFound { key: key.into() })
9595        }
9596    }
9597
9598    fn prepare_insert_edge(&self, edge_type: &str, src_key: &str, dst_key: &str) -> Result<bool> {
9599        for k in [src_key, dst_key] {
9600            if !self.has_key(k) {
9601                return Err(GraphError::KeyNotFound { key: k.into() });
9602            }
9603        }
9604        if self.is_rule_owned(edge_type, src_key, dst_key) {
9605            return Err(GraphError::RuleOwned {
9606                detail: format!("edge {edge_type} {src_key}→{dst_key} is rule-owned"),
9607            });
9608        }
9609        Ok(!self.has_edge(edge_type, src_key, dst_key))
9610    }
9611
9612    fn prepare_remove_prop(&self, key: &str, field: &str) -> Result<bool> {
9613        self.check_live_key(key)?;
9614        Ok(self.has_prop(key, field))
9615    }
9616
9617    fn prepare_delete_edge(&self, edge_type: &str, src_key: &str, dst_key: &str) -> Result<bool> {
9618        for k in [src_key, dst_key] {
9619            if !self.has_key(k) {
9620                return Err(GraphError::KeyNotFound { key: k.into() });
9621            }
9622        }
9623        // Provenance-owned OR a live rule would derive this pair. User-first
9624        // edges that a later rule matches are not in `owned`, but deleting
9625        // them would leave a hole `rebuild_rule` immediately fills.
9626        if self.is_rule_owned(edge_type, src_key, dst_key) {
9627            return Err(GraphError::RuleOwned {
9628                detail: format!(
9629                    "edge {edge_type} {src_key}→{dst_key} is rule-owned; \
9630                     delete or change the owning rule"
9631                ),
9632            });
9633        }
9634        if self.would_derive(edge_type, src_key, dst_key) {
9635            return Err(GraphError::RuleOwned {
9636                detail: format!(
9637                    "edge {edge_type} {src_key}→{dst_key} is rule-owned; \
9638                     delete or change the owning rule, or a live rule would re-derive it"
9639                ),
9640            });
9641        }
9642        Ok(self.has_edge(edge_type, src_key, dst_key))
9643    }
9644
9645    /// True if any live rule (minus overlay-deleted names) would derive
9646    /// `(edge_type, src, dst)` from current overlay-visible props/labels.
9647    /// CreateRule names in `extra_rules` are ignored — same documented
9648    /// same-batch rule-window as [`Self::is_rule_owned`].
9649    fn would_derive(&self, edge_type: &str, src_key: &str, dst_key: &str) -> bool {
9650        if src_key == dst_key {
9651            return false;
9652        }
9653        let Some(src_label) = self.label_of(src_key) else {
9654            return false;
9655        };
9656        let Some(dst_label) = self.label_of(dst_key) else {
9657            return false;
9658        };
9659        for rule in self.db.engine.rules() {
9660            if self.overlay.deleted_rules.contains(&rule.name) {
9661                continue;
9662            }
9663            if rule.edge_type != edge_type {
9664                continue;
9665            }
9666            if rule.src_label != src_label || rule.dst_label != dst_label {
9667                continue;
9668            }
9669            let src_props = |f: &str| self.prop_value(src_key, f);
9670            let dst_props = |f: &str| self.prop_value(dst_key, f);
9671            let src_view = NodeView {
9672                key: src_key,
9673                props: &src_props,
9674            };
9675            let dst_view = NodeView {
9676                key: dst_key,
9677                props: &dst_props,
9678            };
9679            if evaluate(&rule.predicate, &src_view, &dst_view).is_some() {
9680                return true;
9681            }
9682        }
9683        false
9684    }
9685
9686    fn label_of(&self, key: &str) -> Option<String> {
9687        if self.overlay.deleted_keys.contains(key) {
9688            return None;
9689        }
9690        // Fresh identities created in this batch have no stored label in the
9691        // overlay; they cannot be provenance-owned yet either.
9692        let id = self.db.ids.get(key)?;
9693        let sym = self.db.labels.get(id as usize).copied()?;
9694        if sym == u32::MAX {
9695            return None;
9696        }
9697        self.db.syms.resolve(sym).map(str::to_string)
9698    }
9699
9700    fn prop_value(&self, key: &str, field: &str) -> Option<Value> {
9701        if !self.has_key(key) {
9702            return None;
9703        }
9704        let k = (key.to_string(), field.to_string());
9705        if self.overlay.removed_props.contains(&k) {
9706            return None;
9707        }
9708        if let Some(v) = self.overlay.extra_props.get(&k) {
9709            return Some(v.clone());
9710        }
9711        if self.overlay.extra_keys.contains(key) {
9712            return None;
9713        }
9714        self.db.get_prop(key, field)
9715    }
9716
9717    fn check_create_rule(&self, def: &RuleDef) -> Result<()> {
9718        def.validate()
9719            .map_err(|e| GraphError::RuleInvalid { detail: e })?;
9720        if self.has_rule(&def.name) {
9721            return Err(GraphError::RuleInvalid {
9722                detail: format!("rule {:?} already exists", def.name),
9723            });
9724        }
9725        // Rule-chain cycle rejection. Derived edges feed via-hop rules, so a
9726        // rule set forms a graph whose arcs are "hops over `via_edge`, writes
9727        // `edge_type`". A cycle in that graph is a rule set that would re-fire
9728        // itself forever; the engine's depth cap would silently truncate it
9729        // instead, leaving an arbitrary partial result. Reject it here, the one
9730        // place that sees the whole rule set.
9731        //
9732        // Rules accepted earlier in the same batch count too: the overlay
9733        // carries their arcs, so a cycle cannot be assembled one op at a time.
9734        if let Some(via) = def.via_edge.as_deref() {
9735            if via == def.edge_type {
9736                return Err(GraphError::RuleInvalid {
9737                    detail: format!("rule chain cycle: {} -> {}", via, def.edge_type),
9738                });
9739            }
9740            let mut arcs: Vec<(String, String)> = self
9741                .db
9742                .engine
9743                .rules()
9744                .filter(|r| !self.overlay.deleted_rules.contains(&r.name))
9745                .filter_map(|r| r.via_edge.clone().map(|v| (v, r.edge_type.clone())))
9746                .collect();
9747            arcs.extend(self.overlay.extra_rule_arcs.values().cloned());
9748            arcs.push((via.to_string(), def.edge_type.clone()));
9749            if let Some(path) = find_cycle_through(&arcs, &def.edge_type, via) {
9750                return Err(GraphError::RuleInvalid {
9751                    detail: format!("rule chain cycle: {} -> {}", via, path.join(" -> ")),
9752                });
9753            }
9754        }
9755        Ok(())
9756    }
9757
9758    fn check_delete_rule(&self, name: &str) -> Result<()> {
9759        if self.has_rule(name) {
9760            Ok(())
9761        } else {
9762            Err(GraphError::RuleNotFound { name: name.into() })
9763        }
9764    }
9765
9766    fn note_insert_node(&mut self, key: &str, props: &[(String, Value)]) {
9767        self.overlay.deleted_keys.remove(key);
9768        self.overlay.extra_keys.insert(key.to_string());
9769        self.overlay.extra_props.retain(|(k, _), _| k != key);
9770        self.overlay.removed_props.retain(|(k, _)| k != key);
9771        for (field, value) in props {
9772            self.overlay
9773                .extra_props
9774                .insert((key.to_string(), field.clone()), value.clone());
9775        }
9776    }
9777
9778    fn note_insert_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) {
9779        let k = (
9780            edge_type.to_string(),
9781            src_key.to_string(),
9782            dst_key.to_string(),
9783        );
9784        self.overlay.deleted_edges.remove(&k);
9785        self.overlay.extra_edges.insert(k);
9786    }
9787
9788    fn note_set_prop(&mut self, key: &str, field: &str, value: &Value) {
9789        let k = (key.to_string(), field.to_string());
9790        self.overlay.removed_props.remove(&k);
9791        self.overlay.extra_props.insert(k, value.clone());
9792    }
9793
9794    fn note_remove_prop(&mut self, key: &str, field: &str) {
9795        let k = (key.to_string(), field.to_string());
9796        self.overlay.extra_props.remove(&k);
9797        self.overlay.removed_props.insert(k);
9798    }
9799
9800    fn note_delete_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) {
9801        let k = (
9802            edge_type.to_string(),
9803            src_key.to_string(),
9804            dst_key.to_string(),
9805        );
9806        self.overlay.extra_edges.remove(&k);
9807        self.overlay.deleted_edges.insert(k);
9808    }
9809
9810    fn note_delete_node(&mut self, key: &str) {
9811        self.overlay.extra_keys.remove(key);
9812        self.overlay.deleted_keys.insert(key.to_string());
9813        self.overlay.extra_props.retain(|(k, _), _| k != key);
9814        self.overlay.removed_props.retain(|(k, _)| k != key);
9815        self.overlay
9816            .extra_edges
9817            .retain(|(_, s, d)| s != key && d != key);
9818        self.overlay
9819            .deleted_edges
9820            .retain(|(_, s, d)| s != key && d != key);
9821    }
9822
9823    fn note_create_rule(&mut self, def: &RuleDef) {
9824        self.overlay.deleted_rules.remove(&def.name);
9825        self.overlay.extra_rules.insert(def.name.clone());
9826        // Rules accepted earlier in this batch are not in the engine yet, so
9827        // the cycle check would not see their arcs. Keep the arc, not just the
9828        // name, so a batch cannot smuggle in a cycle one op at a time.
9829        if let Some(via) = def.via_edge.clone() {
9830            self.overlay
9831                .extra_rule_arcs
9832                .insert(def.name.clone(), (via, def.edge_type.clone()));
9833        }
9834    }
9835
9836    fn check_rename_node(&self, old: &str, new: &str) -> Result<()> {
9837        if !self.has_key(old) {
9838            return Err(GraphError::KeyNotFound { key: old.into() });
9839        }
9840        if self.has_key(new) {
9841            return Err(GraphError::DuplicateKey { key: new.into() });
9842        }
9843        Ok(())
9844    }
9845
9846    fn note_rename_node(&mut self, old: &str, new: &str) {
9847        // Mark old as deleted so subsequent batch ops cannot reference it.
9848        self.overlay.extra_keys.remove(old);
9849        self.overlay.deleted_keys.insert(old.to_string());
9850        // Mark new as extra so subsequent batch ops can reference it.
9851        self.overlay.deleted_keys.remove(new);
9852        self.overlay.extra_keys.insert(new.to_string());
9853        // Migrate any overlay props from old key to new key.
9854        let new_str = new.to_string();
9855        let transferred: Vec<((String, String), Value)> = self
9856            .overlay
9857            .extra_props
9858            .iter()
9859            .filter(|((k, _), _)| k.as_str() == old)
9860            .map(|((_, f), v)| ((new_str.clone(), f.clone()), v.clone()))
9861            .collect();
9862        self.overlay
9863            .extra_props
9864            .retain(|(k, _), _| k.as_str() != old);
9865        for (k, v) in transferred {
9866            self.overlay.extra_props.insert(k, v);
9867        }
9868        // Migrate removed_props.
9869        let transferred_removed: Vec<(String, String)> = self
9870            .overlay
9871            .removed_props
9872            .iter()
9873            .filter(|(k, _)| k.as_str() == old)
9874            .map(|(_, f)| (new_str.clone(), f.clone()))
9875            .collect();
9876        self.overlay
9877            .removed_props
9878            .retain(|(k, _)| k.as_str() != old);
9879        for k in transferred_removed {
9880            self.overlay.removed_props.insert(k);
9881        }
9882    }
9883
9884    fn note_delete_rule(&mut self, name: &str) {
9885        self.overlay.extra_rules.remove(name);
9886        // Drop its chain arc too: a rule created and then deleted in the same
9887        // batch must not make a later, legal rule look like a cycle.
9888        self.overlay.extra_rule_arcs.remove(name);
9889        self.overlay.deleted_rules.insert(name.to_string());
9890        // Treat the deleted rule's current provenance as gone so a later
9891        // delete_edge of those triples is a no-op (matches sequential).
9892        if let Some(triples) = self.db.engine.provenance().get(name) {
9893            for &(et, s, d) in triples {
9894                let Some(etype) = self.db.syms.resolve(et) else {
9895                    continue;
9896                };
9897                let Some(src) = self.db.ids.key_of(s) else {
9898                    continue;
9899                };
9900                let Some(dst) = self.db.ids.key_of(d) else {
9901                    continue;
9902                };
9903                let k = (etype.to_string(), src.to_string(), dst.to_string());
9904                self.overlay.extra_edges.remove(&k);
9905                self.overlay.deleted_edges.insert(k);
9906            }
9907        }
9908    }
9909}
9910
9911/// Collects mutations and commits them as one WAL `Batch` frame.
9912///
9913/// Holds `&mut GraphDb` for its lifetime. Queue with the same method names
9914/// as [`GraphDb`]; call [`commit`](Self::commit) to validate, log, and apply.
9915/// See [`GraphDb::batch`] for validation and atomicity rules.
9916pub struct BatchBuilder<'a, F: Fs> {
9917    db: &'a mut GraphDb<F>,
9918    ops: Vec<BatchOp>,
9919}
9920
9921impl<'a, F: Fs> BatchBuilder<'a, F> {
9922    pub fn insert_node(
9923        &mut self,
9924        label: &str,
9925        key: &str,
9926        props: Vec<(String, Value)>,
9927    ) -> &mut Self {
9928        self.ops.push(BatchOp::InsertNode {
9929            label: label.into(),
9930            key: key.into(),
9931            props,
9932        });
9933        self
9934    }
9935
9936    pub fn insert_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) -> &mut Self {
9937        self.ops.push(BatchOp::InsertEdge {
9938            edge_type: edge_type.into(),
9939            src_key: src_key.into(),
9940            dst_key: dst_key.into(),
9941        });
9942        self
9943    }
9944
9945    pub fn set_prop(&mut self, key: &str, field: &str, value: Value) -> &mut Self {
9946        self.ops.push(BatchOp::SetProp {
9947            key: key.into(),
9948            field: field.into(),
9949            value,
9950        });
9951        self
9952    }
9953
9954    pub fn remove_prop(&mut self, key: &str, field: &str) -> &mut Self {
9955        self.ops.push(BatchOp::RemoveProp {
9956            key: key.into(),
9957            field: field.into(),
9958        });
9959        self
9960    }
9961
9962    pub fn delete_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) -> &mut Self {
9963        self.ops.push(BatchOp::DeleteEdge {
9964            edge_type: edge_type.into(),
9965            src_key: src_key.into(),
9966            dst_key: dst_key.into(),
9967        });
9968        self
9969    }
9970
9971    pub fn delete_node(&mut self, key: &str) -> &mut Self {
9972        self.ops.push(BatchOp::DeleteNode { key: key.into() });
9973        self
9974    }
9975
9976    pub fn create_rule(&mut self, def: RuleDef) -> &mut Self {
9977        self.ops.push(BatchOp::CreateRule(def));
9978        self
9979    }
9980
9981    pub fn delete_rule(&mut self, name: &str) -> &mut Self {
9982        self.ops.push(BatchOp::DeleteRule { name: name.into() });
9983        self
9984    }
9985
9986    /// Queue a node-rename in this batch.
9987    ///
9988    /// Validation (old exists, new not taken) runs at commit time.
9989    pub fn rename_node(&mut self, old_key: &str, new_key: &str) -> &mut Self {
9990        self.ops.push(BatchOp::RenameNode {
9991            old_key: old_key.into(),
9992            new_key: new_key.into(),
9993        });
9994        self
9995    }
9996
9997    /// Queue an edge insert with endpoint auto-creation.
9998    ///
9999    /// Any missing endpoint is created as a plain node `{key, label:
10000    /// placeholder_label, no props}` inside this batch frame. Rules fire and
10001    /// last-change is updated for each auto-created node.
10002    pub fn insert_edge_upsert(
10003        &mut self,
10004        edge_type: &str,
10005        src_key: &str,
10006        dst_key: &str,
10007        placeholder_label: &str,
10008    ) -> &mut Self {
10009        self.ops.push(BatchOp::InsertEdgeUpsert {
10010            edge_type: edge_type.into(),
10011            src_key: src_key.into(),
10012            dst_key: dst_key.into(),
10013            placeholder_label: placeholder_label.into(),
10014        });
10015        self
10016    }
10017
10018    /// Validate every queued op, then log one `Batch` frame and apply.
10019    /// Empty / all-noop batches return `Ok(())` without writing the WAL.
10020    /// A second `commit()` after a successful one is an empty-batch no-op
10021    /// (queued ops were taken).
10022    /// Takes `&mut self` so it chains after the queue methods (`b.insert_node(..).commit()`)
10023    /// and also works as `let mut b = db.batch(); b.insert_node(..); b.commit()`.
10024    ///
10025    /// **Rule-window limitation:** batch validation cannot see edges that a
10026    /// rule created earlier in the *same* batch will derive at apply time, so
10027    /// a `delete_edge` / `insert_edge` in that window is silently no-oped
10028    /// where sequential calls would return `Err(RuleOwned)`. State integrity
10029    /// is unaffected (idempotent apply, provenance intact). Create rules in
10030    /// their own batch, or sequentially, when later ops may touch derived
10031    /// edges.
10032    /// Validate every queued op and commit atomically.
10033    ///
10034    /// Returns `(nodes_inserted, edges_inserted)` — the counts of node and edge
10035    /// WAL records actually written (duplicate edges are silent no-ops and are
10036    /// NOT counted). Both are 0 when the batch is empty or all-noop.
10037    pub fn commit(&mut self) -> Result<(usize, usize)> {
10038        let ops = std::mem::take(&mut self.ops);
10039        self.db.commit_batch(ops)
10040    }
10041
10042    /// Same as [`commit`](Self::commit) but tail the inner events with
10043    /// [`MutationEvent::Ingested`] instead of [`MutationEvent::BatchApplied`].
10044    pub(crate) fn commit_ingest(&mut self, label: &str, inserted: usize) -> Result<(usize, usize)> {
10045        let ops = std::mem::take(&mut self.ops);
10046        self.db
10047            .commit_logged_batch(ops, Some((label.to_string(), inserted)), None)
10048    }
10049}
10050
10051pub struct NodeRef<'a, F: Fs> {
10052    db: &'a GraphDb<F>,
10053    id: u32,
10054}
10055
10056impl<'a, F: Fs> NodeRef<'a, F> {
10057    pub fn key(&self) -> &str {
10058        self.db.ids.key_of(self.id).expect("dense ids")
10059    }
10060
10061    pub fn label(&self) -> &str {
10062        let sym = self
10063            .db
10064            .labels
10065            .get(self.id as usize)
10066            .copied()
10067            .filter(|&s| s != u32::MAX)
10068            .expect("real nodes always have a label; u32::MAX sentinel cannot occur");
10069        self.db.syms.resolve(sym).expect("interned label symbol")
10070    }
10071
10072    pub fn prop(&self, field: &str) -> Option<Value> {
10073        self.db
10074            .props_view()
10075            .get(self.id, field)
10076            .map(|vr| vr.into_value())
10077    }
10078
10079    /// All stored fields for this node, sorted by field name.
10080    ///
10081    /// Reads from the full base+overlay view so that props stored only in the
10082    /// V8 snapshot base (i.e. before any post-snapshot WAL writes) are visible.
10083    pub fn props(&self) -> BTreeMap<String, Value> {
10084        let mut out = BTreeMap::new();
10085        let pv = self.db.props_view();
10086        for field in pv.field_names() {
10087            if let Some(vr) = pv.get(self.id, &field) {
10088                out.insert(field, vr.into_value());
10089            }
10090        }
10091        out
10092    }
10093
10094    /// depth-N BFS as a ResultSet: columns ["key","label","depth"], BFS order.
10095    pub fn neighborhood(&self, depth: u32, edge_types: Option<&[&str]>, dir: Dir) -> ResultSet {
10096        let view = self.db.view();
10097        let resolved: Option<Vec<u32>> = edge_types.map(|names| {
10098            names
10099                .iter()
10100                .filter_map(|name| view.syms.get(name))
10101                .collect()
10102        });
10103        let nb = neighborhood(&view, self.id, depth, resolved.as_deref(), dir);
10104        let mut rs = ResultSet::new(vec!["key".into(), "label".into(), "depth".into()]);
10105        for (nid, d) in nb.nodes {
10106            let key = view.key_of(nid);
10107            let label = view
10108                .label_of(nid)
10109                .expect("real nodes always have a label; u32::MAX sentinel cannot occur");
10110            rs.push_row(vec![
10111                Some(Value::Str(key.to_string())),
10112                Some(Value::Str(label.to_string())),
10113                Some(Value::Int(d as i64)),
10114            ]);
10115        }
10116        rs
10117    }
10118
10119    /// 1-hop, Both directions: edge-type name → sorted unique neighbor keys.
10120    pub fn grouped_by_edge_type(&self) -> BTreeMap<String, Vec<String>> {
10121        let view = self.db.view();
10122        let mut groups: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
10123        for e in expand(&view, self.id, None, Dir::Both) {
10124            // Skip edges with unknown etypes (only possible from corrupt large
10125            // TOPOLOGY section; function returns BTreeMap not Result).
10126            let Some(etype) = view.syms.resolve(e.etype) else {
10127                continue;
10128            };
10129            let etype = etype.to_string();
10130            let nbr = if e.src == self.id { e.dst } else { e.src };
10131            groups
10132                .entry(etype)
10133                .or_default()
10134                .insert(view.key_of(nbr).to_string());
10135        }
10136        groups
10137            .into_iter()
10138            .map(|(k, v)| (k, v.into_iter().collect()))
10139            .collect()
10140    }
10141}
10142
10143#[cfg(test)]
10144mod tests {
10145    use super::*;
10146    use core_rules::Predicate;
10147
10148    fn tmp_dir(name: &str) -> std::path::PathBuf {
10149        let d =
10150            std::env::temp_dir().join(format!("graphdb-db-unit-{}-{}", name, std::process::id()));
10151        let _ = std::fs::remove_dir_all(&d);
10152        d
10153    }
10154
10155    fn fk_rule() -> RuleDef {
10156        RuleDef {
10157            name: "works_at".into(),
10158            src_label: "Person".into(),
10159            dst_label: "Org".into(),
10160            predicate: Predicate::KeyMatch {
10161                field: "org_id".into(),
10162            },
10163            edge_type: "WORKS_AT".into(),
10164            weight_prop: None,
10165            max_edges: None,
10166            approximate: false,
10167            via_label: None,
10168            via_edge: None,
10169            via_dir: None,
10170        }
10171    }
10172
10173    /// Regression guard for the no-views delta-copy fast path.
10174    ///
10175    /// When no views are defined, `pending_deltas_since().to_vec()` must never
10176    /// be called — even during a large CreateRule backfill. The DELTA_COPY_COUNT
10177    /// thread-local is incremented inside every `if !view_store.is_empty()` block;
10178    /// a count of 0 after the entire sequence proves the guard fires correctly.
10179    #[test]
10180    fn no_delta_copy_when_no_views() {
10181        DELTA_COPY_COUNT.with(|c| c.set(0));
10182        let dir = tmp_dir("no-delta-copy");
10183        {
10184            let mut db = GraphDb::open(&dir).unwrap();
10185            // Insert 50 Org + 50 Person nodes with FK links.
10186            for i in 0..50u32 {
10187                db.insert_node("Org", &format!("o{i}"), vec![]).unwrap();
10188            }
10189            for i in 0..50u32 {
10190                db.insert_node(
10191                    "Person",
10192                    &format!("p{i}"),
10193                    vec![("org_id".into(), Value::Str(format!("o{i}")))],
10194                )
10195                .unwrap();
10196            }
10197            // CreateRule backfill should NOT invoke to_vec() when no views are defined.
10198            db.create_rule(fk_rule()).unwrap();
10199
10200            // Counter must stay 0 — no views, no copies.
10201            let copies = DELTA_COPY_COUNT.with(|c| c.get());
10202            assert_eq!(
10203                copies, 0,
10204                "pending_deltas_since().to_vec() called despite no views"
10205            );
10206
10207            // Derived edges must still be correct (the guard skips only the
10208            // empty delta propagation loop, not the rule application itself).
10209            let nbrs = db.neighbors("p0", "WORKS_AT", Direction::Out).unwrap();
10210            assert_eq!(
10211                nbrs,
10212                vec!["o0"],
10213                "rule must derive edges even with no views"
10214            );
10215        }
10216        let _ = std::fs::remove_dir_all(&dir);
10217    }
10218
10219    /// Gating regression: subscribe AFTER a backfill must see no stale events.
10220    /// subscribe BEFORE a backfill must see every edge-fire event.
10221    #[test]
10222    fn subscribe_after_backfill_no_stale_events() {
10223        let dir = tmp_dir("sub-after-backfill");
10224        {
10225            let mut db = GraphDb::open(&dir).unwrap();
10226            for i in 0..10u32 {
10227                db.insert_node("Org", &format!("o{i}"), vec![]).unwrap();
10228                db.insert_node(
10229                    "Person",
10230                    &format!("p{i}"),
10231                    vec![("org_id".into(), Value::Str(format!("o{i}")))],
10232                )
10233                .unwrap();
10234            }
10235            // Create rule BEFORE subscribing — emit_deltas is false during backfill.
10236            db.create_rule(fk_rule()).unwrap();
10237
10238            // Subscribe AFTER the backfill — queue must be empty (no stale events).
10239            let sub = db.subscribe_all_rules().unwrap();
10240            // No events should have queued for the prior backfill.
10241            assert!(
10242                sub.try_recv().is_none(),
10243                "subscribe after backfill must see no stale events"
10244            );
10245
10246            // Inserting a new node now should fire an event (emit_deltas is now true).
10247            db.insert_node("Org", "o_new", vec![]).unwrap();
10248            db.insert_node(
10249                "Person",
10250                "p_new",
10251                vec![("org_id".into(), Value::Str("o_new".into()))],
10252            )
10253            .unwrap();
10254            let ev = sub.recv_timeout(std::time::Duration::from_millis(200));
10255            assert!(
10256                ev.is_some(),
10257                "edge-fire event must arrive after subscribe (emit_deltas=true)"
10258            );
10259        }
10260        let _ = std::fs::remove_dir_all(&dir);
10261    }
10262
10263    /// Gating regression: subscribe BEFORE a backfill → events flow.
10264    #[test]
10265    fn subscribe_before_backfill_events_flow() {
10266        let dir = tmp_dir("sub-before-backfill");
10267        {
10268            let mut db = GraphDb::open(&dir).unwrap();
10269            // Subscribe FIRST — emit_deltas becomes true.
10270            let sub = db.subscribe_all_rules().unwrap();
10271
10272            for i in 0..5u32 {
10273                db.insert_node("Org", &format!("o{i}"), vec![]).unwrap();
10274                db.insert_node(
10275                    "Person",
10276                    &format!("p{i}"),
10277                    vec![("org_id".into(), Value::Str(format!("o{i}")))],
10278                )
10279                .unwrap();
10280            }
10281            // Backfill fires with emit_deltas=true → events queued.
10282            db.create_rule(fk_rule()).unwrap();
10283
10284            // Should receive at least one edge-fired event from the backfill.
10285            let mut received = 0usize;
10286            while sub.try_recv().is_some() {
10287                received += 1;
10288            }
10289            assert!(
10290                received > 0,
10291                "subscribe before backfill must receive edge-fire events (got 0)"
10292            );
10293        }
10294        let _ = std::fs::remove_dir_all(&dir);
10295    }
10296
10297    /// Companion: when a view IS defined, the delta path fires and view values update.
10298    #[test]
10299    fn delta_copy_fires_when_view_exists() {
10300        use core_rules::ViewSource;
10301        DELTA_COPY_COUNT.with(|c| c.set(0));
10302        let dir = tmp_dir("delta-copy-with-view");
10303        {
10304            let mut db = GraphDb::open(&dir).unwrap();
10305            db.insert_node("Org", "o1", vec![]).unwrap();
10306            db.insert_node(
10307                "Person",
10308                "p1",
10309                vec![("org_id".into(), Value::Str("o1".into()))],
10310            )
10311            .unwrap();
10312            // Declare a Degree view so is_empty() returns false.
10313            db.create_view(ViewDef {
10314                name: "degree_out".into(),
10315                label: "Person".into(),
10316                view_prop: "degree_out".into(),
10317                source: ViewSource::Degree {
10318                    edge_type: "WORKS_AT".into(),
10319                    direction: Direction::Out,
10320                },
10321            })
10322            .unwrap();
10323            db.create_rule(fk_rule()).unwrap();
10324
10325            // At least one delta copy should have happened (CreateRule backfill).
10326            let copies = DELTA_COPY_COUNT.with(|c| c.get());
10327            assert!(
10328                copies > 0,
10329                "expected delta copy to fire when a view is defined"
10330            );
10331
10332            // View value should be computed: p1 has one WORKS_AT out-edge.
10333            let info = db.node_info("p1").unwrap();
10334            let degree = info.props.get("degree_out");
10335            assert!(
10336                degree.is_some(),
10337                "view prop should be written to node props"
10338            );
10339        }
10340        let _ = std::fs::remove_dir_all(&dir);
10341    }
10342
10343    /// Regression: `open_at_with` must call `rebuild_all` after WAL replay so
10344    /// derived-edge-driven view values reflect the as-of state rather than just
10345    /// the initial backfill written at `CreateView` time.
10346    ///
10347    /// Base WAL frames (indices 0..=5 before history markers):
10348    ///   0: insert Org "o1"
10349    ///   1: create_view "employee_count" (Degree / WORKS_AT / In) on Org
10350    ///   2: create_rule fk_rule (WORKS_AT, Person→Org via org_id)
10351    ///   3: insert Person "p1" → rule fires WORKS_AT p1→o1 (degree = 1)  ← mid
10352    ///   4: insert Person "p2" → rule fires WORKS_AT p2→o1 (degree = 2)
10353    ///   5: insert Person "p3" → rule fires WORKS_AT p3→o1 (degree = 3)  ← latest
10354    ///
10355    /// Each rule-fire also appends a DerivedEdgeAdded history-marker frame (state
10356    /// no-op), so the total commit count is higher than the base frame count.
10357    /// The "latest" open_at commit is computed dynamically via `wal_commit_count_at`.
10358    ///
10359    /// Without `rebuild_all`, the as-of instance's "emp" view stays at the
10360    /// initial backfill value (0) instead of reflecting the replayed derived edges.
10361    #[test]
10362    fn open_at_derived_edge_view_values_correct() {
10363        use core_rules::ViewSource;
10364        let dir = tmp_dir("open-at-view-rebuild");
10365        {
10366            let mut db = GraphDb::open(&dir).unwrap();
10367            // frame 0
10368            db.insert_node("Org", "o1", vec![]).unwrap();
10369            // frame 1: create view — initial backfill sees 0 derived edges (none fired yet)
10370            db.create_view(ViewDef {
10371                name: "employee_count".into(),
10372                label: "Org".into(),
10373                view_prop: "emp".into(),
10374                source: ViewSource::Degree {
10375                    edge_type: "WORKS_AT".into(),
10376                    direction: Direction::In,
10377                },
10378            })
10379            .unwrap();
10380            // frame 2: create rule — no Persons yet; backfill is a no-op
10381            db.create_rule(fk_rule()).unwrap();
10382            // frame 3: p1 — rule fires WORKS_AT p1→o1; degree = 1
10383            db.insert_node(
10384                "Person",
10385                "p1",
10386                vec![("org_id".into(), Value::Str("o1".into()))],
10387            )
10388            .unwrap();
10389            // frame 4: p2 — degree = 2
10390            db.insert_node(
10391                "Person",
10392                "p2",
10393                vec![("org_id".into(), Value::Str("o1".into()))],
10394            )
10395            .unwrap();
10396            // frame 5: p3 — degree = 3
10397            db.insert_node(
10398                "Person",
10399                "p3",
10400                vec![("org_id".into(), Value::Str("o1".into()))],
10401            )
10402            .unwrap();
10403            // Sanity: normal open sees degree = 3.
10404            assert_eq!(
10405                db.get_view_prop("o1", "emp"),
10406                Some(Value::Int(3)),
10407                "normal db must show degree 3 after 3 derived edges"
10408            );
10409        } // WAL flushed
10410
10411        // Re-open normally to get the authoritative reference value.
10412        let normal_db = GraphDb::open(&dir).unwrap();
10413        let normal_emp = normal_db.get_view_prop("o1", "emp");
10414        assert_eq!(
10415            normal_emp,
10416            Some(Value::Int(3)),
10417            "re-opened normal db must show degree 3"
10418        );
10419
10420        // Latest as-of (last WAL commit): must match the normal open.
10421        // History-marker frames are appended after each rule-fire, so the total
10422        // commit count is computed dynamically rather than hardcoded.
10423        let total = crate::wal_commit_count_at(&dir).unwrap();
10424        let aof_latest = GraphDb::open_at(&dir, total - 1).unwrap();
10425        assert_eq!(
10426            aof_latest.get_view_prop("o1", "emp"),
10427            normal_emp,
10428            "open_at latest: derived-edge view must equal normal open (rebuild_all required)"
10429        );
10430
10431        // Mid-history as-of (commit 3 = p1 insert Batch frame): only p1; degree = 1.
10432        // The DerivedEdgeAdded marker for p1 is at frame 4 (state no-op on replay),
10433        // so replaying 0..=3 correctly re-derives only the p1→o1 edge.
10434        let aof_mid = GraphDb::open_at(&dir, 3).unwrap();
10435        assert_eq!(
10436            aof_mid.get_view_prop("o1", "emp"),
10437            Some(Value::Int(1)),
10438            "open_at mid-history: only p1 exists at frame 3, degree must be 1"
10439        );
10440
10441        let _ = std::fs::remove_dir_all(&dir);
10442    }
10443
10444    /// Pin: subscribe_* on an as-of instance must return Err(ReadOnly) —
10445    /// as-of instances never commit, so distribute_events never runs and any
10446    /// subscription would wait forever.
10447    #[test]
10448    fn subscribe_on_as_of_returns_read_only_error() {
10449        let dir = tmp_dir("sub-as-of-read-only");
10450        {
10451            let mut db = GraphDb::open(&dir).unwrap();
10452            db.insert_node("Org", "o1", vec![]).unwrap();
10453            db.create_rule(fk_rule()).unwrap();
10454        }
10455        let mut aof = GraphDb::open_at(&dir, 0).unwrap();
10456
10457        assert!(
10458            matches!(
10459                aof.subscribe_all_rules(),
10460                Err(core_storage::GraphError::ReadOnly)
10461            ),
10462            "subscribe_all_rules on as-of must return ReadOnly"
10463        );
10464        assert!(
10465            matches!(
10466                aof.subscribe_writes(),
10467                Err(core_storage::GraphError::ReadOnly)
10468            ),
10469            "subscribe_writes on as-of must return ReadOnly"
10470        );
10471        assert!(
10472            matches!(
10473                aof.subscribe_rule("works_at"),
10474                Err(core_storage::GraphError::ReadOnly)
10475            ),
10476            "subscribe_rule on as-of must return ReadOnly"
10477        );
10478        let _ = std::fs::remove_dir_all(&dir);
10479    }
10480
10481    /// Regression: a failed dense WAL rewrite must not leave speculative
10482    /// interns in `syms`. If it does, the next successful mutation logs an
10483    /// `Intern` record with an inflated id; replay (which never saw the
10484    /// orphans) assigns a smaller id and the WAL becomes unreplayable.
10485    #[test]
10486    fn dense_rewrite_error_rolls_back_speculative_interns() {
10487        let dir = tmp_dir("dense-rewrite-rollback");
10488        {
10489            let mut db = GraphDb::open(&dir).unwrap();
10490            db.insert_node("Person", "a", vec![]).unwrap();
10491
10492            // Bypass MutPreview validation to hit the rewrite's own error path
10493            // (same shape as an id-exhaustion failure mid-rewrite). The
10494            // InsertEdge arm interns the edge type before it resolves keys.
10495            let err = db.rewrite_wal_dense(vec![WalRecord::InsertEdge {
10496                edge_type: "ORPHAN_TYPE".into(),
10497                src_key: "missing".into(),
10498                dst_key: "a".into(),
10499            }]);
10500            assert!(err.is_err(), "rewrite of a missing src key must fail");
10501            assert_eq!(
10502                db.syms.get("ORPHAN_TYPE"),
10503                None,
10504                "failed rewrite must roll back speculative interns"
10505            );
10506
10507            // A later successful mutation must produce a replayable WAL.
10508            db.set_prop("a", "later_field", Value::Int(2)).unwrap();
10509        }
10510        let db = GraphDb::open(&dir).expect("WAL must replay after failed rewrite");
10511        assert_eq!(db.get_prop("a", "later_field"), Some(Value::Int(2)));
10512        let _ = std::fs::remove_dir_all(&dir);
10513    }
10514}