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    let mut vals = Vec::with_capacity(args.len());
830    for arg in args {
831        vals.push(eval_set_return_operand(
832            db, match_rs, row, rel_vars, arg, params,
833        )?);
834    }
835    match norm.as_str() {
836        "tolower" => {
837            if vals.len() != 1 {
838                return Err(GraphError::QueryError {
839                    detail: format!("toLower() requires exactly 1 argument, got {}", vals.len()),
840                });
841            }
842            Ok(vals[0].clone().map(|val| match val {
843                Value::Str(s) => Value::Str(s.to_ascii_lowercase()),
844                other => other,
845            }))
846        }
847        "toupper" => {
848            if vals.len() != 1 {
849                return Err(GraphError::QueryError {
850                    detail: format!("toUpper() requires exactly 1 argument, got {}", vals.len()),
851                });
852            }
853            Ok(vals[0].clone().map(|val| match val {
854                Value::Str(s) => Value::Str(s.to_ascii_uppercase()),
855                other => other,
856            }))
857        }
858        "size" => match vals.first().cloned().flatten() {
859            None => Ok(None),
860            Some(Value::Str(s)) => Ok(Some(Value::Int(s.len() as i64))),
861            Some(Value::List(items)) => Ok(Some(Value::Int(items.len() as i64))),
862            Some(_) => Ok(None),
863        },
864        "coalesce" => Ok(vals.into_iter().flatten().next()),
865        "abs" => match vals.first().cloned().flatten() {
866            None => Ok(None),
867            Some(Value::Int(n)) => Ok(Some(Value::Int(n.saturating_abs()))),
868            Some(Value::Float(f)) => Ok(Some(Value::Float(f.abs()))),
869            Some(_) => Ok(None),
870        },
871        "round" => match vals.first().cloned().flatten() {
872            None => Ok(None),
873            Some(Value::Float(f)) => Ok(Some(Value::Float(f.round()))),
874            Some(Value::Int(n)) => Ok(Some(Value::Int(n))),
875            Some(_) => Ok(None),
876        },
877        "decay" => {
878            if vals.len() != 3 {
879                return Err(GraphError::QueryError {
880                    detail: format!("decay() requires exactly 3 arguments, got {}", vals.len()),
881                });
882            }
883            match (vals[0].clone(), vals[1].clone(), vals[2].clone()) {
884                (None, _, _) | (_, None, _) | (_, _, None) => Ok(None),
885                (Some(b), Some(a), Some(h)) => {
886                    let numeric = |v: Value| -> Result<f64> {
887                        match v {
888                            Value::Int(n) => Ok(n as f64),
889                            Value::Float(f) => Ok(f),
890                            other => Err(GraphError::QueryError {
891                                detail: format!(
892                                    "decay() requires numeric arguments, got {other:?}"
893                                ),
894                            }),
895                        }
896                    };
897                    let b = numeric(b)?;
898                    let a = numeric(a)?;
899                    let h = numeric(h)?;
900                    if h <= 0.0 {
901                        return Err(GraphError::QueryError {
902                            detail: "decay() requires halflife > 0".into(),
903                        });
904                    }
905                    Ok(Some(Value::Float(b * 0.5f64.powf(a / h))))
906                }
907            }
908        }
909        _ => Err(GraphError::QueryError {
910            detail: format!(
911                "unknown function `{name}`; supported: toLower, toUpper, size, coalesce, type, abs, round, decay"
912            ),
913        }),
914    }
915}
916
917fn eval_set_return_item<F: Fs>(
918    db: &GraphDb<F>,
919    match_rs: &ResultSet,
920    row: usize,
921    rel_vars: &[String],
922    item: &RetItem,
923    params: &BTreeMap<String, Value>,
924) -> Result<Option<Value>> {
925    match &item.value {
926        RetVal::Var(v) => eval_set_return_operand(
927            db,
928            match_rs,
929            row,
930            rel_vars,
931            &Operand::Var(v.clone()),
932            params,
933        ),
934        RetVal::Prop { var, field } => eval_set_return_operand(
935            db,
936            match_rs,
937            row,
938            rel_vars,
939            &Operand::Prop {
940                var: var.clone(),
941                field: field.clone(),
942            },
943            params,
944        ),
945        RetVal::FuncCall { name, args } => {
946            eval_set_return_func(db, match_rs, row, rel_vars, name, args, params)
947        }
948        RetVal::ScalarExpr(op) => eval_set_return_operand(db, match_rs, row, rel_vars, op, params),
949        RetVal::Agg { .. } => Err(GraphError::QueryError {
950            detail: "aggregates are not supported in MATCH … SET … RETURN".into(),
951        }),
952    }
953}
954
955/// Project user RETURN from original MATCH rows after SET. No rematch.
956fn project_set_return_rows<F: Fs>(
957    db: &GraphDb<F>,
958    rel_vars: &[String],
959    match_rs: &ResultSet,
960    returns: &[RetItem],
961    params: &BTreeMap<String, Value>,
962) -> Result<ResultSet> {
963    let columns: Vec<String> = returns.iter().map(ret_column_name).collect();
964    let mut out = ResultSet::new(columns);
965    for row in 0..match_rs.len() {
966        let mut cells = Vec::with_capacity(returns.len());
967        for item in returns {
968            cells.push(eval_set_return_item(
969                db, match_rs, row, rel_vars, item, params,
970            )?);
971        }
972        out.push_row(cells);
973    }
974    Ok(out)
975}
976
977/// Single construction point for a `GraphMut` view over the split-borrowed graph fields.
978/// Callers use `std::mem::take` on the engine before calling this, then restore it after.
979/// Extract a `Vec<f64>` from a `Value::List` whose items are all numeric.
980/// Returns `None` for non-list values or lists with non-numeric elements.
981fn value_as_float_list(v: &Value) -> Option<Vec<f64>> {
982    match v {
983        Value::List(items) => items
984            .iter()
985            .map(|item| match item {
986                Value::Float(f) => Some(*f),
987                Value::Int(i) => Some(*i as f64),
988                _ => None,
989            })
990            .collect(),
991        _ => None,
992    }
993}
994
995fn make_graph_mut<'a>(
996    ids: &'a IdMap,
997    syms: &'a mut Interner,
998    labels: &'a [u32],
999    props: core_storage::v8::seam::ColumnsView<'a>,
1000    topo: &'a mut Topology,
1001    edge_props: &'a mut EdgeProps,
1002) -> GraphMut<'a> {
1003    GraphMut {
1004        ids,
1005        syms,
1006        labels,
1007        props,
1008        topo,
1009        edge_props,
1010    }
1011}
1012
1013/// Build a `ColumnsView` from the disjoint `props` overlay and optional V8 base.
1014///
1015/// Takes explicit field references rather than `&self` so the caller can hold
1016/// simultaneous mutable borrows of other fields (e.g. `syms`, `topo`).
1017fn build_props_view<'a>(
1018    props: &'a ColumnStore,
1019    base: &'a Option<std::sync::Arc<core_storage::v8::MappedBase>>,
1020) -> core_storage::v8::seam::ColumnsView<'a> {
1021    match base {
1022        None => core_storage::v8::seam::ColumnsView::owned(props),
1023        Some(b) => {
1024            let archived = b
1025                .columns()
1026                .expect("base columns section bounds validated at open");
1027            core_storage::v8::seam::ColumnsView::with_base(props, archived)
1028        }
1029    }
1030}
1031
1032fn build_topo_view<'a>(
1033    overlay: &'a Topology,
1034    base: &'a Option<std::sync::Arc<core_storage::v8::MappedBase>>,
1035) -> core_storage::v8::seam::TopologyView<'a> {
1036    match base {
1037        None => core_storage::v8::seam::TopologyView::owned(overlay),
1038        Some(b) => {
1039            let archived_csr = b
1040                .topology()
1041                .expect("base topology section bounds validated at open");
1042            core_storage::v8::seam::TopologyView::with_base(overlay, archived_csr)
1043        }
1044    }
1045}
1046
1047/// When [`GraphDb`] calls `Fs::sync` after a WAL append.
1048///
1049/// Default is [`Strict`](FsyncPolicy::Strict): every `log_then_apply_with`
1050/// fsyncs (single `insert_node` / `set_prop`). Ingest and `write_batch`
1051/// emit one `WalRecord::Batch` and fsync once at that frame (Batched).
1052/// [`Relaxed`](FsyncPolicy::Relaxed) skips WAL sync; [`GraphDb::snapshot`]
1053/// is still durable via `write_atomic`. Crash-recovery DST stays Strict.
1054#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
1055pub enum FsyncPolicy {
1056    /// Every WAL commit calls `fs.sync` (today's behavior).
1057    #[default]
1058    Strict,
1059    /// Sync only at a `Batch` frame end. Single-op path stays Strict unless
1060    /// this policy is set on the database.
1061    Batched,
1062    /// Never call `fs.sync`. [`GraphDb::snapshot`] still syncs via `write_atomic`.
1063    Relaxed,
1064}
1065
1066/// A precondition for a compare-and-set batch write.
1067///
1068/// All preconditions in a [`GraphDb::write_batch_cas`] or
1069/// [`crate::SharedDb::submit_batch_cas`] call are checked atomically before
1070/// any operation in the batch is applied.  If any precondition fails, the
1071/// entire batch is rejected with [`GraphError::CasConflict`] and no WAL frame
1072/// is written.
1073///
1074/// # Touch definition
1075///
1076/// A node's last-change commit (`last_changed`) is updated when any of the
1077/// following state-changing WAL records touch it:
1078///
1079/// - `InsertNode` / `InsertNodeId` — the newly-inserted node.
1080/// - `SetProp` / `SetPropId` / `RemoveProp` — the property-bearing node.
1081/// - `InsertEdge` / `InsertEdgeId` / `DeleteEdge` — **both** src and dst
1082///   endpoints (an edge change touches both sides).
1083/// - `DeleteNode` — the node is tombstoned; `last_changed` returns `None`
1084///   for deleted keys so the pre-deletion entry is never observed.
1085///
1086/// History markers (`DerivedEdgeAdded` / `DerivedEdgeRetracted`) are
1087/// state no-ops.  The underlying mutation that triggered rule firing already
1088/// updated the relevant nodes' last-change entries.  Rule-management records
1089/// (`CreateRule`, `DeleteRule`, `RebuildRule`) and view/full-text declarations
1090/// do not touch any node's last-change.
1091#[derive(Debug, Clone, PartialEq, Eq)]
1092pub enum Precondition {
1093    /// The node's last-change commit must equal `expected`.
1094    ///
1095    /// Fails with [`GraphError::CasConflict`] when:
1096    /// - The node does not exist (`last_changed` returns `None`), or
1097    /// - The recorded commit seq does not match `expected`.
1098    NodeUnchangedSince { key: String, expected: u64 },
1099    /// The node must not exist (not inserted, or already deleted).
1100    ///
1101    /// Fails with [`GraphError::CasConflict`] (expected=`u64::MAX`,
1102    /// actual=`last_changed(key).unwrap_or(0)`) when the node is live.
1103    NodeAbsent { key: String },
1104}
1105
1106pub struct GraphDb<F: Fs> {
1107    fs: F,
1108    ids: IdMap,
1109    syms: Interner,
1110    topo: Topology,
1111    props: ColumnStore,
1112    labels: Vec<u32>, // node id -> label symbol
1113    edge_props: EdgeProps,
1114    engine: RuleEngine,
1115    view_store: ViewStore,
1116    /// Incremental inverted index for full-text-lite search.
1117    /// Rebuild-on-open: populated from WAL replay + rebuild_all at open end.
1118    fulltext: FulltextIndex,
1119    /// Opt-in equality index over scalar node properties.
1120    /// Rebuild-on-open: declarations replay from the WAL, postings rebuild at
1121    /// open end (mirrors `fulltext`).
1122    prop_index: PropertyIndex,
1123    event_sink: Option<Box<dyn Fn(MutationEvent) + Send + Sync>>,
1124    /// WAL fsync cadence. Default [`FsyncPolicy::Strict`].
1125    fsync: FsyncPolicy,
1126    /// Monotonically increasing per-commit counter.  A single `log_then_apply_with`
1127    /// call increments this once; all events emitted from that call share the same
1128    /// `commit_seq` value.
1129    commit_seq: u64,
1130    /// RBAC role definitions loaded from `roles.json` at open.
1131    ///
1132    /// `Some(roles)` — loaded successfully (may be empty when no roles are defined).
1133    /// `None` — `roles.json` was present but corrupt; `mask_for_role` returns
1134    /// `Err` for any request (fail-loud, never silently grant empty visibility).
1135    roles: Option<Vec<RoleDef>>,
1136    /// Live subscriptions.  Entries with a dead `Weak` are pruned on the next
1137    /// distribute_events call.
1138    subscriptions: Vec<SubEntry>,
1139    /// Live query subscriptions. Re-executed on every commit when non-empty.
1140    /// Dead `Weak` entries are pruned inside `distribute_events`.
1141    query_subscriptions: Vec<QuerySubEntry>,
1142    /// Queue capacity for new subscriptions created by this db.  Default is
1143    /// [`DEFAULT_SUB_CAPACITY`]; can be overridden via [`set_sub_capacity`]
1144    /// to test Lagged behaviour with small queues.
1145    sub_capacity: usize,
1146    /// True for as-of instances opened via [`GraphDb::open_at`].
1147    /// Every mutation method and `snapshot()` returns [`GraphError::ReadOnly`]
1148    /// when this flag is set.
1149    read_only: bool,
1150    /// Total WAL commit count at the time [`open_at`] was called.
1151    /// 0 for normal (non-as-of) instances.
1152    total_wal_commits: u64,
1153    /// Immutable mmap-backed base snapshot (V8).  When `Some`, `self.topo` is
1154    /// the WAL-replay overlay (empty at open time, populated by apply()) and
1155    /// reads go through a merged `TopologyView`.  `self.props` is always
1156    /// fully materialized (base + WAL replay) for HNSW/IVF and view compat.
1157    base: Option<Arc<core_storage::v8::MappedBase>>,
1158    // ── MVCC epoch reader state ───────────────────────────────────────────────
1159    /// Most-recent full overlay clone.  Initialized at end of `open_with` /
1160    /// `open_at_with`; refreshed every `FOLD_EVERY_K` commits.
1161    /// `None` only between struct creation and the first fold.
1162    fold_overlay: Option<Arc<crate::reader::FrozenOverlay>>,
1163    /// Per-commit deltas accumulated since the last fold.
1164    delta_tail: Vec<Arc<crate::reader::CommitDelta>>,
1165    /// How many commits have occurred since the last fold.
1166    commits_since_fold: usize,
1167    /// When true, `log_then_apply_with` buffers event notifications instead of
1168    /// firing them immediately.  Used by the group-commit drain thread to defer
1169    /// events until after the group fsync (R2: durability before notification).
1170    /// Cleared to false once the drain thread flushes or discards the buffer.
1171    defer_events: bool,
1172    /// Buffered events accumulated while `defer_events` is true.
1173    deferred_events: Vec<DeferredEvent>,
1174    /// Set to true by the group-commit drain thread when a group fsync fails
1175    /// after WAL truncation.  All subsequent mutation attempts return an IO
1176    /// error until the database is reopened.
1177    degraded: bool,
1178    /// Set to `true` after `ensure_v8_base_sections_loaded` has read provenance,
1179    /// HNSW, and IVF sections from the mmap base into the engine's retained
1180    /// fields.  `false` on all opens until first use; always `true` for non-V8
1181    /// opens (base is None, fast-path sets flag immediately).
1182    v8_sections_loaded: std::sync::atomic::AtomicBool,
1183    /// Serializes the one-time section population in `ensure_v8_base_sections_loaded`.
1184    v8_sections_mutex: std::sync::Mutex<()>,
1185    /// Per-node last-change commit sequence.  `last_change[node_id] = seq` means
1186    /// the node was last modified by commit `seq`.
1187    ///
1188    /// Loaded from V8 section 11 at open; updated on every state-changing commit
1189    /// and WAL replay frame.  V5-V7 stores start with an empty map; pre-WAL-horizon
1190    /// nodes return `None` from `last_changed` until they are next mutated.
1191    ///
1192    /// See [`Precondition`] for the full touch definition.
1193    last_change: HashMap<u32, u64>,
1194    /// WAL archive retention policy set by [`set_wal_archive_retention`].
1195    /// `None` = unlimited (keep all archives); `Some(N)` = keep N newest archives,
1196    /// pruning older ones at snapshot time.  0 is treated as unlimited.
1197    wal_archive_retention: Option<u32>,
1198    /// Global frame index of the first commit that is still reachable through
1199    /// surviving archives.  Persisted to `wal.floor` sidecar when pruning occurs.
1200    /// Default 0 = all history reachable.
1201    wal_horizon_floor: u64,
1202    /// True when the surviving archive chain forms a continuous WAL history
1203    /// starting from the store's first commit (the genesis chain).
1204    ///
1205    /// `open_at` may replay archive-resident commits from empty state only when
1206    /// this flag is true AND `wal_horizon_floor == 0`.  Cleared whenever:
1207    ///   - a WAL-truncating snapshot (`keep_wal=false`) is taken after archives
1208    ///     already exist (breaks the chain for subsequent archives), or
1209    ///   - any archive is pruned (floor advances past zero).
1210    ///
1211    /// Persisted via the `wal.genesis` marker file; loaded from it at open.
1212    archive_genesis_chain: bool,
1213    /// Transient write-authz context set by `write_batch_authz` /
1214    /// `query_write_authz` for the duration of ONE mutation call.
1215    /// Always `None` at rest.  Never serialized, never WAL-replayed.
1216    pending_write_authz: Option<WriteAuthz>,
1217    /// Slow-query threshold in milliseconds.  0 = disabled.
1218    /// Seeded from `MUSHROOMDB_SLOW_QUERY_MS` at open; override via
1219    /// [`GraphDb::set_slow_query_threshold_ms`] (tests must use the setter
1220    /// — env vars are process-global and race parallel test threads).
1221    slow_query_threshold_ms: u64,
1222    /// Ring buffer of recent slow queries (interior-mutable so `query(&self)`
1223    /// can record entries without requiring `&mut self`).
1224    slow_queries: std::sync::Mutex<SlowQueryLog>,
1225    /// Instant at which the database was opened (used by `/metrics` uptime).
1226    started_at: std::time::Instant,
1227}
1228
1229/// One group of deferred event notifications, held until the group fsync
1230/// completes.  Replayed by [`GraphDb::flush_deferred_events`].
1231struct DeferredEvent {
1232    rec: core_storage::WalRecord,
1233    engine_deltas: Vec<EngineEdgeDelta>,
1234    seq: u64,
1235    ingest: Option<(String, usize)>,
1236}
1237
1238/// Options for [`GraphDb::open_with_options`].
1239#[derive(Clone, Copy, Debug)]
1240pub struct OpenOptions {
1241    /// Rewrite an old-format snapshot to the current VERSION after a
1242    /// successful load (default `true`). The old snapshot is kept as
1243    /// `snapshot.bin.bak` until the next clean open at the current version,
1244    /// at which point the `.bak` is deleted.
1245    ///
1246    /// Set to `false` to open a store without touching any on-disk files
1247    /// (useful for read-only inspection of a store at an older format).
1248    pub auto_migrate: bool,
1249
1250    /// Write the valid WAL prefix back over a torn tail on open (default
1251    /// `true`). Truncating a genuinely torn tail is correct crash recovery.
1252    ///
1253    /// Set to `false` for an unattended reader. The valid prefix is still
1254    /// decoded and replayed in memory, but nothing is written: the store has
1255    /// no cross-process lock, so a reader that opens while another process is
1256    /// mid-append would otherwise discard a frame that writer believes
1257    /// durable. `mushroomdb recall`, which runs on every prompt, passes
1258    /// `false` for exactly this reason.
1259    pub repair_wal: bool,
1260}
1261
1262impl Default for OpenOptions {
1263    fn default() -> Self {
1264        Self {
1265            auto_migrate: true,
1266            repair_wal: true,
1267        }
1268    }
1269}
1270
1271/// Authorization context carried by `write_batch_authz` / `query_write_authz`.
1272///
1273/// `None` at the call site = full authority (today's zero-cost behavior).
1274/// `Some(WriteAuthz)` = role-scoped: the decision table (plan §"authz decision
1275/// table") is evaluated per-op inside `commit_logged_batch` BEFORE any WAL
1276/// record is built.  A denial returns an error with no WAL frame written.
1277///
1278/// The mask is ALWAYS `Omit`-mode: role-token paths must never acknowledge
1279/// hidden-node existence to callers.
1280#[derive(Clone, Debug)]
1281pub struct WriteAuthz {
1282    pub role: String,
1283    pub scope: WriteScope,
1284    /// Resolved by `mask_for_role` under the same write guard as the mutation.
1285    /// Always `Omit`-mode — never `Stub`.
1286    pub mask: crate::mask::NodeMask,
1287}
1288
1289/// Write `bytes` to `snapshot.bin.bak` atomically with full fsync.
1290///
1291/// Uses [`RealFs::write_atomic`] which applies `F_FULLFSYNC` on macOS and
1292/// `sync_all` on other platforms, then renames the `.tmp` file into place and
1293/// syncs the directory entry. This is the only correct path for writing the
1294/// `.bak` — plain `std::fs::write + sync_all` misses both `F_FULLFSYNC` and
1295/// the directory sync.
1296pub fn write_snapshot_bak(dir: &std::path::Path, bytes: &[u8]) -> crate::Result<()> {
1297    use core_storage::fs::{FileId, Fs as _};
1298    RealFs::new(dir)
1299        .map_err(core_storage::GraphError::Io)?
1300        .write_atomic(FileId::SnapshotBak, bytes)
1301        .map_err(core_storage::GraphError::Io)
1302}
1303
1304/// Return the on-disk snapshot format version without decoding the full snapshot.
1305///
1306/// Reads only the 6-byte header (magic + version LE). Returns `None` when no
1307/// snapshot file exists (WAL-only store). Returns an error if the header is
1308/// malformed.
1309pub fn snapshot_version_at(dir: &std::path::Path) -> crate::Result<Option<u16>> {
1310    use std::io::Read as _;
1311    let path = dir.join("snapshot.bin");
1312    let mut header = [0u8; 6];
1313    let n = match std::fs::File::open(&path) {
1314        Ok(mut f) => f.read(&mut header).map_err(core_storage::GraphError::Io)?,
1315        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
1316        Err(e) => return Err(core_storage::GraphError::Io(e)),
1317    };
1318    core_storage::snapshot::peek_version(&header[..n])
1319}
1320
1321/// Options for [`GraphDb::snapshot_with`].
1322#[derive(Debug, Clone, Default)]
1323pub struct SnapshotOptions {
1324    /// When `true`, the WAL is preserved after the snapshot write.
1325    /// Pre-snapshot commits remain reachable via [`GraphDb::open_at`].
1326    /// When `false` (the default), the WAL is truncated to a minimal
1327    /// baseline so cold-start replay stays fast.
1328    pub keep_wal: bool,
1329    /// When `true`, the current WAL is renamed to `wal.<commit_seq>.archive`
1330    /// before a fresh WAL baseline is written (history-preserving snapshot).
1331    ///
1332    /// This is the feature opt-in: `false` (the default) leaves the existing
1333    /// truncation / keep-wal behaviour byte-identical.  `archive_wal` takes
1334    /// precedence over `keep_wal` when both are set.
1335    ///
1336    /// Archives can be scanned by [`GraphDb::node_history`],
1337    /// [`GraphDb::edge_history`], [`GraphDb::was_linked`], and
1338    /// [`GraphDb::open_at`], extending the reachable history horizon across
1339    /// snapshot boundaries.
1340    pub archive_wal: bool,
1341}
1342
1343/// Derive the scan-label sym for the commit-skip fast-path.
1344///
1345/// Walks `ops` to find the plan's leading scan op (`ScanLabel`, `IndexScan`,
1346/// or `IndexIntersect`) with a concrete label string, then interns it.
1347///
1348/// Returns `None` in all cases where skipping is unsafe:
1349/// - Any `Expand` op is present (edge traversal; edges change results regardless
1350///   of node labels).
1351/// - The leading scan has no label (`ScanLabel { label: None }` — full scan).
1352/// - No recognizable leading scan op is found.
1353///
1354/// This is the conservative v0.4.3 boundary. The caller stores the result in
1355/// [`QuerySubEntry::scan_label`] at subscribe time; `None` means always execute.
1356fn extract_scan_label(ops: &[PlanOp], syms: &mut Interner) -> Option<u32> {
1357    // Any Expand → must always re-execute (edges can change join results).
1358    if ops.iter().any(|op| matches!(op, PlanOp::Expand { .. })) {
1359        return None;
1360    }
1361    for op in ops {
1362        match op {
1363            PlanOp::ScanLabel {
1364                label: Some(label), ..
1365            } => return Some(syms.intern(label)),
1366            PlanOp::IndexScan {
1367                label: Some(label), ..
1368            } => return Some(syms.intern(label)),
1369            PlanOp::IndexIntersect {
1370                label: Some(label), ..
1371            } => return Some(syms.intern(label)),
1372            _ => {}
1373        }
1374    }
1375    None
1376}
1377
1378impl GraphDb<RealFs> {
1379    /// Open the database at `dir` with default options.
1380    ///
1381    /// Equivalent to `open_with_options(dir, OpenOptions::default())`.
1382    /// Old-format snapshots (V5, V6) are automatically migrated to the
1383    /// current version on a successful load (see [`OpenOptions::auto_migrate`]).
1384    pub fn open(dir: &std::path::Path) -> Result<Self> {
1385        Self::open_with_options(dir, OpenOptions::default())
1386    }
1387
1388    /// Open the database at `dir` with explicit options.
1389    ///
1390    /// When `opts.auto_migrate` is `true` (the default) and the on-disk
1391    /// snapshot is an older format version, this function:
1392    ///   1. Copies the current `snapshot.bin` to `snapshot.bin.bak` (atomic
1393    ///      + fsynced) before any modification.
1394    ///   2. Rewrites `snapshot.bin` at the current format version via
1395    ///      [`GraphDb::snapshot_with`] with `keep_wal: true` (WAL preserved).
1396    ///
1397    /// If migration fails the error is returned and the original files are
1398    /// intact (the `.bak` was written before the new snapshot was attempted).
1399    ///
1400    /// A clean open that finds the snapshot already at the current version
1401    /// deletes any leftover `.bak` file.
1402    ///
1403    /// WAL-only stores (no snapshot) are never auto-migrated on open.
1404    ///
1405    /// `opts.repair_wal` controls the other write this function can make; see
1406    /// [`OpenOptions::repair_wal`]. With both flags `false` the open touches
1407    /// no file on disk.
1408    pub fn open_with_options(dir: &std::path::Path, opts: OpenOptions) -> Result<Self> {
1409        // Header-only peek — 6 bytes, no full decode.
1410        let snap_version = snapshot_version_at(dir)?;
1411
1412        // Full load: decode snapshot + replay WAL + rebuild indexes.
1413        let mut db = Self::open_with_repair(RealFs::new(dir)?, opts.repair_wal)?;
1414
1415        if opts.auto_migrate {
1416            match snap_version {
1417                Some(ver) if ver < core_storage::snapshot::VERSION => {
1418                    let _tm = std::time::Instant::now();
1419                    // Copy the original snapshot to .bak at OS level — no in-memory
1420                    // buffer required for a 2+ GiB file.
1421                    //
1422                    // Crash-safety: snapshot.bin remains intact (write_atomic inside
1423                    // snapshot_with uses a .tmp+rename) until the V8 write succeeds.
1424                    // A torn .bak on crash is acceptable because the original
1425                    // snapshot.bin is the authoritative source until after the rename.
1426                    std::fs::copy(dir.join("snapshot.bin"), dir.join("snapshot.bin.bak"))
1427                        .map_err(core_storage::GraphError::Io)?;
1428                    trace_migrate!("bak copy done", _tm);
1429                    // Rewrite snapshot at current version; keep WAL intact.
1430                    db.snapshot_with(SnapshotOptions {
1431                        keep_wal: true,
1432                        ..SnapshotOptions::default()
1433                    })?;
1434                    trace_migrate!("snapshot_with done", _tm);
1435                }
1436                Some(_) => {
1437                    // Already current version: remove any leftover .bak.
1438                    let bak = dir.join("snapshot.bin.bak");
1439                    if bak.exists() {
1440                        std::fs::remove_file(&bak).map_err(core_storage::GraphError::Io)?;
1441                    }
1442                }
1443                None => {
1444                    // WAL-only store — nothing to migrate on open.
1445                }
1446            }
1447        }
1448
1449        Ok(db)
1450    }
1451
1452    /// Open a read-only view of the database as it existed after `commit`.
1453    ///
1454    /// Commit indices are 0-based over the current WAL: commit 0 is the state
1455    /// after the first WAL frame, commit N-1 is the state after the N-th (most
1456    /// recent) frame.  Call [`GraphDb::open`] to read the full current state.
1457    ///
1458    /// **Replay base.** [`GraphDb::snapshot`] truncates the WAL when it runs,
1459    /// so as-of can only reach commits recorded in the current WAL (those
1460    /// written after the most recent snapshot, or all commits if no snapshot
1461    /// was ever taken).  Commit 0 in `open_at` always refers to the first
1462    /// frame in the WAL that exists on disk, not the first ever write to the
1463    /// database.  When the on-disk snapshot recorded that it truncated the
1464    /// WAL (V7, default `keep_wal: false`), it is loaded as the base state
1465    /// before frame replay, so the as-of view includes all pre-snapshot data.
1466    /// Snapshots written with `keep_wal: true` (and legacy V5/V6 snapshots)
1467    /// are ignored and replay is WAL-only, as before.
1468    ///
1469    /// **Read-only.** Every mutation method and `snapshot()` on the returned
1470    /// instance returns [`GraphError::ReadOnly`].  Queries, `explain()`, and
1471    /// `stats()` work normally.
1472    ///
1473    /// # Errors
1474    /// - [`GraphError::CommitOutOfRange`] if `commit >= wal_commit_count` (including
1475    ///   when the WAL is empty after a snapshot).
1476    pub fn open_at(dir: &std::path::Path, commit: u64) -> Result<Self> {
1477        Self::open_at_with(RealFs::new(dir)?, commit)
1478    }
1479
1480    /// Run a **read-only** Cypher query against the graph as it existed at
1481    /// `commit` — the "time-travel" / agent-replay query. Opens a temporal view
1482    /// of this store's directory at that commit and executes the read there.
1483    ///
1484    /// The current instance is unaffected. Write statements are rejected (the
1485    /// temporal view is read-only). `commit` is a 0-based WAL commit index;
1486    /// `commit == wal_commit_count` (or `open_at`'s range) yields the newest
1487    /// state. Prefer this over holding many historical instances open.
1488    ///
1489    /// # Errors
1490    /// - [`GraphError::CommitOutOfRange`] if `commit` is past the WAL horizon.
1491    /// - A query error for a malformed or write query.
1492    pub fn query_at(
1493        &self,
1494        commit: u64,
1495        cypher: &str,
1496        params: &std::collections::BTreeMap<String, Value>,
1497    ) -> Result<ResultSet> {
1498        let dir = self.fs.dir().to_path_buf();
1499        let temporal = Self::open_at(&dir, commit)?;
1500        if is_write_tokens(&lex(cypher).map_err(|e| GraphError::QueryError {
1501            detail: format!("lex: {e}"),
1502        })?) {
1503            return Err(GraphError::QueryError {
1504                detail: "query_at is read-only: write statements are not permitted in a \
1505                         time-travel query"
1506                    .into(),
1507            });
1508        }
1509        temporal.query(cypher, params)
1510    }
1511}
1512
1513impl<F: Fs> GraphDb<F> {
1514    /// Open over an arbitrary [`Fs`], repairing a torn WAL tail as usual.
1515    pub fn open_with(fs: F) -> Result<Self> {
1516        Self::open_with_repair(fs, true)
1517    }
1518
1519    /// As [`GraphDb::open_with`], but `repair_wal: false` decodes the valid WAL
1520    /// prefix without writing the truncation back. See
1521    /// [`OpenOptions::repair_wal`].
1522    pub fn open_with_repair(fs: F, repair_wal: bool) -> Result<Self> {
1523        let mut db = Self {
1524            fs,
1525            ids: IdMap::new(),
1526            syms: Interner::new(),
1527            topo: Topology::new(),
1528            props: ColumnStore::new(),
1529            labels: Vec::new(),
1530            edge_props: EdgeProps::new(),
1531            engine: RuleEngine::new(),
1532            view_store: ViewStore::new(),
1533            fulltext: FulltextIndex::new(),
1534            prop_index: PropertyIndex::new(),
1535            event_sink: None,
1536            fsync: FsyncPolicy::Strict,
1537            commit_seq: 0,
1538            roles: Some(vec![]),
1539            subscriptions: Vec::new(),
1540            query_subscriptions: Vec::new(),
1541            sub_capacity: DEFAULT_SUB_CAPACITY,
1542            read_only: false,
1543            total_wal_commits: 0,
1544            base: None,
1545            fold_overlay: None,
1546            delta_tail: Vec::new(),
1547            commits_since_fold: 0,
1548            defer_events: false,
1549            deferred_events: Vec::new(),
1550            degraded: false,
1551            v8_sections_loaded: std::sync::atomic::AtomicBool::new(false),
1552            v8_sections_mutex: std::sync::Mutex::new(()),
1553            last_change: HashMap::new(),
1554            wal_archive_retention: None,
1555            wal_horizon_floor: 0,
1556            archive_genesis_chain: false,
1557            pending_write_authz: None,
1558            slow_query_threshold_ms: std::env::var("MUSHROOMDB_SLOW_QUERY_MS")
1559                .ok()
1560                .and_then(|v| v.parse().ok())
1561                .unwrap_or(100),
1562            slow_queries: std::sync::Mutex::new(SlowQueryLog {
1563                entries: std::collections::VecDeque::new(),
1564                total: 0,
1565            }),
1566            started_at: std::time::Instant::now(),
1567        };
1568        db.wal_horizon_floor = db.fs.read_horizon_floor()?;
1569        db.archive_genesis_chain = db.fs.has_genesis_marker();
1570        // Opening cleanup: remove orphaned archives — archives whose frames all
1571        // fall below the horizon floor.  Orphans arise when a crash interrupted
1572        // the retention-prune sequence after the floor was written but before
1573        // all surplus archives were deleted.  Safe to delete: floor already
1574        // accounts for their frames.
1575        db.cleanup_orphaned_archives()?;
1576        let _t0 = std::time::Instant::now();
1577        // Peek 6 bytes to determine snapshot version without reading the full
1578        // file. For RealFs this is a true partial read (O(1)); for SimFs the
1579        // default impl reads all bytes and truncates (still correct).
1580        let snap_header = db.fs.read_prefix(FileId::Snapshot, 6)?;
1581        let is_v8 = snap_header.len() >= 6
1582            && &snap_header[0..4] == b"GDB1"
1583            && u16::from_le_bytes([snap_header[4], snap_header[5]])
1584                == core_storage::snapshot::VERSION_8;
1585        if is_v8 {
1586            // V8: map the file zero-copy (RealFs) or read full bytes (SimFs).
1587            // No 2.4GB heap Vec is allocated on RealFs.
1588            let mapped = Arc::new(
1589                if let Some(snap_path) = db.fs.snapshot_path() {
1590                    core_storage::v8::MappedBase::map(&snap_path)
1591                } else {
1592                    let snap_bytes = db.fs.read(FileId::Snapshot)?;
1593                    core_storage::v8::MappedBase::from_bytes(snap_bytes)
1594                }
1595                .map_err(|e| GraphError::Corrupt {
1596                    detail: format!("v8: mmap open: {e:?}"),
1597                })?,
1598            );
1599            db.restore_v8_base(Arc::clone(&mapped))?;
1600            trace_open!("restore_v8_base", _t0);
1601            db.base = Some(mapped);
1602            trace_open!("base assigned", _t0);
1603        } else if !snap_header.is_empty() {
1604            // Legacy V5-V7: full read required for decode.
1605            let snap_bytes = db.fs.read(FileId::Snapshot)?;
1606            if let Some(state) = core_storage::snapshot::decode(&snap_bytes)? {
1607                db.restore_snapshot_state(state)?;
1608            }
1609        }
1610        // else: snap_header is empty = no snapshot file, fresh store.
1611        //
1612        // Seed commit_seq from the highest seq persisted in last_change so that
1613        // WAL-replay frames (which start at commit_seq+1) always exceed any seq
1614        // already stored in the snapshot.  Without this, a db with one snapshot
1615        // commit would save last_change["a"]=1, then on reopen the first WAL
1616        // frame would replay at seq=1 again — colliding and making WAL-tail
1617        // mutations indistinguishable from the snapshot baseline.
1618        //
1619        // Safety invariant (seq-recycling):
1620        //   Recycled seqs (those below the seeded baseline) were NEVER stored in
1621        //   last_change because they belonged to a previous db lifetime — a new
1622        //   db starts at commit_seq=0 with an empty last_change.  Therefore no
1623        //   CAS precondition can carry a recycled seq as its `expected` value
1624        //   and accidentally match a live node's last_change entry.
1625        //
1626        // `expected:0` on a deleted-then-reinserted node:
1627        //   After deletion, last_changed() returns None; callers that call
1628        //   last_changed() and then use NodeUnchangedSince get None.unwrap_or(0)
1629        //   = 0.  The reinserted node gets seq > 0, so a subsequent CAS with
1630        //   expected=0 correctly conflicts.  The only way to observe actual=0 in
1631        //   a CasConflict would be a caller that invented expected=0 without ever
1632        //   calling last_changed() — unreachable via the documented API contract.
1633        if let Some(&max_seq) = db.last_change.values().max() {
1634            db.commit_seq = db.commit_seq.max(max_seq);
1635        }
1636        let bytes = db.fs.read(FileId::Wal)?;
1637        let (records, valid_len) = decode_all(&bytes);
1638        // The valid prefix is replayed either way; `repair_wal` only decides
1639        // whether the truncation is written back. A reader that races a live
1640        // appender must not persist a truncation the writer never asked for.
1641        if valid_len < bytes.len() && repair_wal {
1642            db.fs.write_atomic(FileId::Wal, &bytes[..valid_len])?;
1643        }
1644        // WAL-present path: build indexes eagerly BEFORE replay so that the
1645        // first replayed record does not trigger the lazy-init guard (which
1646        // would call reindex_all_load_ivf on an empty graph, defeating the
1647        // point of restoring IVF/HNSW blobs from the snapshot).
1648        if !records.is_empty() {
1649            db.ensure_v8_base_sections_loaded();
1650            trace_open!("lazy sections loaded (WAL path)", _t0);
1651            db.engine.consume_retained_state_eager(
1652                &db.ids,
1653                &db.syms,
1654                &db.labels,
1655                build_props_view(&db.props, &db.base),
1656            );
1657        }
1658        for rec in records {
1659            db.apply(&rec)?;
1660            // Drain per-frame to keep pending_deltas O(1) during replay (I-2).
1661            // No subscriber exists yet; discard is correct.
1662            let _ = db.engine.drain_deltas();
1663            // Track commit_seq during replay so last_change entries are
1664            // consistent with the seqs assigned by log_then_apply_with on
1665            // subsequent live commits.  After N replayed frames, commit_seq=N;
1666            // live commits begin at N+1.
1667            db.commit_seq += 1;
1668            let replay_seq = db.commit_seq;
1669            db.update_last_change_from_rec(&rec, replay_seq);
1670        }
1671        // Enforce I-2: if the per-frame drain above is ever removed or skipped,
1672        // this assert catches the regression in debug builds immediately.
1673        debug_assert_eq!(
1674            db.engine.pending_delta_count(),
1675            0,
1676            "pending_deltas non-empty after replay — \
1677             per-frame drain must run inside the loop to keep memory O(1)"
1678        );
1679        // T2 note: the per-frame drain IS the suppression seam for replay.
1680        // Any future as-of replay path (Plan-15 T2) must drain here to feed
1681        // replaying subscribers; the mechanism is already in place.
1682        let _ = db.engine.drain_deltas(); // belt-and-braces no-op after loop drain
1683        trace_open!("wal replay done", _t0);
1684        // Rebuild view values after WAL replay only when there is no V8 base.
1685        // With a V8 base, view values are correct in the snapshot and are updated
1686        // incrementally during WAL replay (on_edge_changed / on_prop_changed).
1687        // A full rebuild would read overlay-only props (empty after restore_v8_base)
1688        // and overwrite correct base values with wrong results (e.g. NeighborAgg
1689        // Sum reads no "score" in overlay → writes 0.0, shadowing the correct
1690        // base value).
1691        if db.base.is_none() {
1692            let topo_view = TopologyView::owned(&db.topo);
1693            db.view_store
1694                .rebuild_all(&mut db.props, &topo_view, &db.ids, &db.syms, &db.labels);
1695        }
1696        // Rebuild full-text index after WAL replay.  Corrects drift from
1697        // per-record incremental apply during replay.
1698        db.fulltext.rebuild_all(
1699            &db.ids,
1700            &db.labels,
1701            &db.syms,
1702            build_props_view(&db.props, &db.base),
1703        );
1704        db.prop_index.rebuild_all(
1705            &db.ids,
1706            &db.labels,
1707            &db.syms,
1708            build_props_view(&db.props, &db.base),
1709        );
1710        // Load roles sidecar. Missing file = no roles (Some(vec![])).
1711        // Corrupt/unparseable = poisoned (None); mask_for_role will fail-loud.
1712        db.roles = Self::load_roles_from_fs(&db.fs)?;
1713        // Capture the initial MVCC fold so reader() is ready immediately.
1714        db.fold_now();
1715        trace_open!("open_with complete", _t0);
1716        Ok(db)
1717    }
1718
1719    /// As-of replay for [`GraphDb::open_at`]: snapshot base (only when the
1720    /// snapshot truncated the WAL) plus the first `commit + 1` WAL frames;
1721    /// see [`GraphDb::open_at`] for the semantics.  The per-frame drain
1722    /// mirrors `open_with` exactly so pending_delta_count is 0 on exit.
1723    /// Restore all persisted state from a decoded snapshot. Shared by
1724    /// `open_with` and (when the snapshot truncated the WAL) `open_at_with`.
1725    fn restore_snapshot_state(
1726        &mut self,
1727        state: core_storage::snapshot::SnapshotState,
1728    ) -> Result<()> {
1729        self.ids = state.ids;
1730        self.syms = state.syms;
1731        self.topo = state.topo;
1732        self.props = state.props;
1733        self.labels = state.labels;
1734        self.edge_props = state.edge_props;
1735        // Cross-section label integrity for V5/V7 snapshots: same invariants as
1736        // restore_v8_base.  A crafted bincode snapshot with a short `labels` vec,
1737        // out-of-range sym ids, or a sentinel label on a live node would otherwise
1738        // open successfully and panic later in `NodeRef::label()` or
1739        // `neighborhood_masked()`.  Catching it here turns those into typed
1740        // `GraphError::Corrupt` at open time.
1741        {
1742            let ids_len = self.ids.len();
1743            if self.labels.len() != ids_len {
1744                return Err(GraphError::Corrupt {
1745                    detail: format!(
1746                        "snapshot: labels vec has {} entries but id table has {} total slots",
1747                        self.labels.len(),
1748                        ids_len,
1749                    ),
1750                });
1751            }
1752            let syms_len = self.syms.len() as u32;
1753            for (i, &sym) in self.labels.iter().enumerate() {
1754                let is_tombstoned = self.ids.is_tombstoned(i as u32);
1755                if sym == u32::MAX {
1756                    if !is_tombstoned {
1757                        return Err(GraphError::Corrupt {
1758                            detail: format!(
1759                                "snapshot: live node at id slot {i} has sentinel label (u32::MAX)"
1760                            ),
1761                        });
1762                    }
1763                } else if sym >= syms_len {
1764                    return Err(GraphError::Corrupt {
1765                        detail: format!(
1766                            "snapshot: label at id slot {i} references sym {sym} \
1767                             which is out of interner range ({syms_len})"
1768                        ),
1769                    });
1770                }
1771            }
1772        }
1773        let defs: Vec<RuleDef> = state
1774            .rule_defs
1775            .iter()
1776            .map(|b| {
1777                decode_rule_def(b).map_err(|e| GraphError::Corrupt {
1778                    detail: format!("snapshot rule_def deserialize: {e}"),
1779                })
1780            })
1781            .collect::<Result<Vec<_>>>()?;
1782        self.engine =
1783            RuleEngine::from_persist(defs, state.provenance, state.rule_tripped, state.rule_fires);
1784        // Candidate indexes are rebuilt lazily on the first mutation (see
1785        // RuleEngine::on_node_changed).  HNSW blobs and IVF centroids from the
1786        // snapshot are retained without deserializing so that:
1787        //   - clean-open (empty WAL): indexes stay empty; blobs load on first
1788        //     ANN query via ensure_hnsw_loaded, or on first mutation via the
1789        //     lazy-init guard which calls reindex_all_load_ivf + load_hnsw_state.
1790        //   - WAL-present: open_with calls consume_retained_state_eager before
1791        //     replay so HNSW/IVF are live before any record fires the hooks.
1792        let ivf_bytes = if state.ivf_state.is_empty() {
1793            Vec::new()
1794        } else {
1795            bincode::serialize(&state.ivf_state).expect("IVF state serialize cannot fail")
1796        };
1797        // Store blobs without eagerly deserializing them.
1798        self.engine
1799            .store_snapshot_state(state.hnsw_state, ivf_bytes);
1800        // Restore view defs from snapshot (V5).
1801        // The ColumnStore already contains view values from the snapshot;
1802        // use restore_view (no collision check, no backfill) so the store
1803        // is aware of the definitions.  rebuild_all runs after WAL replay.
1804        for def_bytes in &state.view_defs {
1805            let def: ViewDef =
1806                bincode::deserialize(def_bytes).map_err(|e| GraphError::Corrupt {
1807                    detail: format!("snapshot view_def deserialize: {e}"),
1808                })?;
1809            self.view_store
1810                .restore_view(def)
1811                .map_err(|e| GraphError::Corrupt {
1812                    detail: format!("snapshot view restore: {e}"),
1813                })?;
1814        }
1815        Ok(())
1816    }
1817
1818    /// Restore all persisted state from a V8 `MappedBase` snapshot, **except**
1819    /// topology (`self.topo` stays empty and serves as the WAL-replay overlay).
1820    ///
1821    /// `self.props` IS fully materialised from the base so that HNSW/IVF blob
1822    /// deserialization and view rebuild have access to all column data.
1823    fn restore_v8_base(&mut self, mapped: Arc<core_storage::v8::MappedBase>) -> Result<()> {
1824        self.ids = archived_to_idmap(mapped.ids().map_err(|e| GraphError::Corrupt {
1825            detail: format!("v8: ids section: {e:?}"),
1826        })?);
1827        self.syms = archived_to_interner(mapped.syms().map_err(|e| GraphError::Corrupt {
1828            detail: format!("v8: syms section: {e:?}"),
1829        })?);
1830
1831        // C1: self.props is left as an empty overlay. Column reads go through
1832        // props_view() (ColumnsView::with_base), which consults the archived base
1833        // section zero-copy. This avoids the O(columns) heap copy at every open.
1834
1835        // self.topo deliberately left as Topology::new() — overlay path.
1836
1837        let meta = decode_meta(mapped.meta_bytes().map_err(|e| GraphError::Corrupt {
1838            detail: format!("v8: meta section: {e:?}"),
1839        })?)
1840        .map_err(|e| GraphError::Corrupt {
1841            detail: format!("v8: meta decode: {e:?}"),
1842        })?;
1843        self.labels = meta.labels;
1844        // Cross-section label integrity: labels must cover every id slot (live
1845        // and tombstoned), every non-sentinel sym must be within the interner's
1846        // bound, and no live (non-tombstoned) node may carry the u32::MAX
1847        // sentinel label.  Without this check, a crafted snapshot where the META
1848        // section (small, CRC-validated) holds a short `labels` vec, out-of-range
1849        // sym ids, or a sentinel label on a live node, would open successfully
1850        // and then panic in `NodeRef::label()`, `neighborhood_masked()`, and
1851        // related read paths.  Catching the inconsistency here converts those
1852        // panics into typed `GraphError::Corrupt` at open time.
1853        {
1854            let ids_len = self.ids.len();
1855            if self.labels.len() != ids_len {
1856                return Err(GraphError::Corrupt {
1857                    detail: format!(
1858                        "v8: labels section has {} entries but id table has {} total slots",
1859                        self.labels.len(),
1860                        ids_len,
1861                    ),
1862                });
1863            }
1864            let syms_len = self.syms.len() as u32;
1865            for (i, &sym) in self.labels.iter().enumerate() {
1866                let is_tombstoned = self.ids.is_tombstoned(i as u32);
1867                if sym == u32::MAX {
1868                    // Sentinel is only valid for tombstoned slots.
1869                    if !is_tombstoned {
1870                        return Err(GraphError::Corrupt {
1871                            detail: format!(
1872                                "v8: live node at id slot {i} has sentinel label (u32::MAX)"
1873                            ),
1874                        });
1875                    }
1876                } else if sym >= syms_len {
1877                    return Err(GraphError::Corrupt {
1878                        detail: format!(
1879                            "v8: label at id slot {i} references sym {sym} \
1880                             which is out of interner range ({syms_len})"
1881                        ),
1882                    });
1883                }
1884            }
1885        }
1886        // C3: self.edge_props stays as an empty overlay.  Reads go through
1887        // edge_props_view() which consults the mmap'd base section zero-copy
1888        // via EdgePropsView::with_base.  No heap decode at open time.
1889
1890        // Restore rule engine.
1891        let (rule_def_bytes, rule_tripped, rule_fires) =
1892            archived_rules_meta_to_owned(mapped.rules_meta_section().map_err(|e| {
1893                GraphError::Corrupt {
1894                    detail: format!("v8: rules_meta section: {e:?}"),
1895                }
1896            })?);
1897        let defs: Vec<RuleDef> = rule_def_bytes
1898            .iter()
1899            .map(|b| {
1900                decode_rule_def(b).map_err(|e| GraphError::Corrupt {
1901                    detail: format!("v8: rule_def deserialize: {e}"),
1902                })
1903            })
1904            .collect::<Result<Vec<_>>>()?;
1905        self.engine = RuleEngine::from_persist(defs, BTreeMap::new(), rule_tripped, rule_fires);
1906        // C4+C5: provenance, HNSW, and IVF sections are NOT read here.
1907        // `ensure_v8_base_sections_loaded` reads them on first use from
1908        // `self.base` (set by the caller immediately after this returns).
1909        // A clean open touches only: header + IDS + SYMS + META + RULES_META.
1910
1911        // Restore view definitions.
1912        let view_defs =
1913            archived_views_to_owned(mapped.views_section().map_err(|e| GraphError::Corrupt {
1914                detail: format!("v8: views section: {e:?}"),
1915            })?);
1916        for def_bytes in &view_defs {
1917            let def: ViewDef =
1918                bincode::deserialize(def_bytes).map_err(|e| GraphError::Corrupt {
1919                    detail: format!("v8: view_def deserialize: {e}"),
1920                })?;
1921            self.view_store
1922                .restore_view(def)
1923                .map_err(|e| GraphError::Corrupt {
1924                    detail: format!("v8: view restore: {e}"),
1925                })?;
1926        }
1927        // Load the last-change map from section 11 (small section; load eagerly).
1928        // Pre-Task-3 snapshots lack this section; `last_change_bytes` returns &[]
1929        // in that case and `decode_last_change_bytes` returns an empty map.
1930        let last_change_raw = mapped
1931            .last_change_bytes()
1932            .map_err(|e| GraphError::Corrupt {
1933                detail: format!("v8: last_change section: {e:?}"),
1934            })?;
1935        self.last_change = decode_last_change_bytes(last_change_raw);
1936
1937        // Validate that all deferred sections (provenance, HNSW, IVF) fit within
1938        // the file.  Pure bounds check — no bytes read, no page faults triggered.
1939        // Catches truncated snapshots at open time before the lazy deferred reads.
1940        mapped.validate_section_bounds().map_err(|e| match e {
1941            GraphError::Corrupt { detail } => GraphError::Corrupt {
1942                detail: format!("v8: section bounds: {detail}"),
1943            },
1944            other => other,
1945        })?;
1946        Ok(())
1947    }
1948
1949    /// Read provenance, HNSW, and IVF sections from the mmap base into the
1950    /// engine's retained fields on first call.  Subsequent calls are a no-op
1951    /// (AtomicBool fast-path).
1952    ///
1953    /// Must be called before any code path that reads or mutates engine
1954    /// provenance, HNSW, or IVF state:
1955    /// - WAL replay (before `consume_retained_state_eager`)
1956    /// - First mutation (`log_then_apply_with`)
1957    /// - Read-only paths (`stats`, `explain`, `node_edges`)
1958    /// - Snapshot (`snapshot_with`)
1959    ///
1960    /// No-op for fresh stores and V5-V7 opens (`self.base` is `None`).
1961    fn ensure_v8_base_sections_loaded(&self) {
1962        use std::sync::atomic::Ordering;
1963        if self.v8_sections_loaded.load(Ordering::Acquire) {
1964            return;
1965        }
1966        let _guard = self
1967            .v8_sections_mutex
1968            .lock()
1969            .expect("v8 sections mutex poisoned");
1970        if self.v8_sections_loaded.load(Ordering::Acquire) {
1971            return; // another caller populated while we waited
1972        }
1973        let _t = std::time::Instant::now();
1974        if let Some(base) = &self.base {
1975            // Provenance: raw rkyv bytes; CRC validated inside section_bytes.
1976            // Bounds are already validated at open time (restore_v8_base →
1977            // validate_section_bounds) — unreachable post-validate_section_bounds;
1978            // unwrap_or_default is a safety belt against impossible errors.
1979            let prov_bytes = base
1980                .provenance_raw_bytes()
1981                .map(|b| b.to_vec())
1982                .unwrap_or_default();
1983            self.engine.store_provenance_bytes(prov_bytes);
1984            // HNSW: decode rkyv blobs into owned map.
1985            let hnsw_state = base
1986                .hnsw_section()
1987                .map(archived_hnsw_to_owned)
1988                .unwrap_or_default();
1989            // IVF: raw bincode bytes; deserialized on first mutation/query.
1990            let ivf_bytes = base.ivf_bytes().map(|b| b.to_vec()).unwrap_or_default();
1991            self.engine.store_snapshot_state(hnsw_state, ivf_bytes);
1992        }
1993        self.v8_sections_loaded.store(true, Ordering::Release);
1994        if std::env::var("MUSHROOMDB_TRACE_OPEN").is_ok() {
1995            eprintln!(
1996                "[MUSHROOMDB_TRACE_OPEN] ensure_v8_base_sections_loaded: {:>9.3?}",
1997                _t.elapsed()
1998            );
1999        }
2000    }
2001
2002    /// Return a `TopologyView` that merges the mmap'd base (when present) with
2003    /// the in-memory WAL overlay.  Used by all read paths in db.rs that need
2004    /// the full merged topology without going through `self.view()`.
2005    fn topo_view(&self) -> TopologyView<'_> {
2006        match self.base {
2007            None => TopologyView::owned(&self.topo),
2008            Some(ref base) => {
2009                // SAFETY: base lives as long as self; section bounds validated at open.
2010                // topology() uses access_unchecked; all field reads are bounds-checked in seam.rs.
2011                let archived = base
2012                    .topology()
2013                    .expect("base topology section bounds validated at open");
2014                TopologyView::with_base(&self.topo, archived)
2015            }
2016        }
2017    }
2018
2019    /// Return a `ColumnsView` that merges the mmap'd base columns (when a V8
2020    /// snapshot is open) with the in-memory WAL overlay.  Reads consult the
2021    /// overlay first, then fall through to the archived base section zero-copy.
2022    fn props_view(&self) -> core_storage::v8::seam::ColumnsView<'_> {
2023        match self.base {
2024            None => core_storage::v8::seam::ColumnsView::owned(&self.props),
2025            Some(ref base) => {
2026                // columns() uses access_unchecked; field reads are bounds-checked in seam.rs.
2027                let archived = base
2028                    .columns()
2029                    .expect("base columns section bounds validated at open");
2030                core_storage::v8::seam::ColumnsView::with_base(&self.props, archived)
2031            }
2032        }
2033    }
2034
2035    /// Return an `EdgePropsView` that merges the mmap'd base edge-props section
2036    /// (when a V8 snapshot is open) with the in-memory WAL overlay.
2037    ///
2038    /// Reads consult the overlay first (for post-snapshot mutations), then fall
2039    /// through to the archived base section zero-copy.  Tombstones in the
2040    /// overlay mask deleted-from-base entries.
2041    fn edge_props_view(&self) -> EdgePropsView<'_> {
2042        match self.base {
2043            None => EdgePropsView::owned(&self.edge_props),
2044            Some(ref base) => {
2045                // edge_props_section() uses access_unchecked; field reads bounds-checked in seam.rs.
2046                let archived = base
2047                    .edge_props_section()
2048                    .expect("base edge_props section bounds validated at open");
2049                EdgePropsView::with_base(&self.edge_props, archived)
2050            }
2051        }
2052    }
2053
2054    fn open_at_with(fs: F, commit: u64) -> Result<Self> {
2055        let mut db = Self {
2056            fs,
2057            ids: IdMap::new(),
2058            syms: Interner::new(),
2059            topo: Topology::new(),
2060            props: ColumnStore::new(),
2061            labels: Vec::new(),
2062            edge_props: EdgeProps::new(),
2063            engine: RuleEngine::new(),
2064            view_store: ViewStore::new(),
2065            fulltext: FulltextIndex::new(),
2066            prop_index: PropertyIndex::new(),
2067            event_sink: None,
2068            fsync: FsyncPolicy::Strict,
2069            commit_seq: 0,
2070            roles: Some(vec![]),
2071            subscriptions: Vec::new(),
2072            query_subscriptions: Vec::new(),
2073            sub_capacity: DEFAULT_SUB_CAPACITY,
2074            read_only: false, // set to true after replay
2075            total_wal_commits: 0,
2076            base: None,
2077            fold_overlay: None,
2078            delta_tail: Vec::new(),
2079            commits_since_fold: 0,
2080            defer_events: false,
2081            deferred_events: Vec::new(),
2082            degraded: false,
2083            v8_sections_loaded: std::sync::atomic::AtomicBool::new(false),
2084            v8_sections_mutex: std::sync::Mutex::new(()),
2085            last_change: HashMap::new(),
2086            wal_archive_retention: None,
2087            wal_horizon_floor: 0,
2088            archive_genesis_chain: false,
2089            pending_write_authz: None,
2090            slow_query_threshold_ms: std::env::var("MUSHROOMDB_SLOW_QUERY_MS")
2091                .ok()
2092                .and_then(|v| v.parse().ok())
2093                .unwrap_or(100),
2094            slow_queries: std::sync::Mutex::new(SlowQueryLog {
2095                entries: std::collections::VecDeque::new(),
2096                total: 0,
2097            }),
2098            started_at: std::time::Instant::now(),
2099        };
2100        db.wal_horizon_floor = db.fs.read_horizon_floor()?;
2101        db.archive_genesis_chain = db.fs.has_genesis_marker();
2102        // Same orphaned-archive cleanup as open_with: floor was written first
2103        // during pruning, so a crash may have left stale archives below floor.
2104        db.cleanup_orphaned_archives()?;
2105        // Collect archive frames (oldest-first) and live WAL frames.
2106        // Archives represent pre-snapshot history; the snapshot captures the
2107        // cumulative state at the time of archiving.  Crash-window guarantee:
2108        //   A: crash before rename → WAL intact, no archive. Reopen: normal.
2109        //   B: crash after rename, before new WAL → archive present, WAL
2110        //      absent. Reopen: snapshot loaded (full state), no WAL replay.
2111        //   C: crash after new baseline WAL written → normal post-archive.
2112        let archive_ns = db.fs.list_archives()?;
2113        let mut archive_frames_all: Vec<WalRecord> = Vec::new();
2114        for n in &archive_ns {
2115            let arc_bytes = db.fs.read_archive(*n)?;
2116            let (arc_frames, _) = decode_all(&arc_bytes);
2117            archive_frames_all.extend(arc_frames);
2118        }
2119        let total_archive_frames = archive_frames_all.len() as u64;
2120
2121        let live_bytes = db.fs.read(FileId::Wal)?;
2122        let (live_records, _valid_len) = decode_all(&live_bytes);
2123        let total_surviving = total_archive_frames + live_records.len() as u64;
2124        // Global total including any pruned history below the horizon floor.
2125        let total = db.wal_horizon_floor + total_surviving;
2126
2127        // Horizon and range check.
2128        if commit < db.wal_horizon_floor {
2129            return Err(GraphError::CommitOutOfRange { commit, total });
2130        }
2131        if commit >= total {
2132            return Err(GraphError::CommitOutOfRange { commit, total });
2133        }
2134
2135        // Local index into surviving frames (0 = first frame of oldest archive).
2136        let local = commit - db.wal_horizon_floor;
2137
2138        if local < total_archive_frames {
2139            // Target commit is in an archive.  Correct replay from empty state
2140            // is only possible when the archive chain is an uninterrupted
2141            // genesis chain (first archive taken from a fresh store, no prior
2142            // WAL truncation) and no archives have been pruned (floor == 0).
2143            //
2144            // If either condition is violated the prefix needed to reconstruct
2145            // the requested state is gone; refuse rather than return wrong data.
2146            if db.wal_horizon_floor > 0 || !db.archive_genesis_chain {
2147                return Err(GraphError::CommitOutOfRange { commit, total });
2148            }
2149            // Replay all archive frames up to and including the target commit
2150            // from an empty database state.  Archives must be replayed in order
2151            // so that dense-id intern tables are built up correctly.
2152            for rec in archive_frames_all.into_iter().take((local + 1) as usize) {
2153                db.apply(&rec)?;
2154                let _ = db.engine.drain_deltas();
2155            }
2156        } else {
2157            // Target commit is in the live WAL: load snapshot as base, then
2158            // replay the needed live WAL prefix.
2159            //
2160            // Base state: a truncating snapshot (wal_truncated=true) compacts
2161            // all pre-truncation / pre-archive commits.  Dense-id records in
2162            // the live WAL reference ids/interns that the snapshot provides.
2163            // Peek 6 bytes (same pattern as open_with).
2164            let snap_header = db.fs.read_prefix(FileId::Snapshot, 6)?;
2165            let is_v8 = snap_header.len() >= 6
2166                && &snap_header[0..4] == b"GDB1"
2167                && u16::from_le_bytes([snap_header[4], snap_header[5]])
2168                    == core_storage::snapshot::VERSION_8;
2169            if is_v8 {
2170                let state = if let Some(snap_path) = db.fs.snapshot_path() {
2171                    let mapped = core_storage::v8::MappedBase::map(&snap_path).map_err(|e| {
2172                        GraphError::Corrupt {
2173                            detail: format!("v8: open_at mmap: {e:?}"),
2174                        }
2175                    })?;
2176                    core_storage::snapshot::decode_v8_from_mapped(&mapped)?
2177                } else {
2178                    let snap_bytes = db.fs.read(FileId::Snapshot)?;
2179                    core_storage::snapshot::decode(&snap_bytes)?
2180                };
2181                if let Some(state) = state {
2182                    if state.wal_truncated {
2183                        db.restore_snapshot_state(state)?;
2184                    }
2185                }
2186            } else if !snap_header.is_empty() {
2187                let snap_bytes = db.fs.read(FileId::Snapshot)?;
2188                if let Some(state) = core_storage::snapshot::decode(&snap_bytes)? {
2189                    if state.wal_truncated {
2190                        db.restore_snapshot_state(state)?;
2191                    }
2192                }
2193            }
2194            // else: snap_header empty = no snapshot file.
2195            let live_local = local - total_archive_frames;
2196            for rec in live_records.into_iter().take((live_local + 1) as usize) {
2197                db.apply(&rec)?;
2198                let _ = db.engine.drain_deltas();
2199            }
2200        }
2201        // Pin: pending_delta_count must be 0 after as-of replay, mirroring T1's
2202        // post-loop assert in open_with.
2203        debug_assert_eq!(
2204            db.engine.pending_delta_count(),
2205            0,
2206            "pending_deltas non-empty after open_at replay — \
2207             per-frame drain must run inside the loop to keep memory O(1)"
2208        );
2209        let _ = db.engine.drain_deltas(); // belt-and-braces no-op
2210                                          // Rebuild view values after WAL replay so derived-edge-driven views
2211                                          // reflect the as-of state.  open_at always uses the legacy path (no V8
2212                                          // base), so topo_view is always owned.
2213        {
2214            let topo_view = TopologyView::owned(&db.topo);
2215            db.view_store
2216                .rebuild_all(&mut db.props, &topo_view, &db.ids, &db.syms, &db.labels);
2217        }
2218        // Rebuild full-text index for as-of view (mirrors open_with pattern).
2219        db.fulltext.rebuild_all(
2220            &db.ids,
2221            &db.labels,
2222            &db.syms,
2223            build_props_view(&db.props, &db.base),
2224        );
2225        db.prop_index.rebuild_all(
2226            &db.ids,
2227            &db.labels,
2228            &db.syms,
2229            build_props_view(&db.props, &db.base),
2230        );
2231        // Load roles sidecar (current roles, not point-in-time).
2232        db.roles = Self::load_roles_from_fs(&db.fs)?;
2233        db.read_only = true;
2234        db.total_wal_commits = total;
2235        // Capture initial fold so reader() is immediately usable.
2236        db.fold_now();
2237        Ok(db)
2238    }
2239
2240    /// Whether this instance is a read-only as-of view.
2241    pub fn is_read_only(&self) -> bool {
2242        self.read_only
2243    }
2244
2245    // ── MVCC epoch reader ─────────────────────────────────────────────────────
2246
2247    /// Clone the current overlay state into a new `FrozenOverlay` and reset
2248    /// the delta tail. Called automatically every `FOLD_EVERY_K` commits and at
2249    /// the end of `open_with` / `open_at_with` to prime the reader.
2250    fn fold_now(&mut self) {
2251        let frozen = crate::reader::FrozenOverlay {
2252            ids: self.ids.clone(),
2253            syms: self.syms.clone(),
2254            topo: self.topo.clone(),
2255            props: self.props.clone(),
2256            labels: self.labels.clone(),
2257            edge_props: self.edge_props.clone(),
2258            roles: self.roles.clone(),
2259            fulltext: self.fulltext.clone(),
2260        };
2261        self.fold_overlay = Some(Arc::new(frozen));
2262        self.delta_tail.clear();
2263        self.commits_since_fold = 0;
2264    }
2265
2266    /// Capture a lock-free reader snapshot of the current db state.
2267    ///
2268    /// The read lock is held only for the duration of this call (to clone a
2269    /// handful of `Arc` handles). Subsequent query operations run without any
2270    /// lock.
2271    pub fn reader(&self) -> crate::reader::ReaderSnapshot {
2272        crate::reader::ReaderSnapshot::new(
2273            self.fold_overlay
2274                .clone()
2275                .expect("fold_overlay is always Some after open_with; call reader() after open"),
2276            self.base.clone(),
2277            self.delta_tail.clone(),
2278        )
2279    }
2280
2281    /// Total number of WAL commits at the time [`open_at`] was called.
2282    /// Returns 0 for normal (non-as-of) instances.
2283    pub fn total_wal_commits(&self) -> u64 {
2284        self.total_wal_commits
2285    }
2286
2287    /// Apply a record to in-memory state. Used by both live writes and replay,
2288    /// so replay is definitionally identical to the original execution.
2289    fn apply(&mut self, rec: &WalRecord) -> Result<()> {
2290        match rec {
2291            WalRecord::InsertNode { label, key, props } => {
2292                let id = self.ids.try_insert(key)?;
2293                let sym = self.syms.intern(label);
2294                if self.labels.len() <= id as usize {
2295                    // gap slots are sentinels, never valid label symbols
2296                    self.labels.resize(id as usize + 1, u32::MAX);
2297                }
2298                self.labels[id as usize] = sym;
2299                for (field, value) in props {
2300                    self.props.set(id, field, value.clone());
2301                }
2302                // Initialize view values for the new node before the engine runs so
2303                // delta-based increments start from a known zero baseline.
2304                self.view_store
2305                    .init_node_views(id, &mut self.props, &self.syms, &self.labels);
2306                // Fire rules for the newly inserted node.
2307                let cursor = self.engine.pending_delta_count();
2308                let mut eng = std::mem::take(&mut self.engine);
2309                {
2310                    let mut gm = make_graph_mut(
2311                        &self.ids,
2312                        &mut self.syms,
2313                        &self.labels,
2314                        build_props_view(&self.props, &self.base),
2315                        &mut self.topo,
2316                        &mut self.edge_props,
2317                    );
2318                    eng.on_node_changed(id, None, &mut gm);
2319                }
2320                self.engine = eng;
2321                // Process derived-edge deltas for view maintenance.
2322                // Fast path: skip the O(delta_count) allocation when no views exist.
2323                if !self.view_store.is_empty() {
2324                    #[cfg(test)]
2325                    DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
2326                    let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
2327                    for d in &new_deltas {
2328                        self.view_store.on_edge_changed(
2329                            d.etype_sym,
2330                            d.src_id,
2331                            d.dst_id,
2332                            d.fired,
2333                            &mut self.props,
2334                            &build_topo_view(&self.topo, &self.base),
2335                            &self.ids,
2336                            &self.syms,
2337                            &self.labels,
2338                            self.base.as_ref().map(|b| {
2339                                b.columns()
2340                                    .expect("base columns section bounds validated at open")
2341                            }),
2342                        );
2343                    }
2344                }
2345                // Full-text index maintenance: index enabled fields for this label.
2346                if self.fulltext.has_label(label) {
2347                    for (field, value) in props {
2348                        if self.fulltext.is_enabled(label, field) {
2349                            self.fulltext.add_tokens(id, field, value);
2350                        }
2351                    }
2352                }
2353                // Property (equality) index maintenance.
2354                if self.prop_index.has_label(label) {
2355                    for (field, value) in props {
2356                        self.prop_index.set(label, field, id, value);
2357                    }
2358                }
2359            }
2360            WalRecord::InsertEdge {
2361                edge_type,
2362                src_key,
2363                dst_key,
2364            } => {
2365                let src = self.ids.get(src_key).ok_or_else(|| GraphError::Corrupt {
2366                    detail: format!("wal replay references unknown key {src_key}"),
2367                })?;
2368                let dst = self.ids.get(dst_key).ok_or_else(|| GraphError::Corrupt {
2369                    detail: format!("wal replay references unknown key {dst_key}"),
2370                })?;
2371                let etype = self.syms.intern(edge_type);
2372                // Skip if the edge is already visible in the merged base+overlay
2373                // view.  This keeps WAL replay idempotent when the WAL contains
2374                // pre-snapshot records that are already encoded in a V8 base
2375                // (keep_wal=true opens and crash-before-truncation scenarios).
2376                if self.base.is_some()
2377                    && self
2378                        .topo_view()
2379                        .neighbors(etype, Direction::Out, src)
2380                        .contains(&dst)
2381                {
2382                    return Ok(());
2383                }
2384                self.topo.add_edge(etype, src, dst);
2385                // View maintenance for manual edge insert.
2386                self.view_store.on_edge_changed(
2387                    etype,
2388                    src,
2389                    dst,
2390                    true,
2391                    &mut self.props,
2392                    &build_topo_view(&self.topo, &self.base),
2393                    &self.ids,
2394                    &self.syms,
2395                    &self.labels,
2396                    self.base.as_ref().map(|b| {
2397                        b.columns()
2398                            .expect("base columns section bounds validated at open")
2399                    }),
2400                );
2401                // Rule engine: via-hop rules must update when user edges change.
2402                let cursor = self.engine.pending_delta_count();
2403                let mut eng = std::mem::take(&mut self.engine);
2404                {
2405                    let mut gm = make_graph_mut(
2406                        &self.ids,
2407                        &mut self.syms,
2408                        &self.labels,
2409                        build_props_view(&self.props, &self.base),
2410                        &mut self.topo,
2411                        &mut self.edge_props,
2412                    );
2413                    eng.on_edge_changed(edge_type, src, dst, &mut gm);
2414                }
2415                self.engine = eng;
2416                if !self.view_store.is_empty() {
2417                    let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
2418                    for d in &new_deltas {
2419                        self.view_store.on_edge_changed(
2420                            d.etype_sym,
2421                            d.src_id,
2422                            d.dst_id,
2423                            d.fired,
2424                            &mut self.props,
2425                            &build_topo_view(&self.topo, &self.base),
2426                            &self.ids,
2427                            &self.syms,
2428                            &self.labels,
2429                            self.base.as_ref().map(|b| {
2430                                b.columns()
2431                                    .expect("base columns section bounds validated at open")
2432                            }),
2433                        );
2434                    }
2435                }
2436            }
2437            WalRecord::SetProp { key, field, value } => {
2438                let id = self.ids.get(key).ok_or_else(|| GraphError::Corrupt {
2439                    detail: format!("wal replay references unknown key {key}"),
2440                })?;
2441                let old_value = build_props_view(&self.props, &self.base)
2442                    .get(id, field)
2443                    .map(|vr| vr.into_value());
2444                self.props.set(id, field, value.clone());
2445                // Fire rules for the changed field.
2446                let cursor = self.engine.pending_delta_count();
2447                let mut eng = std::mem::take(&mut self.engine);
2448                {
2449                    let mut gm = make_graph_mut(
2450                        &self.ids,
2451                        &mut self.syms,
2452                        &self.labels,
2453                        build_props_view(&self.props, &self.base),
2454                        &mut self.topo,
2455                        &mut self.edge_props,
2456                    );
2457                    eng.on_node_changed(id, Some((field, old_value)), &mut gm);
2458                }
2459                self.engine = eng;
2460                // Derived-edge deltas → view updates.
2461                if !self.view_store.is_empty() {
2462                    #[cfg(test)]
2463                    DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
2464                    let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
2465                    for d in &new_deltas {
2466                        self.view_store.on_edge_changed(
2467                            d.etype_sym,
2468                            d.src_id,
2469                            d.dst_id,
2470                            d.fired,
2471                            &mut self.props,
2472                            &build_topo_view(&self.topo, &self.base),
2473                            &self.ids,
2474                            &self.syms,
2475                            &self.labels,
2476                            self.base.as_ref().map(|b| {
2477                                b.columns()
2478                                    .expect("base columns section bounds validated at open")
2479                            }),
2480                        );
2481                    }
2482                }
2483                // Neighbor-aggregate views that read `field` must also update.
2484                self.view_store.on_prop_changed(
2485                    id,
2486                    field,
2487                    &mut self.props,
2488                    &build_topo_view(&self.topo, &self.base),
2489                    &self.ids,
2490                    &self.syms,
2491                    &self.labels,
2492                    self.base.as_ref().map(|b| {
2493                        b.columns()
2494                            .expect("base columns section bounds validated at open")
2495                    }),
2496                );
2497                // Full-text index maintenance: update tokens for this field if indexed.
2498                if self.fulltext.field_indexed(field) {
2499                    let label_opt = self.labels.get(id as usize).and_then(|&sym| {
2500                        if sym == u32::MAX {
2501                            None
2502                        } else {
2503                            self.syms.resolve(sym)
2504                        }
2505                    });
2506                    if let Some(label) = label_opt {
2507                        if self.fulltext.is_enabled(label, field) {
2508                            self.fulltext.remove_node_field(id, field);
2509                            self.fulltext.add_tokens(id, field, value);
2510                        }
2511                    }
2512                }
2513                // Property (equality) index maintenance: re-key this node's value.
2514                if self.prop_index.field_indexed(field) {
2515                    let label_opt = self.labels.get(id as usize).and_then(|&sym| {
2516                        if sym == u32::MAX {
2517                            None
2518                        } else {
2519                            self.syms.resolve(sym)
2520                        }
2521                    });
2522                    if let Some(label) = label_opt {
2523                        self.prop_index.set(label, field, id, value);
2524                    }
2525                }
2526            }
2527            WalRecord::Intern { id, text } => {
2528                if let Some(existing) = self.syms.get(text) {
2529                    if existing != *id {
2530                        return Err(GraphError::Corrupt {
2531                            detail: format!(
2532                                "wal intern mismatch for {text:?}: have {existing}, record {id}"
2533                            ),
2534                        });
2535                    }
2536                } else {
2537                    let got = self.syms.intern(text);
2538                    if got != *id {
2539                        return Err(GraphError::Corrupt {
2540                            detail: format!(
2541                                "wal intern assigned {got} for {text:?}, record wanted {id}"
2542                            ),
2543                        });
2544                    }
2545                }
2546            }
2547            WalRecord::InsertNodeId { label, key, props } => {
2548                let id = self.ids.try_insert(key)?;
2549                if self.labels.len() <= id as usize {
2550                    self.labels.resize(id as usize + 1, u32::MAX);
2551                }
2552                self.labels[id as usize] = *label;
2553                let label_str = self
2554                    .syms
2555                    .resolve(*label)
2556                    .ok_or_else(|| GraphError::Corrupt {
2557                        detail: format!("wal InsertNodeId unknown label intern {label}"),
2558                    })?
2559                    .to_string();
2560                for (field_sym, value) in props {
2561                    let field =
2562                        self.syms
2563                            .resolve(*field_sym)
2564                            .ok_or_else(|| GraphError::Corrupt {
2565                                detail: format!(
2566                                    "wal InsertNodeId unknown field intern {field_sym}"
2567                                ),
2568                            })?;
2569                    self.props.set(id, field, value.clone());
2570                }
2571                self.view_store
2572                    .init_node_views(id, &mut self.props, &self.syms, &self.labels);
2573                let cursor = self.engine.pending_delta_count();
2574                let mut eng = std::mem::take(&mut self.engine);
2575                {
2576                    let mut gm = make_graph_mut(
2577                        &self.ids,
2578                        &mut self.syms,
2579                        &self.labels,
2580                        build_props_view(&self.props, &self.base),
2581                        &mut self.topo,
2582                        &mut self.edge_props,
2583                    );
2584                    eng.on_node_changed(id, None, &mut gm);
2585                }
2586                self.engine = eng;
2587                if !self.view_store.is_empty() {
2588                    #[cfg(test)]
2589                    DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
2590                    let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
2591                    for d in &new_deltas {
2592                        self.view_store.on_edge_changed(
2593                            d.etype_sym,
2594                            d.src_id,
2595                            d.dst_id,
2596                            d.fired,
2597                            &mut self.props,
2598                            &build_topo_view(&self.topo, &self.base),
2599                            &self.ids,
2600                            &self.syms,
2601                            &self.labels,
2602                            self.base.as_ref().map(|b| {
2603                                b.columns()
2604                                    .expect("base columns section bounds validated at open")
2605                            }),
2606                        );
2607                    }
2608                }
2609                if self.fulltext.has_label(&label_str) {
2610                    for (field_sym, value) in props {
2611                        let Some(field) = self.syms.resolve(*field_sym) else {
2612                            continue;
2613                        };
2614                        if self.fulltext.is_enabled(&label_str, field) {
2615                            self.fulltext.add_tokens(id, field, value);
2616                        }
2617                    }
2618                }
2619                if self.prop_index.has_label(&label_str) {
2620                    for (field_sym, value) in props {
2621                        let Some(field) = self.syms.resolve(*field_sym) else {
2622                            continue;
2623                        };
2624                        self.prop_index.set(&label_str, field, id, value);
2625                    }
2626                }
2627            }
2628            WalRecord::InsertEdgeId { etype, src, dst } => {
2629                // Replay-over-snapshot: dense ids in the pre-snapshot WAL may
2630                // already be tombstoned. Skip rather than attaching edges to
2631                // dead ids (DeleteNode keys the live re-insert, not the old id).
2632                if self.ids.is_tombstoned(*src)
2633                    || self.ids.is_tombstoned(*dst)
2634                    || self.ids.key_of(*src).is_none()
2635                    || self.ids.key_of(*dst).is_none()
2636                {
2637                    return Ok(());
2638                }
2639                // Skip if already visible in the merged view (same idempotency
2640                // guard as InsertEdge above: prevents double-counting when
2641                // pre-snapshot WAL records are replayed over a V8 base).
2642                if self.base.is_some()
2643                    && self
2644                        .topo_view()
2645                        .neighbors(*etype, Direction::Out, *src)
2646                        .contains(dst)
2647                {
2648                    return Ok(());
2649                }
2650                self.topo.add_edge(*etype, *src, *dst);
2651                self.view_store.on_edge_changed(
2652                    *etype,
2653                    *src,
2654                    *dst,
2655                    true,
2656                    &mut self.props,
2657                    &build_topo_view(&self.topo, &self.base),
2658                    &self.ids,
2659                    &self.syms,
2660                    &self.labels,
2661                    self.base.as_ref().map(|b| {
2662                        b.columns()
2663                            .expect("base columns section bounds validated at open")
2664                    }),
2665                );
2666                // Rule engine: via-hop rules fire when user via-edges are inserted.
2667                // Resolve etype back to string so on_edge_changed can match rules by name.
2668                if let Some(etype_str) = self.syms.resolve(*etype).map(|s| s.to_string()) {
2669                    let cursor = self.engine.pending_delta_count();
2670                    let mut eng = std::mem::take(&mut self.engine);
2671                    {
2672                        let mut gm = make_graph_mut(
2673                            &self.ids,
2674                            &mut self.syms,
2675                            &self.labels,
2676                            build_props_view(&self.props, &self.base),
2677                            &mut self.topo,
2678                            &mut self.edge_props,
2679                        );
2680                        eng.on_edge_changed(&etype_str, *src, *dst, &mut gm);
2681                    }
2682                    self.engine = eng;
2683                    if !self.view_store.is_empty() {
2684                        let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
2685                        for d in &new_deltas {
2686                            self.view_store.on_edge_changed(
2687                                d.etype_sym,
2688                                d.src_id,
2689                                d.dst_id,
2690                                d.fired,
2691                                &mut self.props,
2692                                &build_topo_view(&self.topo, &self.base),
2693                                &self.ids,
2694                                &self.syms,
2695                                &self.labels,
2696                                self.base.as_ref().map(|b| {
2697                                    b.columns()
2698                                        .expect("base columns section bounds validated at open")
2699                                }),
2700                            );
2701                        }
2702                    }
2703                }
2704            }
2705            WalRecord::SetPropId { id, field, value } => {
2706                if self.ids.is_tombstoned(*id) || self.ids.key_of(*id).is_none() {
2707                    return Ok(());
2708                }
2709                let field_str = self
2710                    .syms
2711                    .resolve(*field)
2712                    .ok_or_else(|| GraphError::Corrupt {
2713                        detail: format!("wal SetPropId unknown field intern {field}"),
2714                    })?
2715                    .to_string();
2716                let old_value = build_props_view(&self.props, &self.base)
2717                    .get(*id, &field_str)
2718                    .map(|vr| vr.into_value());
2719                self.props.set(*id, &field_str, value.clone());
2720                let cursor = self.engine.pending_delta_count();
2721                let mut eng = std::mem::take(&mut self.engine);
2722                {
2723                    let mut gm = make_graph_mut(
2724                        &self.ids,
2725                        &mut self.syms,
2726                        &self.labels,
2727                        build_props_view(&self.props, &self.base),
2728                        &mut self.topo,
2729                        &mut self.edge_props,
2730                    );
2731                    eng.on_node_changed(*id, Some((field_str.as_str(), old_value)), &mut gm);
2732                }
2733                self.engine = eng;
2734                if !self.view_store.is_empty() {
2735                    #[cfg(test)]
2736                    DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
2737                    let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
2738                    for d in &new_deltas {
2739                        self.view_store.on_edge_changed(
2740                            d.etype_sym,
2741                            d.src_id,
2742                            d.dst_id,
2743                            d.fired,
2744                            &mut self.props,
2745                            &build_topo_view(&self.topo, &self.base),
2746                            &self.ids,
2747                            &self.syms,
2748                            &self.labels,
2749                            self.base.as_ref().map(|b| {
2750                                b.columns()
2751                                    .expect("base columns section bounds validated at open")
2752                            }),
2753                        );
2754                    }
2755                }
2756                self.view_store.on_prop_changed(
2757                    *id,
2758                    &field_str,
2759                    &mut self.props,
2760                    &build_topo_view(&self.topo, &self.base),
2761                    &self.ids,
2762                    &self.syms,
2763                    &self.labels,
2764                    self.base.as_ref().map(|b| {
2765                        b.columns()
2766                            .expect("base columns section bounds validated at open")
2767                    }),
2768                );
2769                if self.fulltext.field_indexed(&field_str) {
2770                    let label_opt = self.labels.get(*id as usize).and_then(|&sym| {
2771                        if sym == u32::MAX {
2772                            None
2773                        } else {
2774                            self.syms.resolve(sym)
2775                        }
2776                    });
2777                    if let Some(label) = label_opt {
2778                        if self.fulltext.is_enabled(label, &field_str) {
2779                            self.fulltext.remove_node_field(*id, &field_str);
2780                            self.fulltext.add_tokens(*id, &field_str, value);
2781                        }
2782                    }
2783                }
2784                if self.prop_index.field_indexed(&field_str) {
2785                    let label_opt = self.labels.get(*id as usize).and_then(|&sym| {
2786                        if sym == u32::MAX {
2787                            None
2788                        } else {
2789                            self.syms.resolve(sym)
2790                        }
2791                    });
2792                    if let Some(label) = label_opt {
2793                        self.prop_index.set(label, &field_str, *id, value);
2794                    }
2795                }
2796            }
2797            WalRecord::CreateRule { def_bytes } => {
2798                let def: RuleDef = decode_rule_def(def_bytes).map_err(|e| GraphError::Corrupt {
2799                    detail: format!("CreateRule def_bytes deserialize failed: {e}"),
2800                })?;
2801                // Replay-over-snapshot idempotency: the rule was captured in the snapshot
2802                // so the engine already has it; silently skip to avoid a spurious
2803                // RuleInvalid error in the crash window between snapshot write and WAL
2804                // truncation.
2805                if self.engine.rules().any(|r| r.name == def.name) {
2806                    return Ok(());
2807                }
2808                let cursor = self.engine.pending_delta_count();
2809                let mut eng = std::mem::take(&mut self.engine);
2810                let result = {
2811                    let mut gm = make_graph_mut(
2812                        &self.ids,
2813                        &mut self.syms,
2814                        &self.labels,
2815                        build_props_view(&self.props, &self.base),
2816                        &mut self.topo,
2817                        &mut self.edge_props,
2818                    );
2819                    eng.create_rule(def, &mut gm)
2820                };
2821                self.engine = eng;
2822                result.map_err(|e| GraphError::RuleInvalid { detail: e })?;
2823                // Derived-edge fires from backfill → view updates.
2824                // Fast path: skip O(edge_count) allocation when no views exist.
2825                if !self.view_store.is_empty() {
2826                    #[cfg(test)]
2827                    DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
2828                    let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
2829                    for d in &new_deltas {
2830                        self.view_store.on_edge_changed(
2831                            d.etype_sym,
2832                            d.src_id,
2833                            d.dst_id,
2834                            d.fired,
2835                            &mut self.props,
2836                            &build_topo_view(&self.topo, &self.base),
2837                            &self.ids,
2838                            &self.syms,
2839                            &self.labels,
2840                            self.base.as_ref().map(|b| {
2841                                b.columns()
2842                                    .expect("base columns section bounds validated at open")
2843                            }),
2844                        );
2845                    }
2846                }
2847            }
2848            WalRecord::DeleteRule { name } => {
2849                // Replay-over-snapshot idempotency: the snapshot already captured the
2850                // post-delete state so the rule is absent; silently skip to avoid a
2851                // spurious RuleNotFound error in the crash window between snapshot write
2852                // and WAL truncation.
2853                if !self.engine.rules().any(|r| r.name == *name) {
2854                    return Ok(());
2855                }
2856                let cursor = self.engine.pending_delta_count();
2857                let mut eng = std::mem::take(&mut self.engine);
2858                let result = {
2859                    let mut gm = make_graph_mut(
2860                        &self.ids,
2861                        &mut self.syms,
2862                        &self.labels,
2863                        build_props_view(&self.props, &self.base),
2864                        &mut self.topo,
2865                        &mut self.edge_props,
2866                    );
2867                    eng.delete_rule(name, &mut gm)
2868                };
2869                self.engine = eng;
2870                result.map_err(|_| GraphError::RuleNotFound { name: name.clone() })?;
2871                // Derived-edge retractions → view updates.
2872                if !self.view_store.is_empty() {
2873                    #[cfg(test)]
2874                    DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
2875                    let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
2876                    for d in &new_deltas {
2877                        self.view_store.on_edge_changed(
2878                            d.etype_sym,
2879                            d.src_id,
2880                            d.dst_id,
2881                            d.fired,
2882                            &mut self.props,
2883                            &build_topo_view(&self.topo, &self.base),
2884                            &self.ids,
2885                            &self.syms,
2886                            &self.labels,
2887                            self.base.as_ref().map(|b| {
2888                                b.columns()
2889                                    .expect("base columns section bounds validated at open")
2890                            }),
2891                        );
2892                    }
2893                }
2894            }
2895            WalRecord::RemoveProp { key, field } => {
2896                // Recovery-safe: unknown key or already-absent field is a
2897                // clean no-op. Crash-window replay over a snapshot that
2898                // already applied this record must not Err.
2899                let Some(id) = self.ids.get(key) else {
2900                    return Ok(());
2901                };
2902                // Read old value through the seam for rule retraction.
2903                let old = build_props_view(&self.props, &self.base)
2904                    .get(id, field)
2905                    .map(|vr| vr.into_value());
2906                self.props.remove(id, field);
2907                // If the base still supplies the value after the overlay removal,
2908                // record a tombstone so ColumnsView::get does not resurrect it.
2909                // This covers both the base-only case AND the both-resident case:
2910                //   base-only (in_overlay=false): old prop was only in base, remove
2911                //     is a no-op on overlay, base still visible → tombstone needed.
2912                //   both-resident (in_overlay=true): overlay had v2, base has v1;
2913                //     removing overlay uncovers v1 → tombstone needed.
2914                // Idempotent on double-replay: second pass sees the tombstone →
2915                // get() returns None → condition is false → no duplicate tombstone.
2916                if build_props_view(&self.props, &self.base)
2917                    .get(id, field)
2918                    .is_some()
2919                {
2920                    self.props.record_prop_tombstone(id, field);
2921                }
2922                let cursor = self.engine.pending_delta_count();
2923                let mut eng = std::mem::take(&mut self.engine);
2924                {
2925                    let mut gm = make_graph_mut(
2926                        &self.ids,
2927                        &mut self.syms,
2928                        &self.labels,
2929                        build_props_view(&self.props, &self.base),
2930                        &mut self.topo,
2931                        &mut self.edge_props,
2932                    );
2933                    eng.on_node_changed(id, Some((field, old)), &mut gm);
2934                }
2935                self.engine = eng;
2936                // Derived-edge deltas → view updates.
2937                if !self.view_store.is_empty() {
2938                    #[cfg(test)]
2939                    DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
2940                    let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
2941                    for d in &new_deltas {
2942                        self.view_store.on_edge_changed(
2943                            d.etype_sym,
2944                            d.src_id,
2945                            d.dst_id,
2946                            d.fired,
2947                            &mut self.props,
2948                            &build_topo_view(&self.topo, &self.base),
2949                            &self.ids,
2950                            &self.syms,
2951                            &self.labels,
2952                            self.base.as_ref().map(|b| {
2953                                b.columns()
2954                                    .expect("base columns section bounds validated at open")
2955                            }),
2956                        );
2957                    }
2958                }
2959                // Neighbor-aggregate views that read `field` must also update.
2960                self.view_store.on_prop_changed(
2961                    id,
2962                    field,
2963                    &mut self.props,
2964                    &build_topo_view(&self.topo, &self.base),
2965                    &self.ids,
2966                    &self.syms,
2967                    &self.labels,
2968                    self.base.as_ref().map(|b| {
2969                        b.columns()
2970                            .expect("base columns section bounds validated at open")
2971                    }),
2972                );
2973                // Full-text index maintenance: remove tokens for this field.
2974                if self.fulltext.field_indexed(field) {
2975                    self.fulltext.remove_node_field(id, field);
2976                }
2977                // Property (equality) index maintenance: drop this node's entry.
2978                if self.prop_index.field_indexed(field) {
2979                    if let Some(label) = self.labels.get(id as usize).and_then(|&sym| {
2980                        (sym != u32::MAX).then(|| self.syms.resolve(sym)).flatten()
2981                    }) {
2982                        self.prop_index.remove_node(label, field, id);
2983                    }
2984                }
2985            }
2986            WalRecord::DeleteEdge {
2987                edge_type,
2988                src_key,
2989                dst_key,
2990            } => {
2991                // Recovery-safe: unknown keys, unknown etype, or already-
2992                // absent edge is a clean no-op (remove_edge returns false).
2993                let Some(src) = self.ids.get(src_key) else {
2994                    return Ok(());
2995                };
2996                let Some(dst) = self.ids.get(dst_key) else {
2997                    return Ok(());
2998                };
2999                let Some(etype) = self.syms.get(edge_type) else {
3000                    return Ok(());
3001                };
3002                // I3: phantom-tombstone guard.  When a V8 base is present, a
3003                // DeleteEdge WAL record for an edge that was already absorbed into
3004                // the new base (i.e. neither in overlay nor in base) must be skipped.
3005                // Without this guard, remove_edge records a tombstone for an edge
3006                // that no longer exists, incorrectly understating edge_count.
3007                if self.base.is_some()
3008                    && !self
3009                        .topo_view()
3010                        .neighbors(etype, core_storage::topology::Direction::Out, src)
3011                        .contains(&dst)
3012                {
3013                    return Ok(());
3014                }
3015                self.topo.remove_edge(etype, src, dst);
3016                self.edge_props.remove_edge(etype, src, dst);
3017                // View maintenance for manual edge delete (topo already updated above).
3018                self.view_store.on_edge_changed(
3019                    etype,
3020                    src,
3021                    dst,
3022                    false,
3023                    &mut self.props,
3024                    &build_topo_view(&self.topo, &self.base),
3025                    &self.ids,
3026                    &self.syms,
3027                    &self.labels,
3028                    self.base.as_ref().map(|b| {
3029                        b.columns()
3030                            .expect("base columns section bounds validated at open")
3031                    }),
3032                );
3033                // Rule engine: via-hop rules must retract when user via-edges are deleted.
3034                let cursor = self.engine.pending_delta_count();
3035                let mut eng = std::mem::take(&mut self.engine);
3036                {
3037                    let mut gm = make_graph_mut(
3038                        &self.ids,
3039                        &mut self.syms,
3040                        &self.labels,
3041                        build_props_view(&self.props, &self.base),
3042                        &mut self.topo,
3043                        &mut self.edge_props,
3044                    );
3045                    eng.on_edge_changed(edge_type, src, dst, &mut gm);
3046                }
3047                self.engine = eng;
3048                if !self.view_store.is_empty() {
3049                    let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
3050                    for d in &new_deltas {
3051                        self.view_store.on_edge_changed(
3052                            d.etype_sym,
3053                            d.src_id,
3054                            d.dst_id,
3055                            d.fired,
3056                            &mut self.props,
3057                            &build_topo_view(&self.topo, &self.base),
3058                            &self.ids,
3059                            &self.syms,
3060                            &self.labels,
3061                            self.base.as_ref().map(|b| {
3062                                b.columns()
3063                                    .expect("base columns section bounds validated at open")
3064                            }),
3065                        );
3066                    }
3067                }
3068            }
3069            WalRecord::DeleteNode { key } => {
3070                // Recovery-safe: already-tombstoned / unknown key is a clean
3071                // no-op. Crash-window replay over a snapshot that already
3072                // applied this record cannot recover the retired id from the
3073                // key (`IdMap::get` is None), so every subsequent step is
3074                // skipped. Each step is independently idempotent if invoked
3075                // twice on a still-live id: retraction is a no-op on empty
3076                // provenance, `remove_edge` returns false, `remove_all` is a
3077                // no-op, `ids.delete` returns None, label sentinel is sticky.
3078                let Some(n) = self.ids.get(key) else {
3079                    return Ok(());
3080                };
3081
3082                // (1) Retract derived edges + de-index while props/labels live.
3083                let cursor = self.engine.pending_delta_count();
3084                let mut eng = std::mem::take(&mut self.engine);
3085                {
3086                    let mut gm = make_graph_mut(
3087                        &self.ids,
3088                        &mut self.syms,
3089                        &self.labels,
3090                        build_props_view(&self.props, &self.base),
3091                        &mut self.topo,
3092                        &mut self.edge_props,
3093                    );
3094                    eng.on_node_removed(n, &mut gm);
3095                }
3096                self.engine = eng;
3097                // Derived-edge retractions → view updates for neighbors.
3098                if !self.view_store.is_empty() {
3099                    #[cfg(test)]
3100                    DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
3101                    let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
3102                    for d in &new_deltas {
3103                        self.view_store.on_edge_changed(
3104                            d.etype_sym,
3105                            d.src_id,
3106                            d.dst_id,
3107                            d.fired,
3108                            &mut self.props,
3109                            &build_topo_view(&self.topo, &self.base),
3110                            &self.ids,
3111                            &self.syms,
3112                            &self.labels,
3113                            self.base.as_ref().map(|b| {
3114                                b.columns()
3115                                    .expect("base columns section bounds validated at open")
3116                            }),
3117                        );
3118                    }
3119                }
3120
3121                // (2) Sweep ALL remaining edges incident to n, both directions,
3122                // every etype. This cascade is intentionally mask-independent:
3123                // topology integrity requires removing every edge touching the
3124                // deleted node regardless of the caller's visibility scope.
3125                // (The mask limits which nodes a role's read phase can return;
3126                // the WAL delete always executes with full storage authority.)
3127                // Collect then remove so neighbor slices stay valid during
3128                // iteration. Remove from topo first, then call view maintenance
3129                // so Avg/Min/Max recompute sees the correct (reduced) neighbor set.
3130                let etypes: Vec<u32> = self.topo.etypes().collect();
3131                let mut doomed = Vec::new();
3132                for et in &etypes {
3133                    for &dst in self.topo.neighbors(*et, Direction::Out, n).as_ref() {
3134                        doomed.push((*et, n, dst));
3135                    }
3136                    for &src in self.topo.neighbors(*et, Direction::In, n).as_ref() {
3137                        doomed.push((*et, src, n));
3138                    }
3139                }
3140                for (et, s, d) in doomed {
3141                    self.topo.remove_edge(et, s, d);
3142                    self.edge_props.remove_edge(et, s, d);
3143                    // View maintenance: n's own view values will be cleared by
3144                    // remove_all below; only update surviving neighbors.
3145                    self.view_store.on_edge_changed(
3146                        et,
3147                        s,
3148                        d,
3149                        false,
3150                        &mut self.props,
3151                        &build_topo_view(&self.topo, &self.base),
3152                        &self.ids,
3153                        &self.syms,
3154                        &self.labels,
3155                        self.base.as_ref().map(|b| {
3156                            b.columns()
3157                                .expect("base columns section bounds validated at open")
3158                        }),
3159                    );
3160                }
3161
3162                // (3) Drop every remaining prop (`ColumnStore::remove_all`).
3163                self.props.remove_all(n);
3164                // Full-text index maintenance: remove all tokens for this node.
3165                self.fulltext.remove_node(n);
3166                // Property (equality) index maintenance: drop all entries for n.
3167                self.prop_index.remove_node_all(n);
3168
3169                // (4) Retire the dense id and stamp the label sentinel.
3170                self.ids.delete(key);
3171                if let Some(slot) = self.labels.get_mut(n as usize) {
3172                    *slot = u32::MAX;
3173                }
3174            }
3175            WalRecord::Batch(inner) => {
3176                // Apply each inner record in order through the same apply path.
3177                // Inner records are validated free of nested Batch by encode_record.
3178                for rec in inner {
3179                    self.apply(rec)?;
3180                }
3181            }
3182            WalRecord::RebuildRule { name } => {
3183                // Replay-over-snapshot idempotency: the snapshot may already
3184                // reflect a later delete_rule, so the rule is absent; skip.
3185                if !self.engine.rules().any(|r| r.name == *name) {
3186                    return Ok(());
3187                }
3188                let cursor = self.engine.pending_delta_count();
3189                let mut eng = std::mem::take(&mut self.engine);
3190                let result = {
3191                    let mut gm = make_graph_mut(
3192                        &self.ids,
3193                        &mut self.syms,
3194                        &self.labels,
3195                        build_props_view(&self.props, &self.base),
3196                        &mut self.topo,
3197                        &mut self.edge_props,
3198                    );
3199                    eng.rebuild(name, &mut gm)
3200                };
3201                self.engine = eng;
3202                result.map_err(|_| GraphError::RuleNotFound { name: name.clone() })?;
3203                // Derived-edge delta changes → view updates.
3204                if !self.view_store.is_empty() {
3205                    #[cfg(test)]
3206                    DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
3207                    let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
3208                    for d in &new_deltas {
3209                        self.view_store.on_edge_changed(
3210                            d.etype_sym,
3211                            d.src_id,
3212                            d.dst_id,
3213                            d.fired,
3214                            &mut self.props,
3215                            &build_topo_view(&self.topo, &self.base),
3216                            &self.ids,
3217                            &self.syms,
3218                            &self.labels,
3219                            self.base.as_ref().map(|b| {
3220                                b.columns()
3221                                    .expect("base columns section bounds validated at open")
3222                            }),
3223                        );
3224                    }
3225                }
3226            }
3227            WalRecord::CreateView { def_bytes } => {
3228                let def: ViewDef =
3229                    bincode::deserialize(def_bytes).map_err(|e| GraphError::Corrupt {
3230                        detail: format!("CreateView def_bytes deserialize failed: {e}"),
3231                    })?;
3232                // Replay-over-snapshot idempotency: view already present → skip.
3233                if self.view_store.has_view(&def.name) {
3234                    return Ok(());
3235                }
3236                self.view_store
3237                    .create_view(
3238                        def,
3239                        &mut self.props,
3240                        &build_topo_view(&self.topo, &self.base),
3241                        &self.ids,
3242                        &self.syms,
3243                        &self.labels,
3244                    )
3245                    .map_err(|e| GraphError::RuleInvalid { detail: e })?;
3246            }
3247            WalRecord::DeleteView { name } => {
3248                // Replay-over-snapshot idempotency: view already absent → skip.
3249                if !self.view_store.has_view(name) {
3250                    return Ok(());
3251                }
3252                self.view_store
3253                    .delete_view(name, &mut self.props, &self.ids, &self.labels, &self.syms)
3254                    .map_err(|_| GraphError::RuleNotFound { name: name.clone() })?;
3255            }
3256            WalRecord::EnableFulltext { label, field } => {
3257                // Replay-over-snapshot idempotency: already enabled → skip.
3258                if self.fulltext.is_enabled(label, field) {
3259                    return Ok(());
3260                }
3261                self.fulltext.enable(label, field);
3262                // Backfill: index all live nodes of this label that have the field.
3263                let n = self.ids.len() as u32;
3264                for id in 0..n {
3265                    let Some(&sym) = self.labels.get(id as usize) else {
3266                        continue;
3267                    };
3268                    if sym == u32::MAX {
3269                        continue; // tombstoned
3270                    }
3271                    let Some(lbl) = self.syms.resolve(sym) else {
3272                        continue;
3273                    };
3274                    if lbl != label {
3275                        continue;
3276                    }
3277                    if let Some(value) = build_props_view(&self.props, &self.base)
3278                        .get(id, field)
3279                        .map(|vr| vr.into_value())
3280                    {
3281                        self.fulltext.add_tokens(id, field, &value);
3282                    }
3283                }
3284            }
3285            WalRecord::DisableFulltext { label, field } => {
3286                // Replay-over-snapshot idempotency: already disabled → skip.
3287                if !self.fulltext.is_enabled(label, field) {
3288                    return Ok(());
3289                }
3290                // If another label still indexes this field, the postings column
3291                // is kept — but it must not contain node_ids from the now-disabled
3292                // label.  Remove them before calling disable() so the field_indexed
3293                // guard inside disable() sees the correct post-removal state.
3294                if self.fulltext.field_indexed_by_other(label, field) {
3295                    if let Some(label_sym) = self.syms.get(label) {
3296                        for (node_id, &lsym) in self.labels.iter().enumerate() {
3297                            if lsym == label_sym {
3298                                self.fulltext.remove_node_field(node_id as u32, field);
3299                            }
3300                        }
3301                    }
3302                }
3303                self.fulltext.disable(label, field);
3304            }
3305            WalRecord::EnableIndex { label, field } => {
3306                // Replay-over-snapshot idempotency: already enabled → skip.
3307                if self.prop_index.is_enabled(label, field) {
3308                    return Ok(());
3309                }
3310                self.prop_index.enable(label, field);
3311                // Backfill: index all live nodes of this label that have the field.
3312                let n = self.ids.len() as u32;
3313                for id in 0..n {
3314                    let Some(&sym) = self.labels.get(id as usize) else {
3315                        continue;
3316                    };
3317                    if sym == u32::MAX {
3318                        continue; // tombstoned
3319                    }
3320                    let Some(lbl) = self.syms.resolve(sym) else {
3321                        continue;
3322                    };
3323                    if lbl != label {
3324                        continue;
3325                    }
3326                    if let Some(value) = build_props_view(&self.props, &self.base)
3327                        .get(id, field)
3328                        .map(|vr| vr.into_value())
3329                    {
3330                        self.prop_index.set(label, field, id, &value);
3331                    }
3332                }
3333            }
3334            WalRecord::DisableIndex { label, field } => {
3335                self.prop_index.disable(label, field);
3336            }
3337            // History markers carry no replay state — rules re-derive edges
3338            // deterministically on open/replay. Skip unconditionally.
3339            WalRecord::DerivedEdgeAdded { .. } | WalRecord::DerivedEdgeRetracted { .. } => {}
3340            // ── rename_node ──────────────────────────────────────────────────
3341            WalRecord::RenameNode { old_key, new_key } => {
3342                // Recovery-safe: if old_key is already gone (key was renamed
3343                // by a snapshot or a prior replay frame), skip cleanly.
3344                if self.ids.get(old_key).is_none() {
3345                    return Ok(());
3346                }
3347                // The rename only updates the key-table; the dense id, all
3348                // topo edges, props, labels, and rule state are id-indexed and
3349                // require no change.
3350                self.ids
3351                    .rename(old_key, new_key)
3352                    .map_err(|e| GraphError::Corrupt {
3353                        detail: format!("wal replay RenameNode {old_key}→{new_key}: {e}"),
3354                    })?;
3355            }
3356        }
3357        Ok(())
3358    }
3359
3360    /// Intern `s` in `syms` and emit a WAL `Intern` record so `*Id` records
3361    /// replay on WAL-only `open_at` (no snapshot intern table). Apply is
3362    /// idempotent when the string is already bound. Always emit: after
3363    /// `snapshot()` the WAL is truncated and live intern is not on disk.
3364    fn intern_wal(&mut self, s: &str) -> (u32, WalRecord) {
3365        let id = if let Some(id) = self.syms.get(s) {
3366            id
3367        } else {
3368            self.syms.intern(s)
3369        };
3370        (
3371            id,
3372            WalRecord::Intern {
3373                id,
3374                text: s.to_string(),
3375            },
3376        )
3377    }
3378
3379    /// Rewrite user-facing records into dense-id records. On `Err`, no live
3380    /// state is left mutated: speculative interns made while building the
3381    /// output are rolled back, so a later successful mutation cannot log an
3382    /// `Intern` record whose id replay would never reproduce.
3383    fn rewrite_wal_dense(&mut self, recs: Vec<WalRecord>) -> Result<Vec<WalRecord>> {
3384        let syms_checkpoint = self.syms.len();
3385        let result = self.rewrite_wal_dense_inner(recs);
3386        if result.is_err() {
3387            self.syms.truncate(syms_checkpoint);
3388        }
3389        result
3390    }
3391
3392    fn rewrite_wal_dense_inner(&mut self, recs: Vec<WalRecord>) -> Result<Vec<WalRecord>> {
3393        let mut out = Vec::with_capacity(recs.len());
3394        // Node ids allocated by later apply(InsertNodeId) in this same batch.
3395        let mut pending: std::collections::HashMap<String, u32> = std::collections::HashMap::new();
3396        let mut interned = std::collections::HashSet::<u32>::new();
3397        let mut next = u32::try_from(self.ids.len()).map_err(|_| GraphError::Corrupt {
3398            detail: "id space exhausted".into(),
3399        })?;
3400        let lookup = |ids: &IdMap,
3401                      pending: &std::collections::HashMap<String, u32>,
3402                      key: &str|
3403         -> Option<u32> { ids.get(key).or_else(|| pending.get(key).copied()) };
3404        for rec in recs {
3405            match rec {
3406                WalRecord::InsertNode { label, key, props } => {
3407                    let (label_id, intern) = self.intern_wal(&label);
3408                    if interned.insert(label_id) {
3409                        out.push(intern);
3410                    }
3411                    let mut props_id = Vec::with_capacity(props.len());
3412                    for (field, value) in props {
3413                        let (field_id, intern) = self.intern_wal(&field);
3414                        if interned.insert(field_id) {
3415                            out.push(intern);
3416                        }
3417                        props_id.push((field_id, value));
3418                    }
3419                    if lookup(&self.ids, &pending, &key).is_none() {
3420                        pending.insert(key.clone(), next);
3421                        next = next.checked_add(1).ok_or_else(|| GraphError::Corrupt {
3422                            detail: "id space exhausted".into(),
3423                        })?;
3424                    }
3425                    out.push(WalRecord::InsertNodeId {
3426                        label: label_id,
3427                        key,
3428                        props: props_id,
3429                    });
3430                }
3431                WalRecord::SetProp { key, field, value } => {
3432                    let id =
3433                        lookup(&self.ids, &pending, &key).ok_or_else(|| GraphError::Corrupt {
3434                            detail: format!("dense WAL rewrite missing key {key}"),
3435                        })?;
3436                    let (field_id, intern) = self.intern_wal(&field);
3437                    if interned.insert(field_id) {
3438                        out.push(intern);
3439                    }
3440                    out.push(WalRecord::SetPropId {
3441                        id,
3442                        field: field_id,
3443                        value,
3444                    });
3445                }
3446                WalRecord::InsertEdge {
3447                    edge_type,
3448                    src_key,
3449                    dst_key,
3450                } => {
3451                    let (etype, intern) = self.intern_wal(&edge_type);
3452                    if interned.insert(etype) {
3453                        out.push(intern);
3454                    }
3455                    let src = lookup(&self.ids, &pending, &src_key).ok_or_else(|| {
3456                        GraphError::Corrupt {
3457                            detail: format!("dense WAL rewrite missing src {src_key}"),
3458                        }
3459                    })?;
3460                    let dst = lookup(&self.ids, &pending, &dst_key).ok_or_else(|| {
3461                        GraphError::Corrupt {
3462                            detail: format!("dense WAL rewrite missing dst {dst_key}"),
3463                        }
3464                    })?;
3465                    out.push(WalRecord::InsertEdgeId { etype, src, dst });
3466                }
3467                WalRecord::RenameNode {
3468                    ref old_key,
3469                    ref new_key,
3470                } => {
3471                    // Track the rename in `pending` so subsequent InsertEdge /
3472                    // SetProp records in this batch can resolve the new key.
3473                    let id = lookup(&self.ids, &pending, old_key).ok_or_else(|| {
3474                        GraphError::Corrupt {
3475                            detail: format!(
3476                                "dense WAL rewrite: RenameNode old key {old_key} not found"
3477                            ),
3478                        }
3479                    })?;
3480                    pending.remove(old_key.as_str());
3481                    pending.insert(new_key.clone(), id);
3482                    out.push(rec);
3483                }
3484                // # Symbol-order invariant (load-bearing)
3485                //
3486                // Write-time and replay-time symbol assignment must agree: every
3487                // symbol in a `Batch` frame has to receive the same dense id when
3488                // the frame's records are replayed in order as it received when
3489                // the frame was written.
3490                //
3491                // A rule's backfill interns its `edge_type` lazily
3492                // (`core_rules::engine`, every `g.syms.intern(&def.edge_type)`
3493                // site), and that backfill runs from `apply` — during the
3494                // `CreateRule` record itself, and again from any later
3495                // `InsertNodeId` in the same frame that makes the rule fire. At
3496                // write time the whole batch is rewritten before any of it is
3497                // applied, so a later `InsertEdge` in the same batch would win the
3498                // lower id for its edge type; on replay the rule's lazy intern
3499                // gets there first and steals it, and the `Intern` record fails at
3500                // the `wal intern assigned …` check in `apply`.
3501                //
3502                // Pre-interning the rule's `edge_type` here, and emitting its
3503                // `Intern` record ahead of the `CreateRule` record, makes both
3504                // orders identical. `weight_prop` needs no pre-intern:
3505                // `EdgeProps::set` keys props by `String`, never through the
3506                // interner. `via_edge` needs none either: via-hop rules resolve it
3507                // with `syms.get` and skip when it is absent.
3508                //
3509                // `RebuildRule` and `DeleteRule` need no such handling here:
3510                // `RebuildRule` has no `BatchOp` variant, so it never appears
3511                // inside a `Batch` today — it is only ever issued as its own
3512                // standalone commit (`rebuild_rule`, or the auto-rebuild path
3513                // that logs it as a second commit after the triggering op).
3514                // `DeleteRule` does have a `BatchOp` variant and can appear
3515                // inside a `Batch`, but it carries only a rule `name` — no
3516                // `edge_type` or other symbol that needs pre-interning — so
3517                // only `CreateRule` needs this arm.
3518                WalRecord::CreateRule { ref def_bytes } => {
3519                    let def = decode_rule_def(def_bytes).map_err(|e| GraphError::Corrupt {
3520                        detail: format!("CreateRule def_bytes deserialize failed: {e}"),
3521                    })?;
3522                    let (etype, intern) = self.intern_wal(&def.edge_type);
3523                    if interned.insert(etype) {
3524                        out.push(intern);
3525                    }
3526                    out.push(rec);
3527                }
3528                other => out.push(other),
3529            }
3530        }
3531        Ok(out)
3532    }
3533
3534    fn log_dense(&mut self, recs: Vec<WalRecord>) -> Result<()> {
3535        let recs = self.rewrite_wal_dense(recs)?;
3536        match recs.len() {
3537            0 => Ok(()),
3538            1 => self.log_then_apply(recs.into_iter().next().unwrap()),
3539            _ => self.log_then_apply(WalRecord::Batch(recs)),
3540        }
3541    }
3542
3543    /// Durable write, then notify the event sink. Replay (`apply` during
3544    /// `open`) never enters this function, so it is the replay-silent seam.
3545    fn log_then_apply(&mut self, rec: WalRecord) -> Result<()> {
3546        self.log_then_apply_with(rec, None, self.fsync)
3547    }
3548
3549    /// Whether this frame must fsync under `policy`.
3550    ///
3551    /// Batched contract: user-visible batches (>1 mutation) fsync; single
3552    /// mutations do not. The dense rewrite wraps a single mutation in a
3553    /// `Batch([Intern.., <one *Id record>])`, so `Intern` records are excluded
3554    /// from the count — removing that filter would make every single-op write
3555    /// fsync under Batched (or, if the threshold were raised instead, skip a
3556    /// needed fsync for real two-op batches).
3557    fn wal_needs_sync(policy: FsyncPolicy, rec: &WalRecord) -> bool {
3558        match policy {
3559            FsyncPolicy::Relaxed => false,
3560            FsyncPolicy::Strict => true,
3561            FsyncPolicy::Batched => match rec {
3562                // Intern + one mutation is the single-op rewrite, not a user batch.
3563                WalRecord::Batch(inner) => {
3564                    inner
3565                        .iter()
3566                        .filter(|r| !matches!(r, WalRecord::Intern { .. }))
3567                        .count()
3568                        > 1
3569                }
3570                _ => false,
3571            },
3572        }
3573    }
3574
3575    /// # Apply-infallibility invariant (load-bearing)
3576    ///
3577    /// The ordering is: WAL append → fsync → apply. If `apply` returned `Err`
3578    /// for a `Batch` frame after a successful WAL write, the WAL would contain
3579    /// the full frame while in-memory state would reflect only the ops before
3580    /// the failure. On reopen, WAL replay would then apply the entire batch —
3581    /// diverging permanently from what the pre-crash process had in memory.
3582    ///
3583    /// For `Batch` frames this situation cannot arise because:
3584    /// - All validation runs via `commit_logged_batch`/`MutPreview` **before**
3585    ///   the WAL write. `MutPreview` uses the same `&mut self` that apply will
3586    ///   use, with no concurrent mutation between validation exit and apply entry.
3587    /// - Every `apply` arm for a validated op is either infallible by construction
3588    ///   (`InsertNode`, `RemoveProp`, `DeleteEdge`, `DeleteNode`), has idempotency
3589    ///   guards that return `Ok(())` (`CreateRule`, `DeleteRule`), or is
3590    ///   guaranteed-present by validation (`InsertEdge`/`SetProp` key lookups).
3591    /// - `on_node_changed` and `on_node_removed` return `()` — never `Err`.
3592    ///
3593    /// A `debug_assert!` below fires in debug builds if `apply` ever returns
3594    /// `Err` for a `Batch` frame, making any future regression immediately visible
3595    /// in tests rather than silently diverging crash-recovery behaviour.
3596    fn log_then_apply_with(
3597        &mut self,
3598        rec: WalRecord,
3599        ingest: Option<(String, usize)>,
3600        policy: FsyncPolicy,
3601    ) -> Result<()> {
3602        // Read-only guard: as-of instances must never write the WAL.
3603        if self.read_only {
3604            return Err(GraphError::ReadOnly);
3605        }
3606        // Degraded guard: fsync failure left WAL truncated; in-memory state
3607        // is ahead of the on-disk WAL, so further mutations would deepen the
3608        // divergence.  Reopen the database to recover.
3609        if self.degraded {
3610            return Err(GraphError::Io(std::io::Error::other(
3611                "database degraded after group-commit fsync failure; reopen required",
3612            )));
3613        }
3614        // Ensure retained provenance bytes are decoded into the live mutable
3615        // fields before any mutation touches self.engine.provenance.  This is a
3616        // no-op if provenance was never stored (fresh store) or has already been
3617        // consumed (subsequent mutations).  WAL replay calls apply() directly
3618        // and is covered by consume_retained_state_eager before replay.
3619        self.ensure_v8_base_sections_loaded();
3620        self.engine.ensure_provenance_loaded_mut();
3621        // Invariant (I-1): no stale deltas may enter from a previous apply.
3622        // If any engine method ever accumulates deltas before erroring, they would
3623        // contaminate the *next* commit's event stream. This assert fires in debug
3624        // builds, making any future regression visible at the earliest point.
3625        debug_assert_eq!(
3626            self.engine.pending_delta_count(),
3627            0,
3628            "stale engine deltas at log_then_apply_with entry — \
3629             a previous apply arm may have accumulated deltas before erroring; \
3630             the caller must drain_deltas() on any error path before returning"
3631        );
3632        self.fs.append(FileId::Wal, &encode_record(&rec))?;
3633        if Self::wal_needs_sync(policy, &rec) {
3634            self.fs.sync(FileId::Wal)?;
3635        }
3636        // Marker writing always needs the engine deltas, but the engine only
3637        // accumulates them when emit_deltas is true (normally gated on subscribers
3638        // or views being present).  Enable emission for this apply if it is
3639        // currently off, then restore the original state unconditionally via an
3640        // RAII guard — this prevents a panic in apply() from leaking the flag.
3641        // The same guard resets the engine's transient chaining state. A panic
3642        // unwinding out of a rule hook would otherwise leave `chain_depth`
3643        // non-zero, which makes every later `begin_chain` decide chaining is
3644        // already running and silently switch it off for good.
3645        struct RestoreEmitDeltas(*mut RuleEngine, bool);
3646        impl Drop for RestoreEmitDeltas {
3647            fn drop(&mut self) {
3648                // SAFETY: pointer into self (GraphDb); guard is dropped within
3649                // this frame before log_then_apply_with returns.
3650                unsafe {
3651                    (*self.0).set_emit_deltas(self.1);
3652                    (*self.0).reset_chain_state();
3653                }
3654            }
3655        }
3656        let original_emit = self.engine.emit_deltas();
3657        if !original_emit {
3658            self.engine.set_emit_deltas(true);
3659        }
3660        // SAFETY: raw pointer into self; guard dropped within this frame.
3661        let _emit_guard = RestoreEmitDeltas(&mut self.engine as *mut _, original_emit);
3662
3663        let apply_result = self.apply(&rec);
3664        // For Batch frames, post-validation apply must be infallible (see above).
3665        // A debug_assert here catches any future change that makes apply fallible
3666        // before the caller notices via silent WAL/memory divergence.
3667        if matches!(&rec, WalRecord::Batch(_)) {
3668            debug_assert!(
3669                apply_result.is_ok(),
3670                "Batch apply returned Err after successful WAL write — \
3671                 the validate-then-apply invariant has been violated; \
3672                 see log_then_apply_with invariant doc"
3673            );
3674        }
3675        if apply_result.is_err() {
3676            // Discard any partial deltas accumulated by the failed apply.
3677            // They must not ride the next commit's event stream (I-1).
3678            // _emit_guard restores emit_deltas on drop automatically.
3679            let _ = self.engine.drain_deltas();
3680            let _ = self.engine.take_rebuild_needed();
3681            apply_result?;
3682        }
3683        self.commit_seq += 1;
3684        let seq = self.commit_seq;
3685        // Update per-node last-change map for the committed record.
3686        // Must happen after commit_seq is incremented so the seq is correct.
3687        self.update_last_change_from_rec(&rec, seq);
3688        // Drain engine deltas and distribute to subscribers before the existing
3689        // MutationEvent sink fires — both happen post-fsync, post-apply.
3690        // _emit_guard restores emit_deltas after this line when it drops.
3691        let engine_deltas = self.engine.drain_deltas();
3692
3693        // Append history-marker WAL records for any derived-edge changes so
3694        // that `edge_history` and `was_linked` can surface rule-attributed
3695        // events. Markers are STATE NO-OPS during replay; they are written
3696        // without an additional fsync (the triggering commit's sync already
3697        // happened; the next commit's sync covers these lazily).
3698        if !engine_deltas.is_empty() {
3699            let markers: Vec<WalRecord> = engine_deltas
3700                .iter()
3701                .map(|d| {
3702                    if d.fired {
3703                        WalRecord::DerivedEdgeAdded {
3704                            rule: d.rule.clone(),
3705                            edge_type: d.edge_type.clone(),
3706                            src_key: d.src_key.clone(),
3707                            dst_key: d.dst_key.clone(),
3708                        }
3709                    } else {
3710                        WalRecord::DerivedEdgeRetracted {
3711                            rule: d.rule.clone(),
3712                            edge_type: d.edge_type.clone(),
3713                            src_key: d.src_key.clone(),
3714                            dst_key: d.dst_key.clone(),
3715                        }
3716                    }
3717                })
3718                .collect();
3719            let marker_frame = if markers.len() == 1 {
3720                markers.into_iter().next().unwrap()
3721            } else {
3722                WalRecord::Batch(markers)
3723            };
3724            // Ignore append errors: markers are best-effort history
3725            // annotations. Losing them does not affect state correctness.
3726            let _ = self.fs.append(FileId::Wal, &encode_record(&marker_frame));
3727        }
3728
3729        // Record MVCC CommitDelta for the epoch reader.  The WAL record is
3730        // stored as-is (including any nested Batch / Intern records); the
3731        // ReaderSnapshot's apply_one function handles all variants.
3732        {
3733            let derived_inserts = engine_deltas
3734                .iter()
3735                .filter(|d| d.fired)
3736                .map(|d| (d.etype_sym, d.src_id, d.dst_id))
3737                .collect();
3738            let derived_deletes = engine_deltas
3739                .iter()
3740                .filter(|d| !d.fired)
3741                .map(|d| (d.etype_sym, d.src_id, d.dst_id))
3742                .collect();
3743            let delta = Arc::new(crate::reader::CommitDelta {
3744                records: vec![rec.clone()],
3745                derived_inserts,
3746                derived_deletes,
3747            });
3748            self.delta_tail.push(delta);
3749            self.commits_since_fold += 1;
3750            if self.commits_since_fold >= crate::reader::FOLD_EVERY_K {
3751                self.fold_now();
3752            }
3753        }
3754
3755        if self.defer_events {
3756            // Group-commit drain thread: hold events until after the group
3757            // fsync so subscribers only observe durable data (R2).
3758            self.deferred_events.push(DeferredEvent {
3759                rec: rec.clone(),
3760                engine_deltas,
3761                seq,
3762                ingest,
3763            });
3764        } else {
3765            self.distribute_events(&rec, &engine_deltas, seq);
3766            self.emit_committed(&rec, ingest);
3767        }
3768        // Drift is only known after apply, so auto-rebuild cannot join the
3769        // triggering op's WAL frame. Issue RebuildRule as a second commit.
3770        // Skip when `rec` is itself RebuildRule: rebuild resets drift, so a
3771        // retrigger loop is impossible if the fit succeeded, but we still
3772        // drain the flag so a leftover cannot re-enter.
3773        let rebuilds = self.engine.take_rebuild_needed();
3774        if !matches!(&rec, WalRecord::RebuildRule { .. }) {
3775            let mut failed = Vec::new();
3776            for name in rebuilds {
3777                if self.engine.rules().any(|r| r.name == name) {
3778                    // User op is already durable. A failed second commit must
3779                    // not surface as the caller's error.
3780                    if let Err(e) =
3781                        self.log_then_apply(WalRecord::RebuildRule { name: name.clone() })
3782                    {
3783                        eprintln!(
3784                            "auto-rebuild of rule {name:?} failed after durable user commit: {e}"
3785                        );
3786                        failed.push(name);
3787                    }
3788                }
3789            }
3790            for name in failed {
3791                self.engine.queue_rebuild_needed(name);
3792            }
3793        }
3794        Ok(())
3795    }
3796
3797    /// Install a post-commit hook. Replaces any previous sink.
3798    ///
3799    /// The sink runs inside `log_then_apply` after a successful
3800    /// durable commit, while the caller still holds `&mut self`. When this
3801    /// database is behind a [`crate::SharedDb`], that means the **write
3802    /// guard is held**. The sink must never call `read` / `write` (or any
3803    /// other method) on the same `SharedDb` — the `RwLock` is not
3804    /// re-entrant and doing so deadlocks. The sink is `Send + Sync`;
3805    /// `std::sync::mpsc::Sender` is not `Sync` and will not type-check.
3806    /// Intended examples: `std::sync::mpsc::SyncSender`,
3807    /// `tokio::sync::mpsc::Sender`, `tokio::sync::broadcast::Sender`
3808    /// (non-blocking `send`), or `Arc<Mutex<Vec<MutationEvent>>>`.
3809    pub fn set_event_sink(&mut self, sink: Box<dyn Fn(MutationEvent) + Send + Sync>) {
3810        self.event_sink = Some(sink);
3811    }
3812
3813    /// Whether a post-commit event sink is currently installed.
3814    pub fn has_event_sink(&self) -> bool {
3815        self.event_sink.is_some()
3816    }
3817
3818    /// Set WAL fsync cadence. Default [`FsyncPolicy::Strict`].
3819    pub fn set_fsync_policy(&mut self, p: FsyncPolicy) {
3820        self.fsync = p;
3821    }
3822
3823    /// Return the current WAL fsync cadence.
3824    pub fn fsync_policy(&self) -> FsyncPolicy {
3825        self.fsync
3826    }
3827
3828    // ── Group-commit event deferral ───────────────────────────────────────────
3829
3830    /// Enable or disable deferred event mode.
3831    ///
3832    /// When `true`, event notifications (subscription `DbEvent`s and legacy
3833    /// `MutationEvent` sink calls) are buffered rather than fired immediately.
3834    /// Call [`flush_deferred_events`] after the group fsync to deliver them,
3835    /// or [`discard_deferred_events`] if the fsync failed and the group must
3836    /// be treated as lost.
3837    pub fn set_deferred_events_mode(&mut self, defer: bool) {
3838        self.defer_events = defer;
3839    }
3840
3841    /// Fire all buffered events accumulated since [`set_deferred_events_mode`]
3842    /// was set to true.  Clears the buffer.
3843    ///
3844    /// Called by the drain thread AFTER a successful group fsync, so
3845    /// subscribers observe only data that is durably on disk.
3846    pub fn flush_deferred_events(&mut self) {
3847        let events = std::mem::take(&mut self.deferred_events);
3848        for de in events {
3849            self.distribute_events(&de.rec, &de.engine_deltas, de.seq);
3850            self.emit_committed(&de.rec, de.ingest);
3851        }
3852    }
3853
3854    /// Discard all buffered events without firing them.
3855    ///
3856    /// Called by the drain thread when a group fsync fails: the WAL has been
3857    /// truncated back to the pre-group offset, so the committed-but-unsynced
3858    /// ops must not be observable to subscribers.
3859    pub fn discard_deferred_events(&mut self) {
3860        self.deferred_events.clear();
3861    }
3862
3863    // ── Degraded state ────────────────────────────────────────────────────────
3864
3865    /// Mark this database as degraded.
3866    ///
3867    /// Called by the group-commit drain thread after a group fsync failure and
3868    /// WAL truncation: the in-memory state is now ahead of the on-disk WAL, so
3869    /// further mutations would deepen the divergence.  All subsequent calls to
3870    /// [`log_then_apply_with`] return `Err` until the database is reopened.
3871    pub fn set_degraded(&mut self) {
3872        self.degraded = true;
3873    }
3874
3875    fn emit(&self, ev: MutationEvent) {
3876        if let Some(sink) = &self.event_sink {
3877            sink(ev);
3878        }
3879    }
3880
3881    fn emit_committed(&self, rec: &WalRecord, ingest: Option<(String, usize)>) {
3882        match rec {
3883            WalRecord::Batch(inner) => {
3884                for r in inner {
3885                    if let Some(ev) = event_from_record(r, &self.syms, &self.ids) {
3886                        self.emit(ev);
3887                    }
3888                }
3889                match ingest {
3890                    Some((label, inserted)) => {
3891                        self.emit(MutationEvent::Ingested { label, inserted })
3892                    }
3893                    None => {
3894                        let ops = inner
3895                            .iter()
3896                            .filter(|r| !matches!(r, WalRecord::Intern { .. }))
3897                            .count();
3898                        if ops > 1 {
3899                            self.emit(MutationEvent::BatchApplied { ops });
3900                        }
3901                    }
3902                }
3903            }
3904            other => {
3905                if let Some(ev) = event_from_record(other, &self.syms, &self.ids) {
3906                    self.emit(ev);
3907                }
3908            }
3909        }
3910    }
3911
3912    // -----------------------------------------------------------------------
3913    // Subscription API
3914    // -----------------------------------------------------------------------
3915
3916    /// Distribute post-commit events to all live subscribers.
3917    ///
3918    /// Build a row-key → row-data map from a [`ResultSet`].
3919    ///
3920    /// Each row is serialized to JSON to form its key; a debug fallback is used
3921    /// if serialization fails. Used by both the initial-seed path in
3922    /// [`Self::subscribe_query`] and the per-commit diff path in
3923    /// [`Self::distribute_events`] to keep the two in sync.
3924    fn result_to_row_map(
3925        result: &core_query::ResultSet,
3926    ) -> std::collections::HashMap<String, Vec<Option<Value>>> {
3927        (0..result.len())
3928            .map(|i| {
3929                let row = result.row(i).to_vec();
3930                let key = serde_json::to_string(&row).unwrap_or_else(|_| format!("{row:?}"));
3931                (key, row)
3932            })
3933            .collect()
3934    }
3935
3936    /// Collect the set of label syms touched by a WAL record.
3937    ///
3938    /// Returns `Some(set)` when every record in this commit can be attributed to
3939    /// a known label sym. Returns `None` when the commit must not be skipped:
3940    /// edge records, unresolvable key→label lookups, or any record type not in
3941    /// the explicit handled set.
3942    ///
3943    /// Handled record types and their actions:
3944    /// - `InsertNode`   → look up label in interner (fails → None)
3945    /// - `InsertNodeId` → label sym is carried directly
3946    /// - `SetProp`      → resolve key→id→label (fails → None)
3947    /// - `DeleteNode`   → resolve key→id→label (fails → None)
3948    /// - `Batch`        → recurse into every inner record
3949    /// - `InsertEdge`, `DeleteEdge`, `InsertEdgeId` → always None (edge records)
3950    /// - everything else → None (conservative)
3951    fn commit_touched_labels(
3952        rec: &WalRecord,
3953        syms: &Interner,
3954        ids: &IdMap,
3955        labels: &[u32],
3956    ) -> Option<BTreeSet<u32>> {
3957        let mut out = BTreeSet::new();
3958        if Self::collect_touched_labels(rec, syms, ids, labels, &mut out) {
3959            Some(out)
3960        } else {
3961            None
3962        }
3963    }
3964
3965    fn collect_touched_labels(
3966        rec: &WalRecord,
3967        syms: &Interner,
3968        ids: &IdMap,
3969        labels: &[u32],
3970        out: &mut BTreeSet<u32>,
3971    ) -> bool {
3972        match rec {
3973            // String-key insert: the dense rewrite converts this to
3974            // [Intern, InsertNodeId], so this arm fires only for legacy WAL
3975            // records written before the dense path was added.
3976            WalRecord::InsertNode { label, .. } => {
3977                if let Some(sym) = syms.get(label) {
3978                    out.insert(sym);
3979                    true
3980                } else {
3981                    false
3982                }
3983            }
3984            // Dense-id insert (produced by rewrite_wal_dense for every
3985            // insert_node call in the current codebase).
3986            WalRecord::InsertNodeId { label, .. } => {
3987                out.insert(*label);
3988                true
3989            }
3990            // String-key prop set: dense path converts to [Intern, SetPropId].
3991            WalRecord::SetProp { key, .. } => {
3992                if let Some(sym) = Self::resolve_key_label_sym(key, ids, labels) {
3993                    out.insert(sym);
3994                    true
3995                } else {
3996                    false
3997                }
3998            }
3999            // Dense-id prop set (produced by rewrite_wal_dense for set_prop).
4000            WalRecord::SetPropId { id, .. } => {
4001                if let Some(sym) = labels.get(*id as usize).copied().filter(|&s| s != u32::MAX) {
4002                    out.insert(sym);
4003                    true
4004                } else {
4005                    false
4006                }
4007            }
4008            WalRecord::DeleteNode { key } => {
4009                if let Some(sym) = Self::resolve_key_label_sym(key, ids, labels) {
4010                    out.insert(sym);
4011                    true
4012                } else {
4013                    false
4014                }
4015            }
4016            WalRecord::Batch(inner) => inner
4017                .iter()
4018                .all(|r| Self::collect_touched_labels(r, syms, ids, labels, out)),
4019            // Intern is a pure metadata record — it does not touch any node's
4020            // label and is safe to skip for the label-skip predicate.
4021            WalRecord::Intern { .. } => true,
4022            // Edge records: always re-execute (edges can change join results).
4023            WalRecord::InsertEdge { .. }
4024            | WalRecord::DeleteEdge { .. }
4025            | WalRecord::InsertEdgeId { .. } => false,
4026            _ => false,
4027        }
4028    }
4029
4030    /// Resolve a node key to its label sym via the dense id table.
4031    /// Returns `None` if the key is unknown or the label is a tombstone sentinel.
4032    fn resolve_key_label_sym(key: &str, ids: &IdMap, labels: &[u32]) -> Option<u32> {
4033        let id = ids.get(key)?;
4034        let sym = labels.get(id as usize).copied()?;
4035        (sym != u32::MAX).then_some(sym)
4036    }
4037
4038    /// Distribute post-commit events to all live subscribers.
4039    ///
4040    /// Called from `log_then_apply_with` after apply + fsync, before the
4041    /// legacy MutationEvent sink. Prunes dead `Weak` entries in-place.
4042    ///
4043    /// Query subscriptions (subscribe_query) re-execute their plan on every
4044    /// call and diff the result against the previous run. Zero overhead when
4045    /// no query subscriptions are active.
4046    fn distribute_events(&mut self, rec: &WalRecord, engine_deltas: &[EngineEdgeDelta], seq: u64) {
4047        if self.subscriptions.is_empty() && self.query_subscriptions.is_empty() {
4048            return;
4049        }
4050
4051        if !self.subscriptions.is_empty() {
4052            // Build write events from the WAL record.
4053            let write_events: Vec<DbEvent> =
4054                Self::write_events_from_record(rec, seq, &self.syms, &self.ids);
4055
4056            // Build edge events from engine deltas.  Weight is looked up from
4057            // edge_props at distribution time (after apply), so it's always fresh.
4058            let edge_events: Vec<DbEvent> = engine_deltas
4059                .iter()
4060                .map(|d| {
4061                    if d.fired {
4062                        // The score lives under the rule's declared weight_prop,
4063                        // which is not always the literal "weight".
4064                        let prop = self
4065                            .engine
4066                            .rules()
4067                            .find(|r| r.name == d.rule)
4068                            .and_then(|r| r.weight_prop.as_deref());
4069                        let weight = prop.and_then(|p| {
4070                            self.edge_props
4071                                .get(d.etype_sym, d.src_id, d.dst_id, p)
4072                                .and_then(|v| {
4073                                    if let core_storage::Value::Float(f) = v {
4074                                        Some(*f)
4075                                    } else {
4076                                        None
4077                                    }
4078                                })
4079                        });
4080                        DbEvent::EdgeFired {
4081                            rule: d.rule.clone(),
4082                            src_key: d.src_key.clone(),
4083                            dst_key: d.dst_key.clone(),
4084                            edge_type: d.edge_type.clone(),
4085                            weight,
4086                            commit_seq: seq,
4087                        }
4088                    } else {
4089                        DbEvent::EdgeRetracted {
4090                            rule: d.rule.clone(),
4091                            src_key: d.src_key.clone(),
4092                            dst_key: d.dst_key.clone(),
4093                            edge_type: d.edge_type.clone(),
4094                            commit_seq: seq,
4095                        }
4096                    }
4097                })
4098                .collect();
4099
4100            // Prune dead entries; push matching events to live ones.
4101            self.subscriptions.retain(|entry| {
4102                let Some(inner) = entry.inner.upgrade() else {
4103                    return false;
4104                };
4105                for ev in &write_events {
4106                    if event_matches(ev, &entry.filter) {
4107                        inner.push(ev.clone());
4108                    }
4109                }
4110                for ev in &edge_events {
4111                    if event_matches(ev, &entry.filter) {
4112                        inner.push(ev.clone());
4113                    }
4114                }
4115                true
4116            });
4117
4118            // Turn off delta accumulation if all subscribers dropped and no views remain.
4119            if self.subscriptions.is_empty() && self.view_store.is_empty() {
4120                self.engine.set_emit_deltas(false);
4121            }
4122        }
4123
4124        // Query subscriptions: full re-run per commit, then diff rows.
4125        // IMPORTANT: full re-execution on every commit — use LIMIT to bound cost.
4126        // Differential evaluation is roadmap / Phase 5.
4127        if !self.query_subscriptions.is_empty() {
4128            // Take the list out so we can call self.view() without borrow conflict.
4129            let mut query_subs = std::mem::take(&mut self.query_subscriptions);
4130            let empty_params = BTreeMap::new();
4131            query_subs.retain_mut(|entry| {
4132                let Some(inner) = entry.inner.upgrade() else {
4133                    return false; // subscriber dropped — prune
4134                };
4135                // Label-skip: if the plan has a known scan label and this commit
4136                // can be proven to touch only different labels (and no rule-derived
4137                // edge deltas fired), the result set cannot have changed — skip.
4138                if let Some(scan_sym) = entry.scan_label {
4139                    if engine_deltas.is_empty() {
4140                        let touched =
4141                            Self::commit_touched_labels(rec, &self.syms, &self.ids, &self.labels);
4142                        if touched.map(|t| !t.contains(&scan_sym)).unwrap_or(false) {
4143                            return true; // safe to skip — result set unchanged
4144                        }
4145                    }
4146                }
4147                QUERY_SUB_EXECS_TL.with(|c| c.set(c.get() + 1));
4148                let result = match execute(&self.view(), &entry.ops, &Params(&empty_params)) {
4149                    Ok(r) => r,
4150                    Err(e) => {
4151                        // Keep the subscription alive; skip the diff for this commit.
4152                        // Re-run errors are transient (e.g., planner change) and
4153                        // self-heal when the next commit succeeds.
4154                        eprintln!("[mushroomdb] subscribe_query re-run failed: {e}");
4155                        return true;
4156                    }
4157                };
4158                // Build new row map: serialized-key → row data.
4159                let new_row_map = Self::result_to_row_map(&result);
4160                // Removed rows: in prev but not in new.
4161                for (key, row) in &entry.prev_row_map {
4162                    if !new_row_map.contains_key(key) {
4163                        inner.push(DbEvent::QueryRowRemoved {
4164                            columns: entry.columns.clone(),
4165                            row: row.clone(),
4166                        });
4167                    }
4168                }
4169                // Added rows: in new but not in prev.
4170                for (key, row) in &new_row_map {
4171                    if !entry.prev_row_map.contains_key(key) {
4172                        inner.push(DbEvent::QueryRowAdded {
4173                            columns: entry.columns.clone(),
4174                            row: row.clone(),
4175                        });
4176                    }
4177                }
4178                entry.prev_row_map = new_row_map;
4179                true
4180            });
4181            self.query_subscriptions = query_subs;
4182        }
4183    }
4184
4185    /// Returns `true` if any live subscriber or view definition requires delta
4186    /// accumulation. Used to set `engine.emit_deltas` on subscribe/view DDL.
4187    fn needs_emit_deltas(&self) -> bool {
4188        !self.view_store.is_empty()
4189            || self
4190                .subscriptions
4191                .iter()
4192                .any(|e| e.inner.upgrade().is_some())
4193    }
4194
4195    /// Convert a WAL record into `DbEvent` write events with the given seq.
4196    fn write_events_from_record(
4197        rec: &WalRecord,
4198        seq: u64,
4199        intern: &Interner,
4200        ids: &IdMap,
4201    ) -> Vec<DbEvent> {
4202        match rec {
4203            WalRecord::InsertNode { label, key, .. } => vec![DbEvent::NodeInserted {
4204                label: label.clone(),
4205                key: key.clone(),
4206                commit_seq: seq,
4207            }],
4208            // *Id arms run after a successful apply, so resolution can only
4209            // fail on a programming error. Skip the event rather than emit a
4210            // fabricated "" that clients can't tell from a real empty value
4211            // (mirrors event_from_record returning None).
4212            WalRecord::InsertNodeId { label, key, .. } => intern
4213                .resolve(*label)
4214                .map(|label| DbEvent::NodeInserted {
4215                    label: label.to_string(),
4216                    key: key.clone(),
4217                    commit_seq: seq,
4218                })
4219                .into_iter()
4220                .collect(),
4221            WalRecord::SetProp { key, field, .. } => vec![DbEvent::PropSet {
4222                key: key.clone(),
4223                field: field.clone(),
4224                commit_seq: seq,
4225            }],
4226            WalRecord::SetPropId { id, field, .. } => ids
4227                .key_of(*id)
4228                .zip(intern.resolve(*field))
4229                .map(|(key, field)| DbEvent::PropSet {
4230                    key: key.to_string(),
4231                    field: field.to_string(),
4232                    commit_seq: seq,
4233                })
4234                .into_iter()
4235                .collect(),
4236            WalRecord::RemoveProp { key, field } => vec![DbEvent::PropRemoved {
4237                key: key.clone(),
4238                field: field.clone(),
4239                commit_seq: seq,
4240            }],
4241            WalRecord::InsertEdge {
4242                edge_type,
4243                src_key,
4244                dst_key,
4245            } => vec![DbEvent::EdgeInserted {
4246                edge_type: edge_type.clone(),
4247                src: src_key.clone(),
4248                dst: dst_key.clone(),
4249                commit_seq: seq,
4250            }],
4251            WalRecord::InsertEdgeId { etype, src, dst } => (|| {
4252                Some(DbEvent::EdgeInserted {
4253                    edge_type: intern.resolve(*etype)?.to_string(),
4254                    src: ids.key_of(*src)?.to_string(),
4255                    dst: ids.key_of(*dst)?.to_string(),
4256                    commit_seq: seq,
4257                })
4258            })()
4259            .into_iter()
4260            .collect(),
4261            WalRecord::DeleteEdge {
4262                edge_type,
4263                src_key,
4264                dst_key,
4265            } => vec![DbEvent::EdgeDeleted {
4266                edge_type: edge_type.clone(),
4267                src: src_key.clone(),
4268                dst: dst_key.clone(),
4269                commit_seq: seq,
4270            }],
4271            WalRecord::DeleteNode { key } => vec![DbEvent::NodeDeleted {
4272                key: key.clone(),
4273                commit_seq: seq,
4274            }],
4275            WalRecord::Batch(inner) => inner
4276                .iter()
4277                .flat_map(|r| Self::write_events_from_record(r, seq, intern, ids))
4278                .collect(),
4279            WalRecord::CreateRule { .. }
4280            | WalRecord::DeleteRule { .. }
4281            | WalRecord::RebuildRule { .. }
4282            | WalRecord::CreateView { .. }
4283            | WalRecord::DeleteView { .. }
4284            | WalRecord::EnableFulltext { .. }
4285            | WalRecord::DisableFulltext { .. }
4286            | WalRecord::EnableIndex { .. }
4287            | WalRecord::DisableIndex { .. }
4288            | WalRecord::Intern { .. }
4289            // History markers produce no DbEvent — the engine delta already
4290            // fired the EdgeFired/EdgeRetracted subscription events.
4291            | WalRecord::DerivedEdgeAdded { .. }
4292            | WalRecord::DerivedEdgeRetracted { .. }
4293            | WalRecord::RenameNode { .. } => vec![],
4294        }
4295    }
4296
4297    /// Subscribe to edge-fire and edge-retract events for one named rule.
4298    ///
4299    /// Returns `Err(GraphError::RuleNotFound)` if `rule_name` is not
4300    /// currently registered. Dropping the returned [`Subscription`] handle
4301    /// unregisters the subscriber — no further events are queued, no
4302    /// resources leak.
4303    pub fn subscribe_rule(&mut self, rule_name: &str) -> core_storage::Result<Subscription> {
4304        if self.read_only {
4305            return Err(core_storage::GraphError::ReadOnly);
4306        }
4307        if !self.engine.rules().any(|r| r.name == rule_name) {
4308            return Err(core_storage::GraphError::RuleNotFound {
4309                name: rule_name.to_string(),
4310            });
4311        }
4312        let inner = SubInner::new(self.sub_capacity());
4313        self.subscriptions.push(SubEntry {
4314            filter: SubFilter::Rule(rule_name.to_string()),
4315            inner: std::sync::Arc::downgrade(&inner),
4316        });
4317        self.engine.set_emit_deltas(true);
4318        Ok(Subscription(inner))
4319    }
4320
4321    /// Subscribe to edge-fire and edge-retract events for **all** rules.
4322    ///
4323    /// Returns `Err(GraphError::ReadOnly)` if called on an as-of instance —
4324    /// as-of instances never commit, so `distribute_events` never runs and the
4325    /// subscription would never deliver events.
4326    pub fn subscribe_all_rules(&mut self) -> core_storage::Result<Subscription> {
4327        if self.read_only {
4328            return Err(core_storage::GraphError::ReadOnly);
4329        }
4330        let inner = SubInner::new(self.sub_capacity());
4331        self.subscriptions.push(SubEntry {
4332            filter: SubFilter::AllRules,
4333            inner: std::sync::Arc::downgrade(&inner),
4334        });
4335        self.engine.set_emit_deltas(true);
4336        Ok(Subscription(inner))
4337    }
4338
4339    /// Subscribe to write events: node insert/delete, prop set/remove.
4340    ///
4341    /// Does not include edge-fire / edge-retract (rule-derived edge events).
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_writes(&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::Writes,
4353            inner: std::sync::Arc::downgrade(&inner),
4354        });
4355        self.engine.set_emit_deltas(true);
4356        Ok(Subscription(inner))
4357    }
4358
4359    /// Subscribe to incremental Cypher query results.
4360    ///
4361    /// Parses and plans `cypher`; rejects the query if the plan is not in the
4362    /// allowlisted subset (see [`core_query::cypher::is_subscribable`]):
4363    ///   - `MATCH (n:Label) WHERE … RETURN … [LIMIT n]`
4364    ///   - `MATCH (a)-[r:TYPE]->(b) RETURN … [LIMIT n]`  (exactly one hop)
4365    ///
4366    /// SKIP is not supported — it shifts the result window on every commit,
4367    /// causing spurious Added/Removed churn for rows whose data never changed.
4368    /// Multi-hop Expand chains are not supported; each additional MATCH clause
4369    /// widens scope beyond the documented single-scan / single-hop subset.
4370    ///
4371    /// After each successful commit, the plan is **fully re-executed** and the
4372    /// result is diffed against the previous run. Added rows produce
4373    /// [`DbEvent::QueryRowAdded`]; removed rows produce
4374    /// [`DbEvent::QueryRowRemoved`].
4375    ///
4376    /// **Full re-run per commit; use LIMIT to bound execution cost.**
4377    /// The existing 1 M intermediate-row cap applies. Differential evaluation
4378    /// is roadmap / Phase 5.
4379    ///
4380    /// Returns `Err(GraphError::ReadOnly)` if called on an as-of instance —
4381    /// as-of instances never commit, so `distribute_events` never runs and the
4382    /// subscription would never deliver events.
4383    ///
4384    /// Returns `Err(GraphError::QueryError)` if the query fails to parse, plan,
4385    /// or if the plan shape is not in the allowlist.
4386    pub fn subscribe_query(&mut self, cypher: &str) -> Result<Subscription> {
4387        if self.read_only {
4388            return Err(GraphError::ReadOnly);
4389        }
4390        let tokens = lex(cypher).map_err(|e| GraphError::QueryError {
4391            detail: format!("lex: {e}"),
4392        })?;
4393        let ast = parse(&tokens).map_err(|e| GraphError::QueryError {
4394            detail: format!("parse: {e}"),
4395        })?;
4396        let ops = plan(&ast).map_err(|e| GraphError::QueryError {
4397            detail: format!("plan: {e}"),
4398        })?;
4399        if !is_subscribable(&ops) {
4400            return Err(GraphError::QueryError {
4401                detail: "subscribe_query only supports allowlisted plan shapes: \
4402                         MATCH (n:Label) WHERE … RETURN … [LIMIT n] or \
4403                         MATCH (a)-[r:TYPE]->(b) RETURN … [LIMIT n] (exactly one hop). \
4404                         Not supported: multi-hop Expand chains, SKIP (creates \
4405                         unstable offset windows), ORDER BY, DISTINCT, aggregates, \
4406                         variable-length paths, OPTIONAL MATCH, WITH, UNWIND. \
4407                         Use LIMIT to bound re-execution cost."
4408                    .to_string(),
4409            });
4410        }
4411        // Execute once to capture initial state (initial rows are not emitted as
4412        // events — the subscriber learns the baseline via the first query call).
4413        let empty_params = BTreeMap::new();
4414        let initial = execute(&self.view(), &ops, &Params(&empty_params)).map_err(|e| {
4415            GraphError::QueryError {
4416                detail: format!("execute: {e}"),
4417            }
4418        })?;
4419        let columns = initial.columns().to_vec();
4420        let prev_row_map = Self::result_to_row_map(&initial);
4421        let inner = SubInner::new(self.sub_capacity());
4422        // Derive the scan-label sym for the commit-skip fast-path.  Any Expand op
4423        // or unrecognized leading scan → None (always re-execute).
4424        let scan_label = extract_scan_label(&ops, &mut self.syms);
4425        self.query_subscriptions.push(QuerySubEntry {
4426            ops,
4427            columns,
4428            prev_row_map,
4429            inner: std::sync::Arc::downgrade(&inner),
4430            scan_label,
4431        });
4432        Ok(Subscription(inner))
4433    }
4434
4435    /// Queue capacity used for new subscriptions.
4436    fn sub_capacity(&self) -> usize {
4437        self.sub_capacity
4438    }
4439
4440    /// Override per-subscriber queue capacity for subsequently created
4441    /// subscriptions on this db instance.
4442    ///
4443    /// Default is [`DEFAULT_SUB_CAPACITY`] (65,536 events). Use a smaller
4444    /// value in tests to exercise the [`DbEvent::Lagged`] path without
4445    /// generating tens of thousands of events.
4446    ///
4447    /// This is a test-support escape hatch. Calling it in production reduces
4448    /// subscriber reliability (more Lagged events). It is hidden from rustdoc
4449    /// to discourage accidental production use.
4450    #[doc(hidden)]
4451    pub fn set_sub_capacity(&mut self, capacity: usize) {
4452        self.sub_capacity = capacity;
4453    }
4454
4455    // -----------------------------------------------------------------------
4456
4457    /// Start an atomic batch.
4458    ///
4459    /// The returned [`BatchBuilder`] borrows `self` mutably until
4460    /// [`BatchBuilder::commit`]. Builder methods queue ops only — no
4461    /// validation, no WAL I/O. `commit` validates every queued op against
4462    /// live state plus preceding ops in this batch (duplicate key inside
4463    /// the batch is `Err`; an edge between two nodes created earlier in
4464    /// the batch is valid; `delete_node` then insert of the same key is a
4465    /// fresh identity). Validation never mutates the database. Any failure
4466    /// leaves WAL bytes and in-memory state identical to before `commit`.
4467    /// On success, one `WalRecord::Batch` frame is appended (one fsync)
4468    /// and each inner record is applied in order so rules fire per record.
4469    /// An empty batch, or a batch of only no-ops, writes zero WAL bytes.
4470    ///
4471    /// **Rule-window limitation:** batch validation cannot see edges that a
4472    /// rule created earlier in the *same* batch will derive at apply time, so
4473    /// a `delete_edge` / `insert_edge` in that window is silently no-oped
4474    /// where sequential calls would return `Err(RuleOwned)`. State integrity
4475    /// is unaffected (idempotent apply, provenance intact). Create rules in
4476    /// their own batch, or sequentially, when later ops may touch derived
4477    /// edges.
4478    pub fn batch(&mut self) -> BatchBuilder<'_, F> {
4479        BatchBuilder {
4480            db: self,
4481            ops: Vec::new(),
4482        }
4483    }
4484
4485    /// Closure-style atomic write batch.
4486    ///
4487    /// Equivalent to calling [`GraphDb::batch`], invoking `build` to queue ops,
4488    /// then committing. All ops queued inside `build` are validated in order and
4489    /// committed as a single `WalRecord::Batch` frame (one fsync). Rules fire
4490    /// once per inner record, in order, after commit — semantically identical to
4491    /// sequential single-op writes.
4492    ///
4493    /// **Error semantics — validate-then-apply.** `build` queues ops without
4494    /// touching the database. [`BatchBuilder::commit`] validates every op against
4495    /// live state plus earlier ops in this batch before writing anything. If op N
4496    /// fails validation (duplicate key, unknown key, rule-owned edge, …) the
4497    /// entire batch is rejected: no WAL bytes are written and no in-memory state
4498    /// changes. The database is identical to its state before `write_batch` was
4499    /// called.
4500    ///
4501    /// **Atomicity is crash-level, NOT isolation-level.** On replay after a crash,
4502    /// a partial (torn) `Batch` frame applies NONE of its ops — the frame is
4503    /// either fully applied or not at all. However, while applying a committed
4504    /// batch, concurrent readers may observe intermediate states as ops are applied
4505    /// sequentially in memory. There is no interactive transaction isolation in v1.
4506    /// This is documented as "crash-atomic write batches; no interactive
4507    /// transactions or read isolation."
4508    ///
4509    /// **Returns** `(nodes_inserted, edges_inserted)`. An empty or all-noop batch
4510    /// writes zero WAL bytes and returns `(0, 0)`.
4511    ///
4512    /// # Example
4513    ///
4514    /// ```rust,ignore
4515    /// let (nodes, edges) = db.write_batch(|b| {
4516    ///     b.insert_node("Person", "alice", vec![("age".into(), Value::Int(30))]);
4517    ///     b.insert_node("Person", "bob", vec![]);
4518    ///     b.insert_edge("KNOWS", "alice", "bob");
4519    ///     b.set_prop("alice", "role", Value::Str("admin".into()));
4520    ///     b.delete_node("old_key");
4521    /// })?;
4522    /// // One fsync; on crash replay: all five ops land or none do.
4523    /// ```
4524    pub fn write_batch<C>(&mut self, build: C) -> Result<(usize, usize)>
4525    where
4526        C: FnOnce(&mut BatchBuilder<'_, F>),
4527    {
4528        let mut b = self.batch();
4529        build(&mut b);
4530        b.commit()
4531    }
4532
4533    /// Insert `rows` as nodes of `label`. One call is one atomic batch:
4534    /// auto-declared KeyMatch rules (if any) first, then the accepted node
4535    /// inserts, so incremental fire sees the new rules. Per-row key problems
4536    /// are collected in [`IngestReport::row_errors`] and skipped; a commit
4537    /// `Err` means nothing was applied.
4538    ///
4539    /// Auto-FK rule names are `auto_fk_<src_label_lowercase>_<field>` so
4540    /// distinct source labels sharing an FK field each get their own rule.
4541    pub fn ingest(
4542        &mut self,
4543        label: &str,
4544        rows: Vec<BTreeMap<String, Value>>,
4545        opts: &IngestOptions,
4546    ) -> Result<IngestReport> {
4547        self.ingest_with_edges(label, rows, opts, &[])
4548    }
4549
4550    /// [`ingest`] plus user edges in the **same** previewed WAL batch.
4551    /// A failing edge rejects the whole request; nothing is applied.
4552    pub fn ingest_with_edges(
4553        &mut self,
4554        label: &str,
4555        rows: Vec<BTreeMap<String, Value>>,
4556        opts: &IngestOptions,
4557        edges: &[(String, String, String)],
4558    ) -> Result<IngestReport> {
4559        crate::ingest::run(self, label, rows, opts, edges)
4560    }
4561
4562    /// Parse `json` as an array of objects and ingest via [`GraphDb::ingest`].
4563    ///
4564    /// JSON `null` fields are silently omitted (not stored, not a row error).
4565    /// Nested objects and arrays-of-objects are a per-row error (row skipped).
4566    /// Parse failures and a top-level value that is not an array of objects
4567    /// return [`GraphError::IngestError`].
4568    pub fn ingest_json(
4569        &mut self,
4570        label: &str,
4571        json: &str,
4572        opts: &IngestOptions,
4573    ) -> Result<IngestReport> {
4574        crate::ingest::run_json(self, label, json, opts)
4575    }
4576
4577    fn commit_logged_batch(
4578        &mut self,
4579        ops: Vec<BatchOp>,
4580        ingest: Option<(String, usize)>,
4581        // Two-source rule: write_batch_authz threads authz here directly (never
4582        // touches pending_write_authz); query_write_authz sets the field instead
4583        // and passes None.  Only one source is non-None per call.
4584        param_authz: Option<WriteAuthz>,
4585    ) -> Result<(usize, usize)> {
4586        // Read-only guard: catches empty-batch calls before the early-return
4587        // that skips log_then_apply_with, ensuring all mutation entry points fail.
4588        if self.read_only {
4589            return Err(GraphError::ReadOnly);
4590        }
4591        // Ensure provenance is decoded before MutPreview accesses it
4592        // (note_delete_rule / is_rule_owned may call engine.provenance()).
4593        self.engine.ensure_provenance_loaded_mut();
4594
4595        // ── Authz pre-check ──────────────────────────────────────────────────
4596        // Evaluate the decision table per-op BEFORE MutPreview so that a denial
4597        // produces no WAL frame (all-or-nothing at the authz boundary extends
4598        // the existing validate-then-apply contract to role-scope checks).
4599        //
4600        // `batch_created` tracks key→label for nodes created by earlier ops in
4601        // THIS batch, so InsertEdgeUpsert can count same-batch placeholder nodes
4602        // as visible without needing to call `self.ids.get` on not-yet-committed
4603        // keys (they won't be there yet).
4604        //
4605        // Two-source rule: param_authz (write_batch_authz path) takes precedence;
4606        // fall back to self.pending_write_authz (query_write_authz/Cypher path).
4607        // Cloning the field copy avoids a simultaneous borrow of self.ids below.
4608        let authz_opt = param_authz.or_else(|| self.pending_write_authz.clone());
4609        if let Some(ref authz) = authz_opt {
4610            let mut batch_created: BTreeMap<String, String> = BTreeMap::new();
4611            for op in &ops {
4612                self.check_single_op_authz(authz, op, &batch_created)?;
4613                // Update batch_created after a passing authz check so that
4614                // subsequent ops in this batch see the nodes as "about to exist".
4615                match op {
4616                    BatchOp::InsertNode { label, key, .. } => {
4617                        // Only track genuinely new nodes (absent from the
4618                        // snapshot at authz-check time). A pre-existing visible
4619                        // key would be a DuplicateKey — not a real creation —
4620                        // so MutPreview handles it. Letting it into batch_created
4621                        // would allow a later SetProp to bypass update_labels
4622                        // via the "batch-created → always updatable" ruling
4623                        // (delete+recreate exploit, fix for I1 review round 2).
4624                        //
4625                        // Accepted edge: for a delete+recreate-with-different-
4626                        // label batch, node_status resolves the pre-delete
4627                        // (store) label for any subsequent update checks. This
4628                        // grants no net-new capability — a role that can delete+
4629                        // create can already place arbitrary props via
4630                        // InsertNode's own props field.
4631                        if self.ids.get(key.as_str()).is_none() {
4632                            batch_created.insert(key.clone(), label.clone());
4633                        }
4634                    }
4635                    BatchOp::InsertEdgeUpsert {
4636                        placeholder_label,
4637                        src_key,
4638                        dst_key,
4639                        ..
4640                    } => {
4641                        // Both endpoints will be created if not already in store.
4642                        for ep_key in [src_key, dst_key] {
4643                            if self.ids.get(ep_key.as_str()).is_none()
4644                                && !batch_created.contains_key(ep_key.as_str())
4645                            {
4646                                batch_created.insert(ep_key.clone(), placeholder_label.clone());
4647                            }
4648                        }
4649                    }
4650                    _ => {}
4651                }
4652            }
4653        }
4654
4655        let recs = {
4656            let mut preview = MutPreview::new(self);
4657            let mut recs = Vec::with_capacity(ops.len());
4658            for op in ops {
4659                match op {
4660                    BatchOp::InsertNode { label, key, props } => {
4661                        preview.check_insert_node(&key)?;
4662                        preview.note_insert_node(&key, &props);
4663                        recs.push(WalRecord::InsertNode { label, key, props });
4664                    }
4665                    BatchOp::InsertEdge {
4666                        edge_type,
4667                        src_key,
4668                        dst_key,
4669                    } => {
4670                        if preview.prepare_insert_edge(&edge_type, &src_key, &dst_key)? {
4671                            preview.note_insert_edge(&edge_type, &src_key, &dst_key);
4672                            recs.push(WalRecord::InsertEdge {
4673                                edge_type,
4674                                src_key,
4675                                dst_key,
4676                            });
4677                        }
4678                    }
4679                    BatchOp::SetProp { key, field, value } => {
4680                        preview.check_live_key(&key)?;
4681                        preview.note_set_prop(&key, &field, &value);
4682                        recs.push(WalRecord::SetProp { key, field, value });
4683                    }
4684                    BatchOp::RemoveProp { key, field } => {
4685                        if preview.prepare_remove_prop(&key, &field)? {
4686                            preview.note_remove_prop(&key, &field);
4687                            recs.push(WalRecord::RemoveProp { key, field });
4688                        }
4689                    }
4690                    BatchOp::DeleteEdge {
4691                        edge_type,
4692                        src_key,
4693                        dst_key,
4694                    } => {
4695                        if preview.prepare_delete_edge(&edge_type, &src_key, &dst_key)? {
4696                            preview.note_delete_edge(&edge_type, &src_key, &dst_key);
4697                            recs.push(WalRecord::DeleteEdge {
4698                                edge_type,
4699                                src_key,
4700                                dst_key,
4701                            });
4702                        }
4703                    }
4704                    BatchOp::DeleteNode { key } => {
4705                        preview.check_live_key(&key)?;
4706                        preview.note_delete_node(&key);
4707                        recs.push(WalRecord::DeleteNode { key });
4708                    }
4709                    BatchOp::CreateRule(def) => {
4710                        preview.check_create_rule(&def)?;
4711                        let def_bytes =
4712                            bincode::serialize(&def).map_err(|e| GraphError::Corrupt {
4713                                detail: format!("serialize rule: {e}"),
4714                            })?;
4715                        preview.note_create_rule(&def);
4716                        recs.push(WalRecord::CreateRule { def_bytes });
4717                    }
4718                    BatchOp::DeleteRule { name } => {
4719                        preview.check_delete_rule(&name)?;
4720                        preview.note_delete_rule(&name);
4721                        recs.push(WalRecord::DeleteRule { name });
4722                    }
4723                    BatchOp::RenameNode { old_key, new_key } => {
4724                        preview.check_rename_node(&old_key, &new_key)?;
4725                        preview.note_rename_node(&old_key, &new_key);
4726                        recs.push(WalRecord::RenameNode { old_key, new_key });
4727                    }
4728                    BatchOp::InsertEdgeUpsert {
4729                        edge_type,
4730                        src_key,
4731                        dst_key,
4732                        placeholder_label,
4733                    } => {
4734                        // Auto-create any missing endpoints as plain InsertNode ops.
4735                        // Rules fire and last-change is updated for each created node.
4736                        for key in [&src_key, &dst_key] {
4737                            if !preview.has_key(key) {
4738                                preview.check_insert_node(key)?;
4739                                preview.note_insert_node(key, &[]);
4740                                recs.push(WalRecord::InsertNode {
4741                                    label: placeholder_label.clone(),
4742                                    key: key.clone(),
4743                                    props: vec![],
4744                                });
4745                            }
4746                        }
4747                        if preview.prepare_insert_edge(&edge_type, &src_key, &dst_key)? {
4748                            preview.note_insert_edge(&edge_type, &src_key, &dst_key);
4749                            recs.push(WalRecord::InsertEdge {
4750                                edge_type,
4751                                src_key,
4752                                dst_key,
4753                            });
4754                        }
4755                    }
4756                }
4757            }
4758            recs
4759        };
4760        if recs.is_empty() {
4761            return Ok((0, 0));
4762        }
4763        // rewrite_wal_dense converts every InsertNode/InsertEdge into its
4764        // *Id form, so only the dense variants can appear in `recs` here.
4765        let recs = self.rewrite_wal_dense(recs)?;
4766        let nodes_inserted = recs
4767            .iter()
4768            .filter(|r| matches!(r, WalRecord::InsertNodeId { .. }))
4769            .count();
4770        let edges_inserted = recs
4771            .iter()
4772            .filter(|r| matches!(r, WalRecord::InsertEdgeId { .. }))
4773            .count();
4774        // Ingest / write_batch / query_write: one Batch frame, one fsync per call
4775        // under Strict.  Pass self.fsync directly so Strict stays Strict —
4776        // wal_needs_sync(Strict, _) always returns true regardless of op count.
4777        // Mapping Strict → Batched (the prior bug) caused wal_needs_sync to
4778        // short-circuit on single-op batches and silently skip the fsync.
4779        // Batched fsyncs only for multi-op batches; Relaxed always skips.
4780        self.log_then_apply_with(WalRecord::Batch(recs), ingest, self.fsync)?;
4781        Ok((nodes_inserted, edges_inserted))
4782    }
4783
4784    fn commit_batch(&mut self, ops: Vec<BatchOp>) -> Result<(usize, usize)> {
4785        self.commit_logged_batch(ops, None, None)
4786    }
4787
4788    /// Commit one submission WITHOUT an fsync — for use inside `commit_group`
4789    /// and the group-commit drain thread, which do a single group fsync later.
4790    fn commit_batch_nosync(&mut self, ops: Vec<BatchOp>) -> Result<(usize, usize)> {
4791        // Restore fsync policy even on panic via a raw-pointer drop guard.
4792        // A panic here would poison the RwLock anyway, but the correct policy
4793        // must be in place if the guard is ever unwrapped.
4794        struct RestoreFsync(*mut FsyncPolicy, FsyncPolicy);
4795        impl Drop for RestoreFsync {
4796            fn drop(&mut self) {
4797                // SAFETY: the pointer is valid for the full duration of
4798                // commit_batch_nosync; the guard is dropped before the frame
4799                // returns, and GraphDb outlives this frame.
4800                unsafe {
4801                    *self.0 = self.1;
4802                }
4803            }
4804        }
4805        let saved = self.fsync;
4806        // SAFETY: raw pointer into self; guard dropped within this frame.
4807        let _g = RestoreFsync(&mut self.fsync as *mut FsyncPolicy, saved);
4808        self.fsync = FsyncPolicy::Relaxed;
4809        self.commit_logged_batch(ops, None, None)
4810    }
4811
4812    /// Commit multiple op-batches as a **group**: each submission gets its own
4813    /// WAL `Batch` frame, but there is exactly **one** `Fs::sync` for the whole
4814    /// group (under `Strict` / `Batched` policy; `Relaxed` skips all syncs).
4815    ///
4816    /// # Durability semantics
4817    ///
4818    /// A crash before the group fsync may lose **all** submissions in the group.
4819    /// A crash after the group fsync preserves all of them.  No submission is
4820    /// ever torn: each WAL frame is either fully applied on replay or dropped
4821    /// in its entirety (CRC-protected frame boundaries).
4822    ///
4823    /// Events and subscription notifications fire per-submission immediately
4824    /// after apply, which may be before the group fsync.  From a subscriber's
4825    /// perspective this is equivalent to the `Relaxed` durability window.
4826    /// Submitters using [`SharedDb::submit_batch`] only unblock after the group
4827    /// fsync, so from their perspective durability is fully guaranteed.
4828    ///
4829    /// # MVCC interplay
4830    ///
4831    /// Each submission records its own `CommitDelta`; the fold-every-K counter
4832    /// increments per submission (not per group), preserving existing reader
4833    /// snapshot semantics.
4834    ///
4835    /// # Returns
4836    ///
4837    /// One `Result<(nodes_inserted, edges_inserted)>` per input group element,
4838    /// in order.  Failures are per-submission (validation errors); the group
4839    /// fsync error (if any) is returned as the second tuple element.
4840    pub fn commit_group(
4841        &mut self,
4842        groups: Vec<Vec<BatchOp>>,
4843    ) -> (Vec<Result<(usize, usize)>>, Option<GraphError>) {
4844        let mut results = Vec::with_capacity(groups.len());
4845        for ops in groups {
4846            results.push(self.commit_batch_nosync(ops));
4847        }
4848        let any_ok = results.iter().any(|r| r.is_ok());
4849        let sync_err = if self.fsync != FsyncPolicy::Relaxed && any_ok {
4850            self.fs
4851                .sync(core_storage::fs::FileId::Wal)
4852                .map_err(GraphError::Io)
4853                .err()
4854        } else {
4855            None
4856        };
4857        (results, sync_err)
4858    }
4859
4860    /// Like [`commit_group`] but skips the group fsync entirely.
4861    ///
4862    /// Used by the drain thread to apply submissions under the write lock and
4863    /// then perform the single fsync OUTSIDE the lock (via
4864    /// `core_storage::sync_wal_at`), reducing the write-lock hold time visible
4865    /// to concurrent readers.
4866    pub fn commit_group_nosync(
4867        &mut self,
4868        groups: Vec<Vec<BatchOp>>,
4869    ) -> Vec<Result<(usize, usize)>> {
4870        let mut results = Vec::with_capacity(groups.len());
4871        for ops in groups {
4872            results.push(self.commit_batch_nosync(ops));
4873        }
4874        results
4875    }
4876
4877    pub fn insert_node(
4878        &mut self,
4879        label: &str,
4880        key: &str,
4881        props: Vec<(String, Value)>,
4882    ) -> Result<()> {
4883        if self.read_only {
4884            return Err(GraphError::ReadOnly);
4885        }
4886        MutPreview::new(self).check_insert_node(key)?;
4887        self.log_dense(vec![WalRecord::InsertNode {
4888            label: label.into(),
4889            key: key.into(),
4890            props,
4891        }])
4892    }
4893
4894    pub fn insert_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) -> Result<bool> {
4895        if self.read_only {
4896            return Err(GraphError::ReadOnly);
4897        }
4898        if !MutPreview::new(self).prepare_insert_edge(edge_type, src_key, dst_key)? {
4899            return Ok(false);
4900        }
4901        self.log_dense(vec![WalRecord::InsertEdge {
4902            edge_type: edge_type.into(),
4903            src_key: src_key.into(),
4904            dst_key: dst_key.into(),
4905        }])?;
4906        Ok(true)
4907    }
4908
4909    pub fn set_prop(&mut self, key: &str, field: &str, value: Value) -> Result<()> {
4910        if self.read_only {
4911            return Err(GraphError::ReadOnly);
4912        }
4913        if let Some(view_name) = self.view_store.view_for_prop(field) {
4914            return Err(GraphError::ViewPropReadOnly {
4915                view_name: view_name.to_string(),
4916            });
4917        }
4918        MutPreview::new(self).check_live_key(key)?;
4919        self.log_dense(vec![WalRecord::SetProp {
4920            key: key.into(),
4921            field: field.into(),
4922            value,
4923        }])
4924    }
4925
4926    /// Remove a property. Returns `Ok(false)` (and does not log) if the field
4927    /// is already absent. Unknown or tombstoned keys are `Err(KeyNotFound)`.
4928    pub fn remove_prop(&mut self, key: &str, field: &str) -> Result<bool> {
4929        if self.read_only {
4930            return Err(GraphError::ReadOnly);
4931        }
4932        if let Some(view_name) = self.view_store.view_for_prop(field) {
4933            return Err(GraphError::ViewPropReadOnly {
4934                view_name: view_name.to_string(),
4935            });
4936        }
4937        if !MutPreview::new(self).prepare_remove_prop(key, field)? {
4938            return Ok(false);
4939        }
4940        self.log_then_apply(WalRecord::RemoveProp {
4941            key: key.into(),
4942            field: field.into(),
4943        })?;
4944        Ok(true)
4945    }
4946
4947    /// Delete a user edge. Returns `Ok(false)` (and does not log) if the edge
4948    /// is absent. Unknown keys are `Err(KeyNotFound)`. Rule-owned edges — in
4949    /// provenance, or a pair a live rule would derive — are `Err(RuleOwned)`
4950    /// (the rule would just put the edge back; delete or change the rule).
4951    pub fn delete_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) -> Result<bool> {
4952        if self.read_only {
4953            return Err(GraphError::ReadOnly);
4954        }
4955        if !MutPreview::new(self).prepare_delete_edge(edge_type, src_key, dst_key)? {
4956            return Ok(false);
4957        }
4958        self.log_then_apply(WalRecord::DeleteEdge {
4959            edge_type: edge_type.into(),
4960            src_key: src_key.into(),
4961            dst_key: dst_key.into(),
4962        })?;
4963        Ok(true)
4964    }
4965
4966    /// Delete a live node. Unknown or already-tombstoned keys are
4967    /// `Err(KeyNotFound)` and are not logged. Validation runs before the WAL
4968    /// write; `apply` of a logged `DeleteNode` for an already-tombstoned key
4969    /// (crash window) is a clean no-op.
4970    ///
4971    /// Returns a [`DeleteReport`] with counts of manual and derived edges
4972    /// removed (computed from live state before the deletion is applied).
4973    pub fn delete_node(&mut self, key: &str) -> Result<DeleteReport> {
4974        if self.read_only {
4975            return Err(GraphError::ReadOnly);
4976        }
4977        // Provenance must be loaded before we query provenance_touching.
4978        self.engine.ensure_provenance_loaded_mut();
4979        let id = self
4980            .ids
4981            .get(key)
4982            .ok_or_else(|| GraphError::KeyNotFound { key: key.into() })?;
4983
4984        // Count edges before the delete is applied so we can report counts.
4985        let derived_set: BTreeSet<(u32, u32, u32)> = self
4986            .engine
4987            .provenance_touching(id)
4988            .map(|(_, etype, src, dst)| (etype, src, dst))
4989            .collect();
4990        let derived_edges = derived_set.len() as u64;
4991
4992        let mut total_topo = 0u64;
4993        let tv = self.topo_view();
4994        for et in tv.etypes() {
4995            total_topo += tv.neighbors(et, Direction::Out, id).len() as u64
4996                + tv.neighbors(et, Direction::In, id).len() as u64;
4997        }
4998        // For symmetric rules (e.g. Overlap), a→b and b→a are two separate directed
4999        // triples in both the topo scan (Out and In from id) and in provenance_touching.
5000        // The subtraction remains correct because both counts include both directions.
5001        let manual_edges = total_topo.saturating_sub(derived_edges);
5002
5003        self.log_then_apply(WalRecord::DeleteNode { key: key.into() })?;
5004        Ok(DeleteReport {
5005            manual_edges,
5006            derived_edges,
5007        })
5008    }
5009
5010    /// Rename a live node's key.  The dense id (and therefore all edges,
5011    /// props, history, and last-change tracking) is unaffected.
5012    ///
5013    /// Returns `Err(KeyNotFound)` if `old` is not a live key.
5014    /// Returns `Err(DuplicateKey)` if `new` is already live.
5015    pub fn rename_node(&mut self, old: &str, new: &str) -> Result<()> {
5016        if self.read_only {
5017            return Err(GraphError::ReadOnly);
5018        }
5019        MutPreview::new(self).check_rename_node(old, new)?;
5020        self.log_then_apply(WalRecord::RenameNode {
5021            old_key: old.into(),
5022            new_key: new.into(),
5023        })
5024    }
5025
5026    /// Return the IVF drift counter for the dst-side candidate index of `rule`.
5027    /// `None` if the rule does not exist or is not approximate.
5028    ///
5029    /// The drift counter increments on IVF insert/remove after the last fit.
5030    /// When dst-side drift exceeds [`core_rules::IVF_DRIFT_REBUILD`], apply
5031    /// WAL-logs `RebuildRule` as a second commit (rebuild resets the counter).
5032    pub fn ivf_dst_drift(&self, rule: &str) -> Option<u64> {
5033        // SideIvfExport = (centroids, node→cluster, drift)
5034        self.engine
5035            .export_ivf_state()
5036            .remove(rule)
5037            .map(|(_src, dst)| dst.2)
5038    }
5039
5040    /// Validate and WAL-log a new rule, then backfill derived edges inside apply.
5041    /// Validation and duplicate-name check run before logging so invalid rules
5042    /// never enter the WAL.
5043    pub fn create_rule(&mut self, def: RuleDef) -> Result<()> {
5044        if self.read_only {
5045            return Err(GraphError::ReadOnly);
5046        }
5047        MutPreview::new(self).check_create_rule(&def)?;
5048        let def_bytes = bincode::serialize(&def).map_err(|e| GraphError::Corrupt {
5049            detail: format!("serialize rule: {e}"),
5050        })?;
5051        self.log_then_apply(WalRecord::CreateRule { def_bytes })
5052    }
5053
5054    /// WAL-log rule deletion. Returns RuleNotFound if the rule does not exist.
5055    pub fn delete_rule(&mut self, name: &str) -> Result<()> {
5056        if self.read_only {
5057            return Err(GraphError::ReadOnly);
5058        }
5059        MutPreview::new(self).check_delete_rule(name)?;
5060        self.log_then_apply(WalRecord::DeleteRule { name: name.into() })
5061    }
5062
5063    /// Return a snapshot of all registered rules.
5064    pub fn rules(&self) -> Vec<RuleDef> {
5065        self.engine.rules().cloned().collect()
5066    }
5067
5068    // -----------------------------------------------------------------------
5069    // Rule suggestion API
5070    // -----------------------------------------------------------------------
5071
5072    /// Profile the database and suggest linking rules with previewed edge counts.
5073    ///
5074    /// Uses the default seed ([`core_rules::SUGGEST_DEFAULT_SEED`]) for deterministic
5075    /// sampling. Suggestions are sorted by estimated edge count (descending).
5076    /// **NO auto-accept** — call [`GraphDb::create_rule`] explicitly to apply.
5077    pub fn suggest_rules(&self) -> Vec<core_rules::RuleSuggestion> {
5078        self.suggest_rules_seeded(core_rules::SUGGEST_DEFAULT_SEED)
5079    }
5080
5081    /// Like [`suggest_rules`] but with a caller-supplied RNG seed for
5082    /// reproducibility. Same seed + same data = identical output.
5083    pub fn suggest_rules_seeded(&self, seed: u64) -> Vec<core_rules::RuleSuggestion> {
5084        self.suggest_rules_with_config(&core_rules::suggest::SuggestConfig::default(), seed)
5085            .suggestions
5086    }
5087
5088    /// [`suggest_rules_seeded`] with a fully custom [`SuggestConfig`].
5089    ///
5090    /// Returns a [`core_rules::SuggestReport`] that includes both the candidate list
5091    /// and a `truncated` flag indicating whether the global budget fired before all
5092    /// candidates were evaluated.
5093    pub fn suggest_rules_with_config(
5094        &self,
5095        config: &core_rules::suggest::SuggestConfig,
5096        seed: u64,
5097    ) -> core_rules::SuggestReport {
5098        use std::collections::BTreeMap;
5099
5100        // Collect (node_id, key) pairs per label, skipping tombstoned nodes.
5101        let mut label_nodes: BTreeMap<String, Vec<(u32, String)>> = BTreeMap::new();
5102        for id in 0..self.ids.len() as u32 {
5103            let Some(key) = self.ids.key_of(id) else {
5104                continue;
5105            };
5106            let Some(&sym) = self.labels.get(id as usize) else {
5107                continue;
5108            };
5109            if sym == u32::MAX {
5110                continue; // tombstoned
5111            }
5112            let Some(label) = self.syms.resolve(sym) else {
5113                continue;
5114            };
5115            label_nodes
5116                .entry(label.to_string())
5117                .or_default()
5118                .push((id, key.to_string()));
5119        }
5120
5121        let existing = self.rules();
5122        let pv = build_props_view(&self.props, &self.base);
5123        let all_fields: Vec<String> = pv.field_names();
5124
5125        core_rules::suggest::suggest_rules(
5126            &label_nodes,
5127            &|id, field| pv.get(id, field).map(|vr| vr.into_value()),
5128            &all_fields,
5129            &existing,
5130            config,
5131            seed,
5132        )
5133    }
5134
5135    /// Recompute a rule's derived edges from scratch. WAL-logged so un-trip
5136    /// plus later mutations replay identically (rebuild is a pure function
5137    /// of state).
5138    ///
5139    /// Only exit from the tripped latch: if the full desired set fits the
5140    /// budget, it is applied completely and `tripped` clears; if it still
5141    /// exceeds the budget, provenance is left untouched and `tripped` stays
5142    /// true. Counts as a fire evaluation (see [`RuleStats::fires`]).
5143    /// Unknown rule → `RuleNotFound`, nothing logged.
5144    pub fn rebuild_rule(&mut self, name: &str) -> Result<()> {
5145        if self.read_only {
5146            return Err(GraphError::ReadOnly);
5147        }
5148        if !self.engine.rules().any(|r| r.name == name) {
5149            return Err(GraphError::RuleNotFound { name: name.into() });
5150        }
5151        self.log_then_apply(WalRecord::RebuildRule { name: name.into() })
5152    }
5153
5154    // -----------------------------------------------------------------------
5155    // Materialized view API
5156    // -----------------------------------------------------------------------
5157
5158    /// Register a new materialized property view, backfill its values for all
5159    /// existing nodes, and WAL-log the definition.
5160    ///
5161    /// # Errors
5162    /// - `ReadOnly`: called on an as-of instance.
5163    /// - `RuleInvalid`: name collision, view_prop collision, or invalid def.
5164    pub fn create_view(&mut self, def: ViewDef) -> Result<()> {
5165        if self.read_only {
5166            return Err(GraphError::ReadOnly);
5167        }
5168        // Pre-validate before WAL write.
5169        def.validate()
5170            .map_err(|e| GraphError::RuleInvalid { detail: e })?;
5171        if self.view_store.has_view(&def.name) {
5172            return Err(GraphError::RuleInvalid {
5173                detail: format!("view {:?} already exists", def.name),
5174            });
5175        }
5176        if let Some(existing) = self.view_store.view_for_prop(&def.view_prop) {
5177            return Err(GraphError::RuleInvalid {
5178                detail: format!(
5179                    "view_prop {:?} is already used by view {:?}",
5180                    def.view_prop, existing
5181                ),
5182            });
5183        }
5184        let def_bytes = bincode::serialize(&def).map_err(|e| GraphError::Corrupt {
5185            detail: format!("serialize view: {e}"),
5186        })?;
5187        // Enable delta accumulation before the view is registered so subsequent
5188        // incremental edge events reach view maintenance from this point onward.
5189        // (The backfill inside create_view reads topo directly; it does not rely
5190        // on pending deltas.)
5191        self.engine.set_emit_deltas(true);
5192        self.log_then_apply(WalRecord::CreateView { def_bytes })
5193    }
5194
5195    /// Remove a named view and delete its values from every node.
5196    ///
5197    /// # Errors
5198    /// - `ReadOnly`: called on an as-of instance.
5199    /// - `RuleNotFound`: view does not exist.
5200    pub fn delete_view(&mut self, name: &str) -> Result<()> {
5201        if self.read_only {
5202            return Err(GraphError::ReadOnly);
5203        }
5204        if !self.view_store.has_view(name) {
5205            return Err(GraphError::RuleNotFound { name: name.into() });
5206        }
5207        let result = self.log_then_apply(WalRecord::DeleteView { name: name.into() });
5208        // After deletion, disable accumulation if no listeners remain.
5209        if !self.needs_emit_deltas() {
5210            self.engine.set_emit_deltas(false);
5211        }
5212        result
5213    }
5214
5215    /// Snapshot of all registered view definitions.
5216    pub fn views(&self) -> Vec<ViewDef> {
5217        self.view_store.views().cloned().collect()
5218    }
5219
5220    // -----------------------------------------------------------------------
5221    // Full-text-lite API
5222    // -----------------------------------------------------------------------
5223
5224    /// Enable full-text indexing for all nodes of `label` on property `field`.
5225    ///
5226    /// After this call, every subsequent write to `(label, field)` is reflected
5227    /// in the index incrementally.  Existing nodes are backfilled immediately.
5228    /// The declaration is persisted as a WAL record; the index itself is rebuilt
5229    /// from scratch on re-open (no snapshot format changes).
5230    ///
5231    /// # Errors
5232    /// - [`GraphError::ReadOnly`]: called on an as-of instance.
5233    /// - [`GraphError::RuleInvalid`]: `(label, field)` is already indexed.
5234    pub fn enable_fulltext(&mut self, label: &str, field: &str) -> Result<()> {
5235        if self.read_only {
5236            return Err(GraphError::ReadOnly);
5237        }
5238        if self.fulltext.is_enabled(label, field) {
5239            return Err(GraphError::RuleInvalid {
5240                detail: format!("full-text index for ({label:?}, {field:?}) already enabled"),
5241            });
5242        }
5243        self.log_then_apply(WalRecord::EnableFulltext {
5244            label: label.into(),
5245            field: field.into(),
5246        })
5247    }
5248
5249    /// Disable full-text indexing for `(label, field)` and drop its postings.
5250    ///
5251    /// # Errors
5252    /// - [`GraphError::ReadOnly`]: called on an as-of instance.
5253    /// - [`GraphError::RuleNotFound`]: `(label, field)` is not currently indexed.
5254    pub fn disable_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::RuleNotFound {
5260                name: format!("fulltext({label},{field})"),
5261            });
5262        }
5263        self.log_then_apply(WalRecord::DisableFulltext {
5264            label: label.into(),
5265            field: field.into(),
5266        })
5267    }
5268
5269    /// Whether `(label, field)` is currently indexed for full-text search.
5270    pub fn is_fulltext_enabled(&self, label: &str, field: &str) -> bool {
5271        self.fulltext.is_enabled(label, field)
5272    }
5273
5274    /// Every `(label, field)` pair with a live full-text index, sorted.
5275    ///
5276    /// Note that [`GraphDb::search`] is keyed by field alone — a pair only
5277    /// declares which nodes are *indexed*, so callers that want to search
5278    /// everything indexed should query each distinct field once.
5279    pub fn fulltext_pairs(&self) -> Vec<(String, String)> {
5280        let mut v: Vec<(String, String)> = self.fulltext.enabled_pairs().cloned().collect();
5281        v.sort();
5282        v
5283    }
5284
5285    /// Enable an equality index for all nodes of `label` on scalar property
5286    /// `field`. Subsequent `WHERE n.field = value` lookups become O(matches)
5287    /// instead of an O(N_label) scan. Existing nodes are backfilled; the
5288    /// declaration persists via WAL and the postings rebuild on re-open.
5289    ///
5290    /// # Errors
5291    /// - [`GraphError::ReadOnly`]: called on an as-of instance.
5292    /// - [`GraphError::RuleInvalid`]: `(label, field)` is already indexed.
5293    pub fn enable_index(&mut self, label: &str, field: &str) -> Result<()> {
5294        if self.read_only {
5295            return Err(GraphError::ReadOnly);
5296        }
5297        if self.prop_index.is_enabled(label, field) {
5298            return Err(GraphError::RuleInvalid {
5299                detail: format!("property index for ({label:?}, {field:?}) already enabled"),
5300            });
5301        }
5302        self.log_then_apply(WalRecord::EnableIndex {
5303            label: label.into(),
5304            field: field.into(),
5305        })
5306    }
5307
5308    /// Disable the equality index for `(label, field)` and drop its postings.
5309    ///
5310    /// # Errors
5311    /// - [`GraphError::ReadOnly`]: called on an as-of instance.
5312    /// - [`GraphError::RuleNotFound`]: `(label, field)` is not currently indexed.
5313    pub fn disable_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::RuleNotFound {
5319                name: format!("index({label},{field})"),
5320            });
5321        }
5322        self.log_then_apply(WalRecord::DisableIndex {
5323            label: label.into(),
5324            field: field.into(),
5325        })
5326    }
5327
5328    /// Whether `(label, field)` currently has an equality index.
5329    pub fn is_index_enabled(&self, label: &str, field: &str) -> bool {
5330        self.prop_index.is_enabled(label, field)
5331    }
5332
5333    /// Search a full-text-indexed field.
5334    ///
5335    /// Returns `(node_key, match_count)` pairs sorted by match_count descending,
5336    /// ties broken by key (lexicographic).  Tombstoned nodes are excluded.
5337    ///
5338    /// **Query syntax:**
5339    /// - Space-separated terms are AND'd: `"foo bar"` requires both.
5340    /// - `OR` between terms forms disjunction: `"foo OR bar"` matches either.
5341    /// - Trailing `*` on a term is a prefix match: `"rust*"` matches `rustlang`, `rusty`.
5342    /// - `AND` keyword is accepted explicitly and is the default.
5343    /// - Tokenization is unicode-alphanumeric (same as index time); case-insensitive.
5344    ///
5345    /// **Unindexed field:** returns `Ok(vec![])` if `field` is not indexed.
5346    /// Pin: this is the documented, tested, stable behavior for v1.
5347    ///
5348    /// **Memory / performance:** O(postings) lookup; no scan.  The index is
5349    /// in-memory and proportional to total indexed text across all enabled fields.
5350    ///
5351    /// **v2 grammar:** supports `"phrase"`, `-negation`, `prefix*`, `OR`, `AND`.
5352    /// Results are BM25-scored (k1=1.2, b=0.75) and sorted by score descending,
5353    /// key ascending for deterministic tiebreaking.
5354    pub fn search(&self, field: &str, query: &str) -> Vec<(String, f64)> {
5355        // Resolve node_ids to keys (excluding tombstones) then re-sort by
5356        // (score DESC, key ASC) to give a deterministic, key-lexicographic
5357        // tiebreak.  FulltextIndex::search sorts by (score DESC, node_id ASC)
5358        // which diverges from key order when nodes were not inserted in key-lex order.
5359        let mut results: Vec<(String, f64)> = self
5360            .fulltext
5361            .search(field, query, 0)
5362            .into_iter()
5363            .filter_map(|(id, score)| self.ids.key_of(id).map(|key| (key.to_string(), score)))
5364            .collect();
5365        results.sort_by(|a, b| {
5366            b.1.partial_cmp(&a.1)
5367                .unwrap_or(std::cmp::Ordering::Equal)
5368                .then(a.0.cmp(&b.0))
5369        });
5370        results
5371    }
5372
5373    /// Hybrid search: Reciprocal Rank Fusion (RRF) over fulltext + vector results.
5374    ///
5375    /// Takes up to `4*k` fulltext hits for `(text_field, query_text)` and up to
5376    /// `4*k` vector hits for `(vector_field, query_vec, min=0.0)`, then fuses
5377    /// them with RRF using a fixed constant of 60.
5378    ///
5379    /// ```text
5380    /// score(d) = Σ  1 / (60 + rank_i(d))    (rank 1-based per list)
5381    /// ```
5382    ///
5383    /// Returns the top `k` nodes by fused score, ties broken by node key
5384    /// ascending (deterministic).
5385    ///
5386    /// # Vector leg fallback
5387    ///
5388    /// When `query_vec` is empty the vector leg is skipped entirely and
5389    /// results are ranked by the text list alone through the same RRF path
5390    /// (each text result scores `1/(60 + rank)` from that single list).
5391    ///
5392    /// When `label` is `None`, the vector leg **always** returns empty results.
5393    /// Internally `label` is mapped to `""`, which does not match any rule-created
5394    /// HNSW index (all such indexes are keyed to a specific non-empty label), and
5395    /// the brute-force fallback finds no nodes with an empty label.  The fused
5396    /// ranking is therefore text-only in this case.
5397    pub fn search_hybrid(
5398        &self,
5399        text_field: &str,
5400        query_text: &str,
5401        vector_field: &str,
5402        query_vec: &[f64],
5403        label: Option<&str>,
5404        k: usize,
5405    ) -> Vec<(String, f64)> {
5406        use std::collections::HashMap;
5407
5408        const RRF_K: f64 = 60.0;
5409        let pool = 4 * k;
5410
5411        // Accumulate per-node RRF scores.
5412        let mut scores: HashMap<String, f64> = HashMap::new();
5413
5414        // Text leg.
5415        let text_hits = self.search(text_field, query_text);
5416        for (rank0, (key, _count)) in text_hits.into_iter().take(pool).enumerate() {
5417            let rank = (rank0 + 1) as f64;
5418            *scores.entry(key).or_insert(0.0) += 1.0 / (RRF_K + rank);
5419        }
5420
5421        // Vector leg (skipped when query_vec is empty).
5422        if !query_vec.is_empty() {
5423            let vec_hits = self.find_similar_vector(vector_field, label, query_vec, pool, 0.0);
5424            for (rank0, (key, _sim)) in vec_hits.into_iter().enumerate() {
5425                let rank = (rank0 + 1) as f64;
5426                *scores.entry(key).or_insert(0.0) += 1.0 / (RRF_K + rank);
5427            }
5428        }
5429
5430        // Sort: score DESC, then key ASC for deterministic tie-breaking.
5431        let mut ranked: Vec<(String, f64)> = scores.into_iter().collect();
5432        ranked.sort_by(|a, b| {
5433            b.1.partial_cmp(&a.1)
5434                .unwrap_or(std::cmp::Ordering::Equal)
5435                .then(a.0.cmp(&b.0))
5436        });
5437        ranked.truncate(k);
5438        ranked
5439    }
5440
5441    /// For DST/testing: scratch BM25 search over live nodes without the index.
5442    /// Walks every live node, re-stems field tokens, computes corpus stats, and
5443    /// returns BM25-ranked results.
5444    ///
5445    /// The oracle: the ordered key list of `search(field, q)` must equal that of
5446    /// `scratch_search(field, q)` at every quiescent state.
5447    #[doc(hidden)]
5448    pub fn scratch_search(&self, field: &str, query: &str) -> Vec<(String, f64)> {
5449        use core_storage::fulltext::{parse_query, value_tokens_stemmed_with_positions};
5450        use std::collections::BTreeMap;
5451
5452        let groups = parse_query(query);
5453        if groups.is_empty() {
5454            return vec![];
5455        }
5456
5457        // --- Pass 1: collect all live indexed nodes with stemmed token data ---
5458        struct NodeData {
5459            key: String,
5460            /// stemmed_token → positions (sorted)
5461            tokens: BTreeMap<String, Vec<u32>>,
5462            dl: u32,
5463        }
5464
5465        let mut nodes: Vec<NodeData> = Vec::new();
5466        for id in 0..self.ids.len() as u32 {
5467            let Some(key) = self.ids.key_of(id) else {
5468                continue;
5469            };
5470            let Some(&sym) = self.labels.get(id as usize) else {
5471                continue;
5472            };
5473            if sym == u32::MAX {
5474                continue;
5475            }
5476            let label = match self.syms.resolve(sym) {
5477                Some(l) => l,
5478                None => continue,
5479            };
5480            if !self.fulltext.is_enabled(label, field) {
5481                continue;
5482            }
5483            let Some(value) = self.props_view().get(id, field).map(|vr| vr.into_value()) else {
5484                continue;
5485            };
5486            // Use value_tokens_stemmed_with_positions so list elements are
5487            // separated by POSITION_GAP — identical to the index path, which
5488            // prevents phrase queries from matching across element boundaries.
5489            let stemmed_with_pos = match &value {
5490                Value::Str(_) | Value::List(_) => value_tokens_stemmed_with_positions(&value),
5491                _ => continue,
5492            };
5493            let dl = stemmed_with_pos.len() as u32;
5494            let mut tok_map: BTreeMap<String, Vec<u32>> = BTreeMap::new();
5495            for (tok, pos) in stemmed_with_pos {
5496                tok_map.entry(tok).or_default().push(pos);
5497            }
5498            nodes.push(NodeData {
5499                key: key.to_string(),
5500                tokens: tok_map,
5501                dl,
5502            });
5503        }
5504
5505        if nodes.is_empty() {
5506            return vec![];
5507        }
5508
5509        // --- BM25 corpus stats ---
5510        let n = nodes.len() as f64;
5511        let avg_dl: f64 = nodes.iter().map(|nd| nd.dl as f64).sum::<f64>() / n;
5512        // df per stemmed token across all live indexed nodes.
5513        let mut df_map: BTreeMap<&str, f64> = BTreeMap::new();
5514        for nd in &nodes {
5515            for tok in nd.tokens.keys() {
5516                *df_map.entry(tok.as_str()).or_insert(0.0) += 1.0;
5517            }
5518        }
5519
5520        const K1: f64 = 1.2;
5521        const B: f64 = 0.75;
5522
5523        // --- Pass 2: score each node against each OR-group ---
5524        let mut results: Vec<(String, f64)> = Vec::new();
5525        for nd in &nodes {
5526            let dl = nd.dl as f64;
5527            let mut total_score = 0.0f64;
5528
5529            'group: for group in &groups {
5530                let mut group_score = 0.0f64;
5531
5532                for term in group {
5533                    if term.negated {
5534                        // Negated: if doc has this stemmed token → group fails.
5535                        let present = if term.prefix {
5536                            nd.tokens.keys().any(|t| t.starts_with(term.token.as_str()))
5537                        } else {
5538                            nd.tokens.contains_key(term.token.as_str())
5539                        };
5540                        if present {
5541                            continue 'group;
5542                        }
5543                        continue;
5544                    }
5545                    if term.prefix {
5546                        // Prefix: sum BM25 for all matching stemmed tokens.
5547                        let mut prefix_matched = false;
5548                        for (tok, positions) in &nd.tokens {
5549                            if tok.starts_with(term.token.as_str()) {
5550                                let tf = positions.len() as f64;
5551                                let df = df_map.get(tok.as_str()).copied().unwrap_or(1.0);
5552                                let idf = ((n - df + 0.5) / (df + 0.5) + 1.0).ln();
5553                                let tf_norm =
5554                                    tf * (K1 + 1.0) / (tf + K1 * (1.0 - B + B * dl / avg_dl));
5555                                group_score += idf * tf_norm;
5556                                prefix_matched = true;
5557                            }
5558                        }
5559                        if !prefix_matched {
5560                            continue 'group;
5561                        }
5562                    } else {
5563                        // term.token is already stemmed by parse_query; use directly.
5564                        match nd.tokens.get(term.token.as_str()) {
5565                            None => continue 'group,
5566                            Some(positions) => {
5567                                let tf = positions.len() as f64;
5568                                let df = df_map.get(term.token.as_str()).copied().unwrap_or(1.0);
5569                                let idf = ((n - df + 0.5) / (df + 0.5) + 1.0).ln();
5570                                let tf_norm =
5571                                    tf * (K1 + 1.0) / (tf + K1 * (1.0 - B + B * dl / avg_dl));
5572                                group_score += idf * tf_norm;
5573                            }
5574                        }
5575                    }
5576                }
5577
5578                if group_score > 0.0 {
5579                    total_score += group_score;
5580                }
5581            }
5582
5583            if total_score > 0.0 {
5584                results.push((nd.key.clone(), total_score));
5585            }
5586        }
5587
5588        results.sort_by(|a, b| {
5589            b.1.partial_cmp(&a.1)
5590                .unwrap_or(std::cmp::Ordering::Equal)
5591                .then(a.0.cmp(&b.0))
5592        });
5593        results
5594    }
5595
5596    /// Return the current view-maintained value of `view_prop` for node `key`.
5597    /// Equivalent to `get_prop` but documents that it reads a view-managed column.
5598    pub fn get_view_prop(&self, key: &str, view_prop: &str) -> Option<Value> {
5599        let id = self.ids.get(key)?;
5600        self.props_view()
5601            .get(id, view_prop)
5602            .map(|vr| vr.into_value())
5603    }
5604
5605    /// For testing / DST oracle: scratch recompute of a view value for one node.
5606    ///
5607    /// Returns `None` if the node does not exist, the view does not exist, or
5608    /// the view has no result for the node (e.g. Avg with no qualifying neighbors).
5609    #[doc(hidden)]
5610    pub fn scratch_view_value(&self, key: &str, view_name: &str) -> Option<Value> {
5611        let node = self.ids.get(key)?;
5612        let def = self.view_store.views().find(|v| v.name == view_name)?;
5613        // Use TopologyView so that NeighborAgg sees base + overlay edges
5614        // without materialising a temporary Topology (I1).
5615        let topo_view = self.topo_view();
5616        core_rules::views::compute_view_value(
5617            def,
5618            node,
5619            self.props_view(),
5620            &topo_view,
5621            &self.ids,
5622            &self.syms,
5623            &self.labels,
5624        )
5625    }
5626
5627    // -----------------------------------------------------------------------
5628    // Graph algorithm API
5629    // -----------------------------------------------------------------------
5630
5631    /// Run PageRank over the unified topology (manual + derived edges).
5632    ///
5633    /// Returns a [`PageRankReport`] with scores sorted descending (ties: key
5634    /// ascending).  Set `config.edge_type` to restrict to one edge type.
5635    /// `config.converged` is `true` only when the power iteration converged
5636    /// within `config.max_iters` and within any time budget.
5637    pub fn pagerank(&self, config: &crate::algo::PageRankConfig) -> crate::algo::PageRankReport {
5638        let topo = build_topo_view(&self.topo, &self.base);
5639        crate::algo::pagerank(&topo, &self.ids, &self.syms, &self.labels, config)
5640    }
5641
5642    /// Weakly-connected components over the unified topology (treated as
5643    /// undirected regardless of how edges were inserted).
5644    ///
5645    /// Component IDs are the key of the smallest member in the component
5646    /// (deterministic).  Result sorted by (component_id, key).
5647    pub fn connected_components(&self, config: &crate::algo::WccConfig) -> crate::algo::WccReport {
5648        let topo = build_topo_view(&self.topo, &self.base);
5649        crate::algo::wcc(&topo, &self.ids, &self.syms, &self.labels, config)
5650    }
5651
5652    /// Degree centrality for every live node.
5653    ///
5654    /// `direction`: `AlgoDir::Out` = out-degree, `AlgoDir::In` = in-degree,
5655    /// `AlgoDir::Both` = out + in (total directed degree).
5656    ///
5657    /// For one-shot ranking use this; for a live property updated on every
5658    /// write, create a Degree materialized view instead (see `docs/site/algorithms.md`).
5659    pub fn degree_centrality(
5660        &self,
5661        config: &crate::algo::DegreeConfig,
5662    ) -> crate::algo::DegreeReport {
5663        let topo = build_topo_view(&self.topo, &self.base);
5664        crate::algo::degree_centrality(&topo, &self.ids, &self.syms, &self.labels, config)
5665    }
5666
5667    /// Write a vector of `(node_key, score)` pairs as `prop_name` on each node,
5668    /// atomically via a single write-batch (one WAL frame, one fsync).
5669    ///
5670    /// # Errors
5671    /// - [`GraphError::ReadOnly`]: called on an as-of instance.
5672    /// - [`GraphError::RuleInvalid`]: `prop_name` is managed by an existing view
5673    ///   (collision check mirrors `create_view`).
5674    /// - [`GraphError::KeyNotFound`]: a key in `scores` does not exist as a live node.
5675    pub fn write_scores(&mut self, prop_name: &str, scores: &[(String, f64)]) -> Result<()> {
5676        if self.read_only {
5677            return Err(GraphError::ReadOnly);
5678        }
5679        // Collision check: refuse if prop_name is view-managed.
5680        if let Some(view_name) = self.view_store.view_for_prop(prop_name) {
5681            return Err(GraphError::RuleInvalid {
5682                detail: format!(
5683                    "prop {:?} is managed by view {:?} and cannot be written as scores",
5684                    prop_name, view_name
5685                ),
5686            });
5687        }
5688        // Refuse if prop_name is a view name itself (confusing namespace collision).
5689        if self.view_store.has_view(prop_name) {
5690            return Err(GraphError::RuleInvalid {
5691                detail: format!(
5692                    "prop_name {:?} collides with an existing view name",
5693                    prop_name
5694                ),
5695            });
5696        }
5697        // Write all scores in a single crash-atomic batch.
5698        self.write_batch(|b| {
5699            for (key, score) in scores {
5700                b.set_prop(key, prop_name, Value::Float(*score));
5701            }
5702        })?;
5703        Ok(())
5704    }
5705
5706    /// Return the value of `field` for the node with key `key`, or `None` if
5707    /// the node or field is absent.  Reads through the overlay-over-base
5708    /// `ColumnsView`, materialising base values on demand (zero heap cost for
5709    /// overlay hits; one clone per base hit).
5710    pub fn get_prop(&self, key: &str, field: &str) -> Option<Value> {
5711        let id = self.ids.get(key)?;
5712        self.props_view().get(id, field).map(|vr| vr.into_value())
5713    }
5714
5715    pub fn has_node(&self, key: &str) -> bool {
5716        self.ids.get(key).is_some()
5717    }
5718
5719    /// Borrow the raw id map. Used by `NodeMask::from_keys` to resolve keys.
5720    pub(crate) fn ids(&self) -> &IdMap {
5721        &self.ids
5722    }
5723
5724    // -----------------------------------------------------------------------
5725    // RBAC role resolution
5726    // -----------------------------------------------------------------------
5727
5728    /// Parse `roles.json` bytes from `fs`.
5729    ///
5730    /// Return values:
5731    ///   `Ok(Some(roles))` — file absent (returns `vec![]`) **or** file present
5732    ///                       and valid; in both cases `mask_for_role` uses the
5733    ///                       list normally (an absent file means no roles defined).
5734    ///   `Ok(None)`        — file present but corrupt or unrecognised version
5735    ///                       → poisoned state; `mask_for_role` returns `Err` for
5736    ///                       any role name until the file is fixed and the DB
5737    ///                       re-opened (or `apply_schema` is called to repair it).
5738    ///
5739    /// Note: `None` signals corruption, not absence — the opposite of what an
5740    /// optional "file missing" convention would suggest.  The open path stores
5741    /// this result on `db.roles` directly.
5742    fn load_roles_from_fs(fs: &F) -> Result<Option<Vec<RoleDef>>> {
5743        let bytes = fs.read(FileId::Roles).map_err(GraphError::Io)?;
5744        if bytes.is_empty() {
5745            // Empty bytes means either the file is absent or zero-byte — both
5746            // are treated identically as "no roles defined".  A zero-byte
5747            // roles.json does NOT widen access: an absent file and a zero-byte
5748            // file both resolve to an empty role list (sees nothing by default).
5749            return Ok(Some(vec![]));
5750        }
5751        match serde_json::from_slice::<RolesFile>(&bytes) {
5752            Ok(f) if f.version == 1 || f.version == 2 => Ok(Some(f.roles)),
5753            // Corrupt or unrecognised version (>2): poison the roles state.
5754            _ => Ok(None),
5755        }
5756    }
5757
5758    /// Resolve a role to a node-visibility mask against the current graph state.
5759    ///
5760    /// Returns `Err` when:
5761    /// - `roles.json` was present but corrupt at open (poisoned state), or
5762    /// - `role` does not match any defined role name.
5763    ///
5764    /// The mask union is: explicit `keys` (unknown keys silently ignored) plus
5765    /// all live nodes carrying any label in `labels`.  Label resolution is live
5766    /// — new nodes of an allowed label are visible without re-applying the
5767    /// schema.  An empty union = empty mask = sees nothing.
5768    pub fn mask_for_role(&self, role: &str) -> Result<crate::mask::NodeMask> {
5769        let roles = self.roles.as_ref().ok_or_else(|| GraphError::Corrupt {
5770            detail:
5771                "roles.json was corrupt at open; fix the file and re-open to restore role access"
5772                    .into(),
5773        })?;
5774        let def = roles
5775            .iter()
5776            .find(|r| r.name == role)
5777            .ok_or_else(|| GraphError::KeyNotFound {
5778                key: format!("role:{role}"),
5779            })?;
5780
5781        let mut visible = std::collections::HashSet::new();
5782
5783        // Key leg: resolve explicit keys to dense ids (unknown keys ignored).
5784        for key in &def.keys {
5785            if let Some(id) = self.ids.get(key) {
5786                visible.insert(id);
5787            }
5788        }
5789
5790        // Label leg: live scan — iterate labels vec for matching symbol.
5791        for label_name in &def.labels {
5792            if let Some(sym) = self.syms.get(label_name) {
5793                for (i, &s) in self.labels.iter().enumerate() {
5794                    if s == sym {
5795                        visible.insert(i as u32);
5796                    }
5797                }
5798            }
5799        }
5800
5801        Ok(crate::mask::NodeMask::from_ids(visible))
5802    }
5803
5804    /// Return the current list of role definitions.
5805    ///
5806    /// Returns an empty list when no roles are defined or when `roles.json`
5807    /// was corrupt at open (check [`mask_for_role`](Self::mask_for_role) for
5808    /// the fail-loud error in that case).
5809    pub fn roles(&self) -> Vec<RoleDef> {
5810        self.roles.as_deref().unwrap_or(&[]).to_vec()
5811    }
5812
5813    // ── Role-scoped write authz ───────────────────────────────────────────────
5814
5815    /// Execute `ops` with optional role-scoped write authorization.
5816    ///
5817    /// - `None` → full authority, identical to [`write_batch`](Self::write_batch)
5818    ///   (zero-cost bypass of all authz checks).
5819    /// - `Some(authz)` → the decision table is evaluated per-op BEFORE any WAL
5820    ///   record is built.  A denial returns an error with no WAL frame written
5821    ///   (all-or-nothing at the authz boundary, then at the MutPreview boundary).
5822    ///
5823    /// See the plan's "authz decision table" section for the full semantics.
5824    pub fn write_batch_authz(
5825        &mut self,
5826        authz: Option<&WriteAuthz>,
5827        ops: Vec<BatchOp>,
5828    ) -> Result<(usize, usize)> {
5829        // Thread authz as a direct parameter — never touches pending_write_authz.
5830        self.commit_logged_batch(ops, None, authz.cloned())
5831    }
5832
5833    /// Execute a Cypher write statement with role-scoped write authorization.
5834    ///
5835    /// Resolves scope + mask from `self.roles` inside the call (same write-guard
5836    /// lifetime as execution, satisfying §5 lock discipline).  The resolved
5837    /// `WriteAuthz` is stored as `pending_write_authz` for the duration of the
5838    /// call so that all inner `batch.commit()` calls are authz-checked.
5839    ///
5840    /// MERGE is handled specially: the MERGE scope precondition (§3.3) is
5841    /// checked in `exec_merge` BEFORE `has_node` to close the §6.2
5842    /// timing-oracle item (hidden ≡ absent for unscoped roles).
5843    ///
5844    /// Roles with `write: None` (v1 behavior) → `RoleWriteDenied` with
5845    /// "this endpoint is not permitted".
5846    pub fn query_write_authz(
5847        &mut self,
5848        role: &str,
5849        cypher: &str,
5850        params: &BTreeMap<String, Value>,
5851    ) -> Result<ResultSet> {
5852        // Resolve scope (fails fast if role has no write scope).
5853        // write:None → byte-identical v1 blanket-403 body (plan §v1-sidecar mandate).
5854        let scope =
5855            {
5856                let roles = self.roles.as_deref().ok_or_else(|| GraphError::Corrupt {
5857                    detail: "roles.json was corrupt at open; re-open to restore role access".into(),
5858                })?;
5859                let def = roles.iter().find(|r| r.name == role).ok_or_else(|| {
5860                    GraphError::KeyNotFound {
5861                        key: format!("role:{role}"),
5862                    }
5863                })?;
5864                def.write
5865                    .clone()
5866                    .ok_or_else(|| GraphError::RoleWriteDenied {
5867                        reason: "role-bound token: writes are not permitted".into(),
5868                    })?
5869            };
5870        // Resolve mask inside the call (same guard, §5 coherence).
5871        let mask = self.mask_for_role(role)?;
5872        self.pending_write_authz = Some(WriteAuthz {
5873            role: role.into(),
5874            scope,
5875            mask,
5876        });
5877        // RAII guard: always clears pending_write_authz on scope exit, including
5878        // on panic or early-return, mirroring the RestoreEmitDeltas precedent.
5879        struct ClearPendingAuthzOnDrop(*mut Option<WriteAuthz>);
5880        impl Drop for ClearPendingAuthzOnDrop {
5881            fn drop(&mut self) {
5882                // SAFETY: pointer into the owning GraphDb; guard is dropped
5883                // within this function's frame before it returns.
5884                unsafe { *self.0 = None };
5885            }
5886        }
5887        // SAFETY: raw pointer into self; guard dropped before this fn returns.
5888        let _authz_guard = ClearPendingAuthzOnDrop(&mut self.pending_write_authz as *mut _);
5889        let tokens = lex(cypher).map_err(|e| GraphError::QueryError {
5890            detail: format!("lex: {e}"),
5891        })?;
5892        let stmt = parse_write(&tokens).map_err(|e| GraphError::QueryError {
5893            detail: format!("parse: {e}"),
5894        })?;
5895        self.exec_write_stmt(stmt, params)
5896    }
5897
5898    /// Execute `ops` with optional role-scoped write authorization, suppressing
5899    /// fsync (for use inside the group-commit drain thread, which performs one
5900    /// group fsync after releasing the write lock).
5901    ///
5902    /// Identical to [`write_batch_authz`] except the fsync policy is temporarily
5903    /// forced to `Relaxed` for the duration of the call, matching the drain-thread
5904    /// contract established by [`commit_batch_nosync`].
5905    pub(crate) fn write_batch_authz_nosync(
5906        &mut self,
5907        authz: Option<&WriteAuthz>,
5908        ops: Vec<BatchOp>,
5909    ) -> Result<(usize, usize)> {
5910        let saved = self.fsync;
5911        struct RestoreFsync(*mut FsyncPolicy, FsyncPolicy);
5912        impl Drop for RestoreFsync {
5913            fn drop(&mut self) {
5914                // SAFETY: pointer into the owning GraphDb; guard is dropped
5915                // within the enclosing function's frame before it returns.
5916                unsafe { *self.0 = self.1 };
5917            }
5918        }
5919        // SAFETY: raw pointer into self; guard dropped before this fn returns.
5920        let _g = RestoreFsync(&mut self.fsync as *mut FsyncPolicy, saved);
5921        self.fsync = FsyncPolicy::Relaxed;
5922        self.commit_logged_batch(ops, None, authz.cloned())
5923    }
5924
5925    /// Execute a `/ingest` request with role-scoped write authorization.
5926    ///
5927    /// Resolves the role's `WriteScope` and `NodeMask` inside this call (same
5928    /// write-guard lifetime as the mutation, satisfying §5 lock discipline).
5929    /// Sets `pending_write_authz` for the duration of the call so that the
5930    /// `commit_ingest` → `commit_logged_batch` path picks up the authz context
5931    /// and evaluates the decision table per-op before any WAL write.
5932    ///
5933    /// §7.3: roles with empty `create_labels` will see every `InsertNode` op
5934    /// denied by the decision table with the appropriate §4.3 scope reason;
5935    /// no special HTTP-layer check is needed.
5936    ///
5937    /// Roles with `write: None` return `RoleWriteDenied` with
5938    /// "writes are not permitted" (byte-identical to v1 blanket 403).
5939    pub fn ingest_with_edges_authz(
5940        &mut self,
5941        role: &str,
5942        label: &str,
5943        rows: Vec<std::collections::BTreeMap<String, Value>>,
5944        opts: &crate::ingest::IngestOptions,
5945        edges: &[(String, String, String)],
5946    ) -> Result<crate::ingest::IngestReport> {
5947        // Resolve scope (fails fast if role has no write scope).
5948        // write:None → byte-identical v1 blanket-403 body (plan §v1-sidecar mandate).
5949        let scope =
5950            {
5951                let roles = self.roles.as_deref().ok_or_else(|| GraphError::Corrupt {
5952                    detail: "roles.json was corrupt at open; re-open to restore role access".into(),
5953                })?;
5954                let def = roles.iter().find(|r| r.name == role).ok_or_else(|| {
5955                    GraphError::KeyNotFound {
5956                        key: format!("role:{role}"),
5957                    }
5958                })?;
5959                def.write
5960                    .clone()
5961                    .ok_or_else(|| GraphError::RoleWriteDenied {
5962                        reason: "role-bound token: writes are not permitted".into(),
5963                    })?
5964            };
5965        let mask = self.mask_for_role(role)?;
5966        self.pending_write_authz = Some(WriteAuthz {
5967            role: role.into(),
5968            scope,
5969            mask,
5970        });
5971        // RAII guard: always clears pending_write_authz on scope exit, including
5972        // on panic or early-return, mirroring the RestoreEmitDeltas precedent.
5973        struct ClearPendingAuthzOnDrop(*mut Option<WriteAuthz>);
5974        impl Drop for ClearPendingAuthzOnDrop {
5975            fn drop(&mut self) {
5976                // SAFETY: pointer into the owning GraphDb; guard is dropped
5977                // within this function's frame before it returns.
5978                unsafe { *self.0 = None };
5979            }
5980        }
5981        // SAFETY: raw pointer into self; guard dropped before this fn returns.
5982        let _authz_guard = ClearPendingAuthzOnDrop(&mut self.pending_write_authz as *mut _);
5983        self.ingest_with_edges(label, rows, opts, edges)
5984    }
5985
5986    /// Evaluate the write-authz decision table for one `BatchOp`.
5987    ///
5988    /// Called by `commit_logged_batch` for each op when `pending_write_authz`
5989    /// is `Some`, BEFORE MutPreview.  A denial returns an error immediately;
5990    /// the remaining ops are not evaluated and no WAL frame is written.
5991    ///
5992    /// `batch_created` carries the key→label pairs of nodes that earlier ops in
5993    /// THIS batch will create.  Used by `InsertEdgeUpsert` to count same-batch
5994    /// placeholder nodes as visible (spec: "a placeholder endpoint the SAME
5995    /// batch creates counts as visible if its label passed the create-class gate").
5996    fn check_single_op_authz(
5997        &self,
5998        authz: &WriteAuthz,
5999        op: &BatchOp,
6000        batch_created: &BTreeMap<String, String>,
6001    ) -> Result<()> {
6002        // Helper: 3-way node status under the authz mask.
6003        //
6004        // Batch-created nodes (from earlier InsertNode in THIS batch) are treated
6005        // as Visible with their recorded label — their create gate already passed
6006        // and they are not yet in self.ids (not committed).  This fixes the
6007        // MERGE+ON CREATE SET case where InsertNode + SetProp arrive together:
6008        // the SetProp must not see the node as Absent.
6009        let node_status = |key: &str| -> NodeAuthzStatus {
6010            if let Some(label) = batch_created.get(key) {
6011                return NodeAuthzStatus::Visible(label.clone());
6012            }
6013            match self.ids.get(key) {
6014                None => NodeAuthzStatus::Absent,
6015                Some(id) if !authz.mask.contains_id(id) => NodeAuthzStatus::Hidden,
6016                Some(id) => {
6017                    let label = self
6018                        .labels
6019                        .get(id as usize)
6020                        .and_then(|&sym| {
6021                            if sym == u32::MAX {
6022                                None
6023                            } else {
6024                                self.syms.resolve(sym).map(str::to_string)
6025                            }
6026                        })
6027                        .unwrap_or_default();
6028                    NodeAuthzStatus::Visible(label)
6029                }
6030            }
6031        };
6032
6033        // Helper: is an InsertEdgeUpsert endpoint visible?
6034        // A same-batch placeholder counts as visible if its label passed
6035        // the create-class gate (spec "upsert placeholder-counts-as-visible").
6036        let upsert_ep_visible = |ep_key: &str, placeholder_label: &str| -> bool {
6037            // In store and visible?
6038            if let Some(id) = self.ids.get(ep_key) {
6039                return authz.mask.contains_id(id);
6040            }
6041            // Created by an earlier op in this batch?
6042            if let Some(created_label) = batch_created.get(ep_key) {
6043                return authz.scope.create_labels.contains(created_label);
6044            }
6045            // Will be created by THIS InsertEdgeUpsert: placeholder_label
6046            // must pass the create-class gate.
6047            authz
6048                .scope
6049                .create_labels
6050                .contains(&placeholder_label.to_string())
6051        };
6052
6053        match op {
6054            // RenameNode / CreateRule / DeleteRule: defense-in-depth gate.
6055            // These ops are never routed to role-scoped paths by the HTTP layer,
6056            // but we 403 them here to close any future bypass route.
6057            BatchOp::RenameNode { .. } | BatchOp::CreateRule(_) | BatchOp::DeleteRule { .. } => {
6058                return Err(GraphError::RoleWriteDenied {
6059                    reason: "role-bound token: this endpoint is not permitted".into(),
6060                });
6061            }
6062
6063            // ── CREATE-class: InsertNode ─────────────────────────────────────
6064            //
6065            // Decision table row 1 (scope-before-lookup): check label in
6066            // create_labels BEFORE any key lookup.  This is the structural
6067            // closure of the §6.2 timing-oracle item — the denial fires even
6068            // when the store is EMPTY (see test_create_scope_denied_empty_store).
6069            BatchOp::InsertNode { label, key, .. } => {
6070                if !authz.scope.create_labels.contains(label) {
6071                    return Err(GraphError::RoleWriteDenied {
6072                        reason: format!(
6073                            "role-bound token: label '{}' not in write scope (create_labels)",
6074                            label
6075                        ),
6076                    });
6077                }
6078                // Row 2/3: key lookup.
6079                match self.ids.get(key.as_str()) {
6080                    Some(id) if authz.mask.contains_id(id) => {
6081                        // Visible: DuplicateKey — let MutPreview handle this.
6082                    }
6083                    Some(_) => {
6084                        // Hidden: indistinguishable from absent to the role.
6085                        return Err(GraphError::RoleWriteDenied {
6086                            reason: "role-bound token: target node not visible".into(),
6087                        });
6088                    }
6089                    None => {
6090                        // Absent: proceed (create).
6091                    }
6092                }
6093            }
6094
6095            // ── UPDATE-class: SetProp, RemoveProp ────────────────────────────
6096            BatchOp::SetProp { key, .. } | BatchOp::RemoveProp { key, .. } => {
6097                if batch_created.contains_key(key.as_str()) {
6098                    // Batch-created node: create gate already passed this batch.
6099                    // Updating it in the same batch is always allowed, regardless
6100                    // of update_labels (ruling §3.5: "writer just created it").
6101                } else {
6102                    let label = match node_status(key) {
6103                        NodeAuthzStatus::Visible(lbl) => lbl,
6104                        _ => {
6105                            return Err(GraphError::RoleWriteDenied {
6106                                reason: "role-bound token: target node not visible".into(),
6107                            });
6108                        }
6109                    };
6110                    if !authz.scope.update_labels.contains(&label) {
6111                        return Err(GraphError::RoleWriteDenied {
6112                            reason: format!(
6113                                "role-bound token: label '{}' not in write scope (update_labels)",
6114                                label
6115                            ),
6116                        });
6117                    }
6118                }
6119            }
6120
6121            // ── DELETE-class: DeleteNode ─────────────────────────────────────
6122            BatchOp::DeleteNode { key } => {
6123                let label = match node_status(key) {
6124                    NodeAuthzStatus::Visible(lbl) => lbl,
6125                    _ => {
6126                        return Err(GraphError::RoleWriteDenied {
6127                            reason: "role-bound token: target node not visible".into(),
6128                        });
6129                    }
6130                };
6131                if !authz.scope.delete_labels.contains(&label) {
6132                    return Err(GraphError::RoleWriteDenied {
6133                        reason: format!(
6134                            "role-bound token: label '{}' not in write scope (delete_labels)",
6135                            label
6136                        ),
6137                    });
6138                }
6139            }
6140
6141            // ── DELETE-class: DeleteEdge ─────────────────────────────────────
6142            //
6143            // Derived-edge rejection runs BEFORE the delete_edge_types scope
6144            // check (spec §3.5: "existing derived-edge rejection precedes
6145            // delete_edge_types check").
6146            BatchOp::DeleteEdge {
6147                edge_type,
6148                src_key,
6149                dst_key,
6150            } => {
6151                // Check provenance ownership BEFORE scope (spec §3.5 ordering).
6152                if let (Some(src_id), Some(dst_id), Some(et_sym)) = (
6153                    self.ids.get(src_key.as_str()),
6154                    self.ids.get(dst_key.as_str()),
6155                    self.syms.get(edge_type.as_str()),
6156                ) {
6157                    if self.engine.is_owned(et_sym, src_id, dst_id) {
6158                        return Err(GraphError::RuleOwned {
6159                            detail: format!(
6160                                "edge {edge_type} {src_key}→{dst_key} is rule-owned; \
6161                                 delete or change the owning rule"
6162                            ),
6163                        });
6164                    }
6165                    // Also check would_derive via MutPreview (empty overlay, pre-batch).
6166                    let preview = MutPreview::new(self);
6167                    if preview.would_derive(edge_type, src_key, dst_key) {
6168                        return Err(GraphError::RuleOwned {
6169                            detail: format!(
6170                                "edge {edge_type} {src_key}→{dst_key} is rule-owned; \
6171                                 delete or change the owning rule, or a live rule would \
6172                                 re-derive it"
6173                            ),
6174                        });
6175                    }
6176                }
6177                // Scope check (AFTER derived-edge check, BEFORE endpoint visibility).
6178                if !authz.scope.delete_edge_types.contains(edge_type) {
6179                    return Err(GraphError::RoleWriteDenied {
6180                        reason: format!(
6181                            "role-bound token: edge type '{}' not in write scope (delete_edge_types)",
6182                            edge_type
6183                        ),
6184                    });
6185                }
6186                // Both endpoints must be visible.
6187                for ep_key in [src_key.as_str(), dst_key.as_str()] {
6188                    match self.ids.get(ep_key) {
6189                        None => {
6190                            return Err(GraphError::RoleWriteDenied {
6191                                reason: "role-bound token: edge endpoint not visible".into(),
6192                            });
6193                        }
6194                        Some(id) if !authz.mask.contains_id(id) => {
6195                            return Err(GraphError::RoleWriteDenied {
6196                                reason: "role-bound token: edge endpoint not visible".into(),
6197                            });
6198                        }
6199                        _ => {}
6200                    }
6201                }
6202            }
6203
6204            // ── EDGE-CREATE: InsertEdge ──────────────────────────────────────
6205            //
6206            // Scope check BEFORE endpoint lookup (preserves timing symmetry).
6207            BatchOp::InsertEdge {
6208                edge_type,
6209                src_key,
6210                dst_key,
6211            } => {
6212                if !authz.scope.create_edge_types.contains(edge_type) {
6213                    return Err(GraphError::RoleWriteDenied {
6214                        reason: format!(
6215                            "role-bound token: edge type '{}' not in write scope (create_edge_types)",
6216                            edge_type
6217                        ),
6218                    });
6219                }
6220                // Both endpoints must be visible. A node created by an earlier
6221                // InsertNode in the same batch (tracked in batch_created) counts
6222                // as visible if its label passed the create-class gate.
6223                for ep_key in [src_key.as_str(), dst_key.as_str()] {
6224                    if batch_created.contains_key(ep_key) {
6225                        // Created earlier this batch — already scope-checked.
6226                        continue;
6227                    }
6228                    match self.ids.get(ep_key) {
6229                        None => {
6230                            return Err(GraphError::RoleWriteDenied {
6231                                reason: "role-bound token: edge endpoint not visible".into(),
6232                            });
6233                        }
6234                        Some(id) if !authz.mask.contains_id(id) => {
6235                            return Err(GraphError::RoleWriteDenied {
6236                                reason: "role-bound token: edge endpoint not visible".into(),
6237                            });
6238                        }
6239                        _ => {}
6240                    }
6241                }
6242            }
6243
6244            // ── EDGE-CREATE: InsertEdgeUpsert ────────────────────────────────
6245            //
6246            // Scope check first; then endpoint visibility using same-batch
6247            // placeholder awareness (spec: "a placeholder endpoint the SAME
6248            // batch creates counts as visible if its label passed the
6249            // create-class gate").
6250            BatchOp::InsertEdgeUpsert {
6251                edge_type,
6252                src_key,
6253                dst_key,
6254                placeholder_label,
6255            } => {
6256                if !authz.scope.create_edge_types.contains(edge_type) {
6257                    return Err(GraphError::RoleWriteDenied {
6258                        reason: format!(
6259                            "role-bound token: edge type '{}' not in write scope (create_edge_types)",
6260                            edge_type
6261                        ),
6262                    });
6263                }
6264                // Check placeholder label against create_labels (create-class gate).
6265                // This ensures the auto-created endpoints are scope-allowed.
6266                for ep_key in [src_key.as_str(), dst_key.as_str()] {
6267                    if !upsert_ep_visible(ep_key, placeholder_label) {
6268                        return Err(GraphError::RoleWriteDenied {
6269                            reason: "role-bound token: edge endpoint not visible".into(),
6270                        });
6271                    }
6272                }
6273            }
6274        }
6275        Ok(())
6276    }
6277
6278    /// Write `roles` to `roles.json` atomically and update the in-memory list.
6279    ///
6280    /// Called by `apply_schema` when roles change. Never called on unchanged
6281    /// re-apply — this preserves byte-identical idempotency.
6282    pub(crate) fn commit_roles(&mut self, roles: Vec<RoleDef>) -> Result<()> {
6283        let file = RolesFile::new_versioned(roles.clone());
6284        let bytes = serde_json::to_vec(&file).map_err(|e| GraphError::Corrupt {
6285            detail: format!("roles serialization: {e}"),
6286        })?;
6287        self.fs
6288            .write_atomic(FileId::Roles, &bytes)
6289            .map_err(GraphError::Io)?;
6290        self.roles = Some(roles);
6291        // Refresh the MVCC frozen overlay so that reader() immediately sees the
6292        // updated role definitions without waiting for the next K-commit fold.
6293        self.fold_now();
6294        Ok(())
6295    }
6296
6297    fn view(&self) -> GraphView<'_> {
6298        GraphView {
6299            ids: &self.ids,
6300            syms: &self.syms,
6301            labels: &self.labels,
6302            props: self.props_view(),
6303            topo: self.topo_view(),
6304            edge_props: self.edge_props_view(),
6305            mask: None,
6306            prop_index: Some(&self.prop_index),
6307        }
6308    }
6309
6310    fn view_masked<'a>(&'a self, mask: &'a crate::mask::NodeMask) -> GraphView<'a> {
6311        GraphView {
6312            ids: &self.ids,
6313            syms: &self.syms,
6314            labels: &self.labels,
6315            props: self.props_view(),
6316            topo: self.topo_view(),
6317            edge_props: self.edge_props_view(),
6318            mask: Some(&mask.visible),
6319            prop_index: Some(&self.prop_index),
6320        }
6321    }
6322
6323    /// Execute a read-only Cypher query with a node visibility mask.
6324    ///
6325    /// Only nodes whose key is in `mask` are accessible: label scans, key
6326    /// lookups, and neighbor expansions all respect the mask. Edges where
6327    /// either endpoint is hidden are silently dropped.
6328    ///
6329    /// Returns `Err` with a "masked queries are read-only" message when
6330    /// `cypher` is a write statement (CREATE / MERGE / MATCH…SET / DELETE).
6331    pub fn query_masked(
6332        &self,
6333        cypher: &str,
6334        params: &std::collections::BTreeMap<String, Value>,
6335        mask: &crate::mask::NodeMask,
6336    ) -> Result<ResultSet> {
6337        // Reject write statements up front.
6338        let tokens = lex(cypher).map_err(|e| GraphError::QueryError {
6339            detail: format!("lex: {e}"),
6340        })?;
6341        if is_write_tokens(&tokens) {
6342            return Err(GraphError::MaskedReadOnly);
6343        }
6344        let union = parse_read(&tokens).map_err(|e| GraphError::QueryError {
6345            detail: format!("parse: {e}"),
6346        })?;
6347        // Each UNION part executes against the same masked view, so the mask
6348        // applies uniformly across the chain.
6349        execute_union(&self.view_masked(mask), &union, &Params(params)).map_err(|e| {
6350            GraphError::QueryError {
6351                detail: format!("execute: {e}"),
6352            }
6353        })
6354    }
6355
6356    pub fn node_ref(&self, key: &str) -> Option<NodeRef<'_, F>> {
6357        let id = self.ids.get(key)?;
6358        Some(NodeRef { db: self, id })
6359    }
6360
6361    /// BFS neighborhood expansion restricted to visible nodes in `mask`.
6362    ///
6363    /// Hidden nodes are never used as traversal intermediaries in either
6364    /// [`MaskMode::Omit`] or [`MaskMode::Stub`] — a visible node reachable
6365    /// only through a hidden node will not appear in results.
6366    ///
6367    /// In [`MaskMode::Stub`] mode, hidden nodes that are direct neighbours of
6368    /// a visited visible node are appended to the result as stub rows
6369    /// (`label` column is `null`, same key+depth columns as visible rows).
6370    /// They are NOT added to the BFS frontier.
6371    ///
6372    /// Returns `None` when `key` does not exist (caller should 404).
6373    ///
6374    /// **SECURITY**: role-token callers always pass an Omit-mode mask, so
6375    /// stub rows are never produced on the role path.
6376    pub fn neighborhood_masked(
6377        &self,
6378        key: &str,
6379        depth: u32,
6380        edge_types: Option<&[&str]>,
6381        dir: Dir,
6382        mask: &crate::mask::NodeMask,
6383    ) -> Option<ResultSet> {
6384        let start_id = self.ids.get(key)?;
6385        let view = self.view_masked(mask);
6386        let resolved: Option<Vec<u32>> = edge_types.map(|names| {
6387            names
6388                .iter()
6389                .filter_map(|name| view.syms.get(name))
6390                .collect()
6391        });
6392        let nb = neighborhood(&view, start_id, depth, resolved.as_deref(), dir);
6393        let mut rs = ResultSet::new(vec!["key".into(), "label".into(), "depth".into()]);
6394        // Collect visible BFS results (start_id at depth 0, BFS nodes after).
6395        let mut visited: Vec<(u32, u32)> = Vec::with_capacity(nb.nodes.len() + 1);
6396        visited.push((start_id, 0));
6397        for (nid, d) in &nb.nodes {
6398            let k = view.key_of(*nid);
6399            let label = view
6400                .label_of(*nid)
6401                .expect("real nodes always have a label; u32::MAX sentinel cannot occur");
6402            rs.push_row(vec![
6403                Some(Value::Str(k.to_string())),
6404                Some(Value::Str(label.to_string())),
6405                Some(Value::Int(*d as i64)),
6406            ]);
6407            visited.push((*nid, *d));
6408        }
6409        // Stub mode: add hidden direct neighbours of each visited node as stubs.
6410        // Hidden nodes are edge-endpoints only — they are not added to the BFS
6411        // frontier, so the BFS never expands through them.
6412        if mask.mode() == crate::mask::MaskMode::Stub {
6413            let raw_view = self.view();
6414            let mut seen: std::collections::HashSet<u32> =
6415                visited.iter().map(|(id, _)| *id).collect();
6416            for (node_id, node_depth) in &visited {
6417                if *node_depth >= depth {
6418                    continue;
6419                }
6420                for e in expand(&raw_view, *node_id, resolved.as_deref(), dir) {
6421                    let nbr = if e.src == *node_id { e.dst } else { e.src };
6422                    if !mask.contains_id(nbr) && seen.insert(nbr) {
6423                        if let Some(k) = self.ids.key_of(nbr) {
6424                            rs.push_row(vec![
6425                                Some(Value::Str(k.to_string())),
6426                                None,
6427                                Some(Value::Int((*node_depth + 1) as i64)),
6428                            ]);
6429                        }
6430                    }
6431                }
6432            }
6433        }
6434        Some(rs)
6435    }
6436
6437    /// Live node's key, label, and columnar props. Unknown or tombstoned → `None`.
6438    pub fn node_info(&self, key: &str) -> Option<NodeInfo> {
6439        let n = self.node_ref(key)?;
6440        Some(NodeInfo {
6441            key: n.key().to_string(),
6442            label: n.label().to_string(),
6443            props: n.props(),
6444        })
6445    }
6446
6447    /// Look up a node with mask awareness.
6448    ///
6449    /// | Key state         | Omit mode       | Stub mode              |
6450    /// |-------------------|-----------------|------------------------|
6451    /// | does not exist    | `None` (→ 404)  | `None` (→ 404)         |
6452    /// | exists, visible   | `Some(Visible)` | `Some(Visible)`        |
6453    /// | exists, hidden    | `None` (→ 404)  | `Some(Restricted)`     |
6454    ///
6455    /// **SECURITY**: only call from client-mask (full-token) paths.
6456    /// Role-token paths must use [`node_info`] after an explicit visibility check.
6457    pub fn node_info_masked(
6458        &self,
6459        key: &str,
6460        mask: &crate::mask::NodeMask,
6461    ) -> Option<MaskedNodeResult> {
6462        let id = self.ids.get(key)?;
6463        if mask.contains_id(id) {
6464            Some(MaskedNodeResult::Visible(self.node_info(key)?))
6465        } else {
6466            match mask.mode() {
6467                crate::mask::MaskMode::Stub => Some(MaskedNodeResult::Restricted),
6468                crate::mask::MaskMode::Omit => None,
6469            }
6470        }
6471    }
6472
6473    /// Get edges for `key` with mask-aware hidden-endpoint handling.
6474    ///
6475    /// - Omit mode: edges to hidden endpoints are excluded (same as role-path filtering).
6476    /// - Stub mode: edges to hidden endpoints are included; `src_restricted`/`dst_restricted`
6477    ///   is `true` for each hidden endpoint.
6478    ///
6479    /// Unknown key → [`GraphError::KeyNotFound`].
6480    ///
6481    /// **SECURITY**: only call from client-mask (full-token) paths.
6482    pub fn node_edges_masked(
6483        &self,
6484        key: &str,
6485        mask: &crate::mask::NodeMask,
6486    ) -> Result<Vec<MaskedEdge>> {
6487        self.ensure_v8_base_sections_loaded();
6488        let id = self
6489            .ids
6490            .get(key)
6491            .ok_or_else(|| GraphError::KeyNotFound { key: key.into() })?;
6492        let derived: BTreeSet<(u32, u32, u32)> = self
6493            .engine
6494            .provenance_touching(id)
6495            .map(|(_rule, etype, src, dst)| (etype, src, dst))
6496            .collect();
6497        let mut edges = Vec::new();
6498        let tv = self.topo_view();
6499        for etype in tv.etypes() {
6500            // etype comes from the archived CSR (access_unchecked, no eager CRC).
6501            // A bit-flip in the large TOPOLOGY section can produce an etype id
6502            // that is not in the interner.  Return Corrupt rather than panic.
6503            let edge_type = self
6504                .syms
6505                .resolve(etype)
6506                .ok_or_else(|| GraphError::Corrupt {
6507                    detail: format!("v8: topology etype {etype} not in interner"),
6508                })?
6509                .to_string();
6510            for dir in [Direction::Out, Direction::In] {
6511                for &nbr in tv.neighbors(etype, dir, id).as_ref() {
6512                    let nbr_restricted = !mask.contains_id(nbr);
6513                    if nbr_restricted && mask.mode() == crate::mask::MaskMode::Omit {
6514                        continue;
6515                    }
6516                    let nbr_key = self
6517                        .ids
6518                        .key_of(nbr)
6519                        .ok_or_else(|| GraphError::Corrupt {
6520                            detail: format!("topology id {nbr} has no key"),
6521                        })?
6522                        .to_string();
6523                    let (src_id, dst_id, src_key, dst_key, src_restricted, dst_restricted) =
6524                        match dir {
6525                            Direction::Out => {
6526                                (id, nbr, key.to_string(), nbr_key, false, nbr_restricted)
6527                            }
6528                            Direction::In => {
6529                                (nbr, id, nbr_key, key.to_string(), nbr_restricted, false)
6530                            }
6531                        };
6532                    edges.push(MaskedEdge {
6533                        edge_type: edge_type.clone(),
6534                        src_key,
6535                        src_restricted,
6536                        dst_key,
6537                        dst_restricted,
6538                        derived: derived.contains(&(etype, src_id, dst_id)),
6539                    });
6540                }
6541            }
6542        }
6543        edges.sort_by(|a, b| {
6544            a.edge_type
6545                .cmp(&b.edge_type)
6546                .then(a.src_key.cmp(&b.src_key))
6547                .then(a.dst_key.cmp(&b.dst_key))
6548        });
6549        edges.dedup_by(|a, b| {
6550            a.edge_type == b.edge_type && a.src_key == b.src_key && a.dst_key == b.dst_key
6551        });
6552        Ok(edges)
6553    }
6554
6555    /// Every directed edge incident on `key`, both directions, every etype.
6556    ///
6557    /// Walk is `topology.etypes()` × `{Out, In}` × `neighbors()`. `derived` is
6558    /// membership in [`RuleEngine::provenance_touching`] (O(degree) via the
6559    /// Plan-8 `by_node` index). Sorted by `(edge_type, src_key, dst_key)`.
6560    /// Unknown key → [`GraphError::KeyNotFound`].
6561    pub fn node_edges(&self, key: &str) -> Result<Vec<EdgeInfo>> {
6562        self.ensure_v8_base_sections_loaded();
6563        let id = self
6564            .ids
6565            .get(key)
6566            .ok_or_else(|| GraphError::KeyNotFound { key: key.into() })?;
6567        let derived: BTreeSet<(u32, u32, u32)> = self
6568            .engine
6569            .provenance_touching(id)
6570            .map(|(_rule, etype, src, dst)| (etype, src, dst))
6571            .collect();
6572        let mut edges = Vec::new();
6573        let tv = self.topo_view();
6574        for etype in tv.etypes() {
6575            // Same guard as node_edges_masked: etype from unchecked-CRC CSR.
6576            let edge_type = self
6577                .syms
6578                .resolve(etype)
6579                .ok_or_else(|| GraphError::Corrupt {
6580                    detail: format!("v8: topology etype {etype} not in interner"),
6581                })?
6582                .to_string();
6583            for dir in [Direction::Out, Direction::In] {
6584                for &nbr in tv.neighbors(etype, dir, id).as_ref() {
6585                    let (src, dst, src_key, dst_key) = match dir {
6586                        Direction::Out => (
6587                            id,
6588                            nbr,
6589                            key.to_string(),
6590                            self.ids
6591                                .key_of(nbr)
6592                                .ok_or_else(|| GraphError::Corrupt {
6593                                    detail: format!("topology id {nbr} has no key"),
6594                                })?
6595                                .to_string(),
6596                        ),
6597                        Direction::In => (
6598                            nbr,
6599                            id,
6600                            self.ids
6601                                .key_of(nbr)
6602                                .ok_or_else(|| GraphError::Corrupt {
6603                                    detail: format!("topology id {nbr} has no key"),
6604                                })?
6605                                .to_string(),
6606                            key.to_string(),
6607                        ),
6608                    };
6609                    edges.push(EdgeInfo {
6610                        edge_type: edge_type.clone(),
6611                        src_key,
6612                        dst_key,
6613                        derived: derived.contains(&(etype, src, dst)),
6614                    });
6615                }
6616            }
6617        }
6618        edges.sort_by(|a, b| {
6619            a.edge_type
6620                .cmp(&b.edge_type)
6621                .then(a.src_key.cmp(&b.src_key))
6622                .then(a.dst_key.cmp(&b.dst_key))
6623        });
6624        // Self-loops appear in both Out and In; sort makes the pair adjacent
6625        // (sort key matches PartialEq for this case) so one pass drops the dup.
6626        edges.dedup();
6627        Ok(edges)
6628    }
6629
6630    // ── Backup ────────────────────────────────────────────────────────────────
6631
6632    /// Copy this store to `dest` as a consistent, verified snapshot.
6633    ///
6634    /// Copies every durable file in the database directory — `snapshot.bin`,
6635    /// `wal.bin`, all `wal.<N>.archive` files, `wal.floor`, `wal.genesis`, and
6636    /// `roles.json` — into a freshly created `dest` directory using OS-level
6637    /// `copy` calls (no large in-process buffers).
6638    ///
6639    /// # Consistency guarantee
6640    ///
6641    /// The guarantee is **process-local**: the caller holds `&self`, which
6642    /// prevents any concurrent writer in the **same process** from modifying
6643    /// the files during the copy.  Running `mushroomdb backup` against a
6644    /// directory that is **concurrently being written by another process** (e.g.
6645    /// `mushroomdb serve`) is **unsafe** — the copy can be torn.  The post-copy
6646    /// `verified: true` result reduces but does not eliminate the risk of a
6647    /// silent corrupt backup (CRC catches many bit-flips; it cannot catch a
6648    /// consistent mid-write snapshot).
6649    ///
6650    /// **The safe path for a live-served store is `POST /backup` on the HTTP
6651    /// server.** That handler acquires the read lock on the shared database
6652    /// before calling this method, which is the correct cross-process
6653    /// synchronisation point because the server is the single process writing
6654    /// the files.
6655    ///
6656    /// After copying, opens the destination read-only and runs the CRC section
6657    /// verifier (`verify_snapshot`) to confirm byte-for-byte integrity.
6658    /// `BackupReport::verified` reflects whether both checks passed.
6659    ///
6660    /// Returns `Err` when `self` is not backed by a `RealFs` (e.g. `SimFs`).
6661    pub fn backup_to(&self, dest: &std::path::Path) -> Result<BackupReport> {
6662        // Derive source directory from snapshot_path (RealFs only).
6663        let src_dir = match self.fs.snapshot_path() {
6664            Some(p) => p.parent().map(|d| d.to_path_buf()).ok_or_else(|| {
6665                GraphError::Io(std::io::Error::other("snapshot has no parent dir"))
6666            })?,
6667            None => {
6668                return Err(GraphError::Io(std::io::Error::other(
6669                    "backup_to requires a real filesystem (RealFs)",
6670                )))
6671            }
6672        };
6673
6674        std::fs::create_dir_all(dest)?;
6675
6676        let mut files: Vec<String> = Vec::new();
6677        let mut bytes: u64 = 0;
6678
6679        // Helper: copy src_dir/name → dest/name if the file exists.
6680        let mut try_copy = |name: &str| -> std::io::Result<()> {
6681            let src_path = src_dir.join(name);
6682            if src_path.exists() {
6683                let n = std::fs::copy(&src_path, dest.join(name))?;
6684                bytes += n;
6685                files.push(name.to_string());
6686            }
6687            Ok(())
6688        };
6689
6690        try_copy("snapshot.bin")?;
6691        try_copy("snapshot.bin.bak")?;
6692        try_copy("wal.bin")?;
6693        try_copy("wal.floor")?;
6694        try_copy("wal.genesis")?;
6695        try_copy("roles.json")?;
6696
6697        // Copy WAL archives.
6698        let archives = self.fs.list_archives()?;
6699        for n in &archives {
6700            let name = format!("wal.{n}.archive");
6701            let n_bytes = std::fs::copy(src_dir.join(&name), dest.join(&name))?;
6702            bytes += n_bytes;
6703            files.push(name);
6704        }
6705
6706        files.sort();
6707
6708        // Post-copy verification: open dest and run CRC checks.
6709        let snap_in_dest = dest.join("snapshot.bin").exists();
6710        let crc_ok = if snap_in_dest {
6711            crate::verify_snapshot(dest)
6712                .map(|results| results.iter().all(|(_, _, _, r)| r.is_ok()))
6713                .unwrap_or(false)
6714        } else {
6715            true // WAL-only store: nothing to CRC-check in snapshot
6716        };
6717        let opens_ok = GraphDb::<core_storage::fs::RealFs>::open(dest).is_ok();
6718        let verified = crc_ok && opens_ok;
6719
6720        Ok(BackupReport {
6721            files,
6722            bytes,
6723            verified,
6724        })
6725    }
6726
6727    // ── Export helpers ────────────────────────────────────────────────────────
6728
6729    /// All live nodes, sorted by key (deterministic).
6730    ///
6731    /// Reads base + WAL overlay. Tombstoned nodes are excluded.
6732    pub fn all_nodes_for_export(&self) -> Vec<NodeInfo> {
6733        self.ensure_v8_base_sections_loaded();
6734        let pv = self.props_view();
6735        let mut nodes = Vec::new();
6736        for id in 0..self.ids.len() as u32 {
6737            let Some(key) = self.ids.key_of(id) else {
6738                continue;
6739            };
6740            let Some(&sym) = self.labels.get(id as usize) else {
6741                continue;
6742            };
6743            if sym == u32::MAX {
6744                continue; // tombstoned
6745            }
6746            let Some(label) = self.syms.resolve(sym) else {
6747                continue;
6748            };
6749            let mut props = BTreeMap::new();
6750            for field in pv.field_names() {
6751                if let Some(vr) = pv.get(id, &field) {
6752                    props.insert(field, vr.into_value());
6753                }
6754            }
6755            nodes.push(NodeInfo {
6756                key: key.to_string(),
6757                label: label.to_string(),
6758                props,
6759            });
6760        }
6761        nodes.sort_by(|a, b| a.key.cmp(&b.key));
6762        nodes
6763    }
6764
6765    /// All directed edges, sorted by `(edge_type, src, dst)`. Each edge appears once.
6766    ///
6767    /// Derived edges carry `derived: true` and the creating rule's name in `rule`.
6768    /// Manual edges carry `derived: false` and `rule: None`.
6769    /// Deterministic across runs on the same store state.
6770    pub fn all_edges_for_export(&self) -> Vec<ExportEdge> {
6771        self.ensure_v8_base_sections_loaded();
6772
6773        // Build (etype_sym, src_id, dst_id) → rule_name for O(1) derivation lookup.
6774        let mut prov: HashMap<(u32, u32, u32), String> = HashMap::new();
6775        for (rule_name, triples) in self.engine.provenance() {
6776            for &(etype, src, dst) in triples {
6777                prov.insert((etype, src, dst), rule_name.clone());
6778            }
6779        }
6780
6781        let tv = self.topo_view();
6782        let mut edges = Vec::new();
6783
6784        for id in 0..self.ids.len() as u32 {
6785            let Some(key) = self.ids.key_of(id) else {
6786                continue;
6787            };
6788            let Some(&lsym) = self.labels.get(id as usize) else {
6789                continue;
6790            };
6791            if lsym == u32::MAX {
6792                continue; // tombstoned
6793            }
6794
6795            for etype_sym in tv.etypes() {
6796                // etype from archived CSR (access_unchecked, no eager CRC).
6797                // Skip edges whose etype is not in the interner; this can only
6798                // occur with a corrupt large TOPOLOGY section (bit-flip on an
6799                // etype field in the archived data).  The function returns Vec,
6800                // not Result, so we continue rather than propagate.
6801                let Some(edge_type) = self.syms.resolve(etype_sym) else {
6802                    continue;
6803                };
6804                let edge_type = edge_type.to_string();
6805                for &nbr in tv.neighbors(etype_sym, Direction::Out, id).as_ref() {
6806                    let Some(dst_key) = self.ids.key_of(nbr) else {
6807                        continue; // skip corrupt entries
6808                    };
6809                    let prov_key = (etype_sym, id, nbr);
6810                    let rule = prov.get(&prov_key).cloned();
6811                    let derived = rule.is_some();
6812                    edges.push(ExportEdge {
6813                        edge_type: edge_type.clone(),
6814                        src: key.to_string(),
6815                        dst: dst_key.to_string(),
6816                        derived,
6817                        rule,
6818                    });
6819                }
6820            }
6821        }
6822
6823        edges.sort_by(|a, b| {
6824            a.edge_type
6825                .cmp(&b.edge_type)
6826                .then(a.src.cmp(&b.src))
6827                .then(a.dst.cmp(&b.dst))
6828        });
6829        edges
6830    }
6831
6832    pub fn nodes_with_label(&self, label: &str) -> Vec<NodeRef<'_, F>> {
6833        self.view()
6834            .nodes_with_label(label)
6835            .into_iter()
6836            .map(|id| NodeRef { db: self, id })
6837            .collect()
6838    }
6839
6840    pub fn find_nodes(&self, label: &str, filter: &Filter) -> Vec<NodeRef<'_, F>> {
6841        let view = self.view();
6842        view.nodes_with_label(label)
6843            .into_iter()
6844            .filter(|&id| {
6845                eval_filter(filter, &|field| {
6846                    view.prop(id, field).map(|vr| vr.into_value())
6847                })
6848            })
6849            .map(|id| NodeRef { db: self, id })
6850            .collect()
6851    }
6852
6853    /// Returns `true` if any approximate (HNSW) VectorSimilar rule covers
6854    /// `field`.  Use as a capability probe: when `true`, `find_similar_vector`
6855    /// with `label = None` will use the native ANN path rather than the O(n)
6856    /// brute-force scan.
6857    pub fn has_vector_rule(&self, field: &str) -> bool {
6858        self.engine.hnsw_has_rule(field)
6859    }
6860
6861    /// Find nodes whose `field` vector is most similar to `q` (cosine
6862    /// similarity), returning up to `k` results with similarity ≥ `min`,
6863    /// sorted descending.
6864    ///
6865    /// When `label` is `None` the search spans all labels (via
6866    /// `hnsw_search_any_dst` or a full brute-force scan); when `label` is
6867    /// `Some(lbl)` it restricts to nodes with that label.
6868    ///
6869    /// Uses the HNSW index when one is available (fast path); otherwise falls
6870    /// back to an O(n) brute-force scan.
6871    pub fn find_similar_vector(
6872        &self,
6873        field: &str,
6874        label: Option<&str>,
6875        q: &[f64],
6876        k: usize,
6877        min: f64,
6878    ) -> Vec<(String, f64)> {
6879        // Ensure any HNSW blobs retained from the snapshot are deserialized
6880        // before the first ANN query on a clean-open (no-WAL) path.
6881        self.engine.ensure_hnsw_loaded();
6882        // L2-normalise query for cosine via dot product.
6883        let norm: f64 = q.iter().map(|x| x * x).sum::<f64>().sqrt();
6884        if norm == 0.0 {
6885            return vec![];
6886        }
6887        let q_unit: Vec<f64> = q.iter().map(|x| x / norm).collect();
6888
6889        // Try HNSW fast path.
6890        // `None` label searches across all VectorSimilar rules covering `field`
6891        // (merging their results); `Some(lbl)` restricts to rules whose
6892        // dst_label matches.  Returns `None` when no populated HNSW index
6893        // covers the request — the O(n) brute-force fallback handles that case.
6894        let hnsw_hits = match label {
6895            Some(lbl) => self.engine.hnsw_search_dst(field, lbl, &q_unit, k),
6896            None => self.engine.hnsw_search_any_dst(field, &q_unit, k),
6897        };
6898        if let Some(hits) = hnsw_hits {
6899            let mut out: Vec<(String, f64)> = hits
6900                .into_iter()
6901                .filter(|&(_, sim)| sim >= min)
6902                .filter_map(|(id, sim)| self.ids.key_of(id).map(|key| (key.to_string(), sim)))
6903                .collect();
6904            out.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
6905            out.truncate(k);
6906            return out;
6907        }
6908
6909        // Brute-force fallback: O(n) scan (only reached when no HNSW index
6910        // covers the request).
6911        let view = self.view();
6912        let candidate_ids: Vec<u32> = match label {
6913            Some(lbl) => view.nodes_with_label(lbl),
6914            None => view.nodes_all(),
6915        };
6916        let mut scored: Vec<(String, f64)> = candidate_ids
6917            .into_iter()
6918            .filter_map(|id| {
6919                let v = view.prop(id, field)?;
6920                let v_owned = v.into_value();
6921                let xs = value_as_float_list(&v_owned)?;
6922                let v_norm: f64 = xs.iter().map(|x| x * x).sum::<f64>().sqrt();
6923                if v_norm == 0.0 {
6924                    return None;
6925                }
6926                let dot: f64 = q_unit
6927                    .iter()
6928                    .zip(xs.iter())
6929                    .map(|(a, b)| a * (b / v_norm))
6930                    .sum();
6931                if dot < min {
6932                    return None;
6933                }
6934                let key = self.ids.key_of(id)?.to_string();
6935                Some((key, dot))
6936            })
6937            .collect();
6938        scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
6939        scored.truncate(k);
6940        scored
6941    }
6942
6943    /// Like [`find_similar_vector`] but restricts results to nodes visible in
6944    /// `mask`. Hidden nodes never appear in results; the mask is applied
6945    /// **before** k-truncation so a caller still receives up to `k` visible
6946    /// hits.
6947    ///
6948    /// # HNSW path (over-fetch policy)
6949    ///
6950    /// When an HNSW index covers the request, this function fetches `4 * k`
6951    /// candidates from the index and discards hidden nodes in the post-filter
6952    /// step.  If fewer than `k` visible nodes remain after filtering the caller
6953    /// receives whatever is available — we do not re-query the index.  The 4×
6954    /// multiplier is a heuristic suited for sparsely masked graphs; callers
6955    /// operating under a very selective mask should register a VectorSimilar
6956    /// rule with a non-approximate index, or use the brute-force path (no HNSW
6957    /// rule) which exhaustively filters through the masked [`GraphView`].
6958    ///
6959    /// # Brute-force path
6960    ///
6961    /// When no HNSW index covers the request the function builds a masked
6962    /// [`GraphView`] so that `nodes_all` / `nodes_with_label` return only
6963    /// visible nodes, guaranteeing exact `k` results (or all visible nodes if
6964    /// fewer than `k` exist).
6965    pub fn find_similar_vector_masked(
6966        &self,
6967        field: &str,
6968        label: Option<&str>,
6969        q: &[f64],
6970        k: usize,
6971        min: f64,
6972        mask: &crate::mask::NodeMask,
6973    ) -> Vec<(String, f64)> {
6974        self.engine.ensure_hnsw_loaded();
6975        let norm: f64 = q.iter().map(|x| x * x).sum::<f64>().sqrt();
6976        if norm == 0.0 {
6977            return vec![];
6978        }
6979        let q_unit: Vec<f64> = q.iter().map(|x| x / norm).collect();
6980
6981        // HNSW fast path — over-fetch 4×k so post-masking still yields up to k
6982        // visible hits.  See doc comment above for the policy rationale.
6983        let over_k = k.saturating_mul(4).max(k + 1);
6984        let hnsw_hits = match label {
6985            Some(lbl) => self.engine.hnsw_search_dst(field, lbl, &q_unit, over_k),
6986            None => self.engine.hnsw_search_any_dst(field, &q_unit, over_k),
6987        };
6988        if let Some(hits) = hnsw_hits {
6989            let mut out: Vec<(String, f64)> = hits
6990                .into_iter()
6991                .filter(|&(id, sim)| sim >= min && mask.visible.contains(&id))
6992                .filter_map(|(id, sim)| self.ids.key_of(id).map(|key| (key.to_string(), sim)))
6993                .collect();
6994            out.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
6995            out.truncate(k);
6996            return out;
6997        }
6998
6999        // Brute-force fallback — masked view ensures only visible nodes are
7000        // enumerated by nodes_all(); nodes_with_label() does not filter by
7001        // mask so we apply view.visible() explicitly for the labeled case.
7002        let view = self.view_masked(mask);
7003        let candidate_ids: Vec<u32> = match label {
7004            Some(lbl) => view
7005                .nodes_with_label(lbl)
7006                .into_iter()
7007                .filter(|&id| view.visible(id))
7008                .collect(),
7009            None => view.nodes_all(),
7010        };
7011        let mut scored: Vec<(String, f64)> = candidate_ids
7012            .into_iter()
7013            .filter_map(|id| {
7014                let v = view.prop(id, field)?;
7015                let v_owned = v.into_value();
7016                let xs = value_as_float_list(&v_owned)?;
7017                let v_norm: f64 = xs.iter().map(|x| x * x).sum::<f64>().sqrt();
7018                if v_norm == 0.0 {
7019                    return None;
7020                }
7021                let dot: f64 = q_unit
7022                    .iter()
7023                    .zip(xs.iter())
7024                    .map(|(a, b)| a * (b / v_norm))
7025                    .sum();
7026                if dot < min {
7027                    return None;
7028                }
7029                let key = self.ids.key_of(id)?.to_string();
7030                Some((key, dot))
7031            })
7032            .collect();
7033        scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
7034        scored.truncate(k);
7035        scored
7036    }
7037
7038    /// Read a single property from an edge.
7039    ///
7040    /// Returns `None` when the edge does not exist, the field is absent, or any
7041    /// of the string keys cannot be resolved to interned ids.  Only edge props
7042    /// written by rules (weight fields) are accessible without a `set_edge_prop`
7043    /// binding; topology-only edges (no props set) return `None` for every field.
7044    pub fn get_edge_prop(
7045        &self,
7046        edge_type: &str,
7047        src_key: &str,
7048        dst_key: &str,
7049        field: &str,
7050    ) -> Option<Value> {
7051        let etype = self.syms.get(edge_type)?;
7052        let src = self.ids.get(src_key)?;
7053        let dst = self.ids.get(dst_key)?;
7054        self.edge_props_view().get(etype, src, dst, field)
7055    }
7056
7057    /// Lex → parse → plan → execute `cypher` over a read-only view.
7058    /// Every pipeline `Err(String)` becomes `GraphError::QueryError` with a
7059    /// stage prefix (`lex:` / `parse:` / `plan:` / `execute:`).
7060    pub fn query(&self, cypher: &str, params: &BTreeMap<String, Value>) -> Result<ResultSet> {
7061        let tokens = lex(cypher).map_err(|e| GraphError::QueryError {
7062            detail: format!("lex: {e}"),
7063        })?;
7064        let union = parse_read(&tokens).map_err(|e| GraphError::QueryError {
7065            detail: format!("parse: {e}"),
7066        })?;
7067        let t0 = std::time::Instant::now();
7068        let result = execute_union(&self.view(), &union, &Params(params)).map_err(|e| {
7069            GraphError::QueryError {
7070                detail: format!("execute: {e}"),
7071            }
7072        });
7073        let elapsed_ms = t0.elapsed().as_millis() as u64;
7074        let threshold = self.slow_query_threshold_ms;
7075        if threshold > 0 && elapsed_ms >= threshold {
7076            eprintln!("[mushroomdb] slow query ({elapsed_ms}ms): {cypher}");
7077            let entry = SlowQueryEntry {
7078                ms: elapsed_ms,
7079                query: cypher.to_string(),
7080                at_commit: self.commit_seq,
7081            };
7082            if let Ok(mut log) = self.slow_queries.lock() {
7083                if log.entries.len() == SLOW_QUERY_RING_CAP {
7084                    log.entries.pop_front();
7085                }
7086                log.entries.push_back(entry);
7087                log.total += 1;
7088            }
7089        }
7090        result
7091    }
7092
7093    /// Convenience entry-point that accepts a slice of `(name, value)` pairs
7094    /// instead of a pre-built `BTreeMap`.  Equivalent to building the map and
7095    /// calling [`GraphDb::query`].
7096    pub fn query_with_params(&self, cypher: &str, params: &[(&str, Value)]) -> Result<ResultSet> {
7097        let map: BTreeMap<String, Value> = params
7098            .iter()
7099            .map(|(k, v)| (k.to_string(), v.clone()))
7100            .collect();
7101        self.query(cypher, &map)
7102    }
7103
7104    /// Execute a Cypher write statement (CREATE / MATCH…SET / MATCH…DELETE / MERGE).
7105    ///
7106    /// All mutations flow through the same `insert_node` / `set_prop` /
7107    /// `delete_edge` / `insert_edge` path as the Rust API so the rule engine
7108    /// fires and the WAL captures everything with one fsync per statement.
7109    ///
7110    /// Returns a one-row [`ResultSet`] with columns `created`, `properties_set`,
7111    /// and `deleted` matching the write-result contract.
7112    ///
7113    /// **Mutation routing**: mutations are collected into a single
7114    /// [`BatchBuilder`] and committed atomically (one WAL `Batch` frame, one
7115    /// fsync). The MATCH phase for SET/DELETE uses a read-only `execute` call
7116    /// over `self.view()` — the borrow is dropped before the batch is opened.
7117    ///
7118    /// **Limitations (v1)**:
7119    /// - SET RHS must be a literal, `$param`, or arithmetic; bare property copy → named error.
7120    /// - `DETACH DELETE n` → calls `delete_node` for each matched node (removes all edges).
7121    /// - Bare `DELETE n` → error if n has any incident edges; succeeds for isolated nodes.
7122    /// - MERGE supports `ON CREATE SET` / `ON MATCH SET` in the same write batch.
7123    /// - Deleting a derived edge → named error "cannot delete derived edge".
7124    pub fn query_write(
7125        &mut self,
7126        cypher: &str,
7127        params: &BTreeMap<String, Value>,
7128    ) -> Result<ResultSet> {
7129        let tokens = lex(cypher).map_err(|e| GraphError::QueryError {
7130            detail: format!("lex: {e}"),
7131        })?;
7132        let stmt = parse_write(&tokens).map_err(|e| GraphError::QueryError {
7133            detail: format!("parse: {e}"),
7134        })?;
7135        self.exec_write_stmt(stmt, params)
7136    }
7137
7138    fn exec_write_stmt(
7139        &mut self,
7140        stmt: WriteStatement,
7141        params: &BTreeMap<String, Value>,
7142    ) -> Result<ResultSet> {
7143        match stmt {
7144            WriteStatement::Create(s) => self.exec_create(s, params),
7145            WriteStatement::MatchSet(s) => self.exec_match_set(s, params),
7146            WriteStatement::MatchDelete(s) => self.exec_match_delete(s, params),
7147            WriteStatement::MatchDeleteNode(s) => self.exec_match_delete_node(s, params),
7148            WriteStatement::Merge(s) => self.exec_merge(s, params),
7149        }
7150    }
7151
7152    fn exec_create(
7153        &mut self,
7154        stmt: core_query::cypher::CreateStmt,
7155        params: &BTreeMap<String, Value>,
7156    ) -> Result<ResultSet> {
7157        // Extract the node key from props: require a string-valued `id` field.
7158        let mut var_to_key: BTreeMap<String, String> = BTreeMap::new();
7159        for node in &stmt.nodes {
7160            let var = node.var.as_deref().unwrap_or("_cn0");
7161            let key = node
7162                .props
7163                .iter()
7164                .find(|(f, _)| f == "id")
7165                .and_then(|(_, v)| {
7166                    if let Value::Str(s) = v {
7167                        Some(s.clone())
7168                    } else {
7169                        None
7170                    }
7171                })
7172                .ok_or_else(|| GraphError::QueryError {
7173                    detail: format!(
7174                        "CREATE node ({}:{}) requires a string 'id' property",
7175                        var, node.label
7176                    ),
7177                })?;
7178            var_to_key.insert(var.to_string(), key);
7179        }
7180
7181        let mut batch = self.batch();
7182        let mut created: usize = 0;
7183        for node in &stmt.nodes {
7184            let var = node.var.as_deref().unwrap_or("_cn0");
7185            let key = &var_to_key[var];
7186            batch.insert_node(&node.label, key, node.props.clone());
7187            created += 1;
7188        }
7189        for edge in &stmt.edges {
7190            let src_key = var_to_key
7191                .get(&edge.src_var)
7192                .ok_or_else(|| GraphError::QueryError {
7193                    detail: format!("CREATE edge src variable '{}' is not bound", edge.src_var),
7194                })?;
7195            let dst_key = var_to_key
7196                .get(&edge.dst_var)
7197                .ok_or_else(|| GraphError::QueryError {
7198                    detail: format!("CREATE edge dst variable '{}' is not bound", edge.dst_var),
7199                })?;
7200            batch.insert_edge(&edge.etype, src_key, dst_key);
7201        }
7202        batch.commit()?;
7203
7204        // Optional RETURN clause: project created bindings as a read result.
7205        if let Some(returns) = stmt.returns {
7206            // Each created node is looked up by its key via a separate MATCH pattern.
7207            // Multiple single-node patterns cross-join to produce 1 output row with
7208            // all variables bound (each pattern returns exactly 1 row).
7209            let patterns: Vec<Pattern> = stmt
7210                .nodes
7211                .iter()
7212                .map(|node| {
7213                    let var = node.var.as_deref().unwrap_or("_cn0");
7214                    let key = var_to_key[var].clone();
7215                    Pattern {
7216                        start: NodePat {
7217                            var: Some(var.to_string()),
7218                            label: Some(node.label.clone()),
7219                            props: vec![("id".to_string(), Operand::Lit(Value::Str(key)))],
7220                        },
7221                        chain: vec![],
7222                        shortest: false,
7223                    }
7224                })
7225                .collect();
7226            let q = Query {
7227                matches: patterns,
7228                optional_clauses: vec![],
7229                where_expr: None,
7230                unwinds: vec![],
7231                post_unwind_where: None,
7232                stages: vec![],
7233                returns,
7234                distinct: false,
7235                order_by: vec![],
7236                skip: None,
7237                limit: None,
7238            };
7239            let ops = plan(&q).map_err(|e| GraphError::QueryError {
7240                detail: format!("plan: {e}"),
7241            })?;
7242            return execute(&self.view(), &ops, &Params(params)).map_err(|e| {
7243                GraphError::QueryError {
7244                    detail: format!("execute: {e}"),
7245                }
7246            });
7247        }
7248
7249        let mut rs = write_result_set();
7250        rs.push_row(vec![
7251            Some(Value::Int(created as i64)),
7252            Some(Value::Int(0)),
7253            Some(Value::Int(0)),
7254        ]);
7255        Ok(rs)
7256    }
7257
7258    fn exec_match_set(
7259        &mut self,
7260        stmt: core_query::cypher::MatchSetStmt,
7261        params: &BTreeMap<String, Value>,
7262    ) -> Result<ResultSet> {
7263        let project_returns = stmt.returns.clone();
7264        // Collect unique node vars targeted by SET clauses, plus RETURN bindings
7265        // so the post-write projection can look them up by key.
7266        let mut set_vars: Vec<String> = Vec::new();
7267        for s in &stmt.sets {
7268            if !set_vars.contains(&s.var) {
7269                set_vars.push(s.var.clone());
7270            }
7271        }
7272        let rel_vars = pattern_rel_vars(&stmt.matches);
7273        let mut lookup_vars = set_vars.clone();
7274        for v in pattern_node_vars(&stmt.matches) {
7275            add_var(&mut lookup_vars, &v);
7276        }
7277        if let Some(ref returns) = project_returns {
7278            for v in ret_node_vars(returns) {
7279                if !rel_vars.iter().any(|r| r == &v) {
7280                    add_var(&mut lookup_vars, &v);
7281                }
7282            }
7283        }
7284
7285        // Synthesize a read query: MATCH … WHERE … RETURN <lookup_vars>, <set_values…>
7286        // SET values are projected as ScalarExpr items so that arithmetic expressions
7287        // (e.g. `SET n.score = n.score * 1.5`) are evaluated in the matched-row context.
7288        let mut set_returns: Vec<RetItem> = lookup_vars
7289            .iter()
7290            .map(|v| RetItem {
7291                value: RetVal::Var(v.clone()),
7292                alias: None,
7293            })
7294            .collect();
7295        // One computed column per SET clause; alias is `__sv_<i>`.
7296        let set_val_cols: Vec<String> = stmt
7297            .sets
7298            .iter()
7299            .enumerate()
7300            .map(|(i, _)| format!("__sv_{i}"))
7301            .collect();
7302        for (sc, col) in stmt.sets.iter().zip(&set_val_cols) {
7303            set_returns.push(RetItem {
7304                value: RetVal::ScalarExpr(sc.value.clone()),
7305                alias: Some(col.clone()),
7306            });
7307        }
7308        // Capture relationship types while r is bound; SET does not change them.
7309        for r in &rel_vars {
7310            set_returns.push(RetItem {
7311                value: RetVal::FuncCall {
7312                    name: "type".into(),
7313                    args: vec![Operand::Var(r.clone())],
7314                },
7315                alias: Some(rel_type_alias(r)),
7316            });
7317        }
7318
7319        let read_q = Query {
7320            matches: stmt.matches.clone(),
7321            optional_clauses: vec![],
7322            where_expr: stmt.where_expr.clone(),
7323            unwinds: vec![],
7324            post_unwind_where: None,
7325            stages: vec![],
7326            returns: set_returns,
7327            distinct: false,
7328            order_by: vec![],
7329            skip: None,
7330            limit: None,
7331        };
7332        let ops = plan(&read_q).map_err(|e| GraphError::QueryError {
7333            detail: format!("plan: {e}"),
7334        })?;
7335        // MATCH phase is read-only; borrow ends before batch opens.
7336        //
7337        // When a role-scoped write is in flight, run the MATCH read through
7338        // view_masked so hidden nodes are invisible → hidden ≡ absent ≡
7339        // zero-rows (no SetProp ops generated, no existence-oracle 403).
7340        // Full-authority writes (pending_write_authz=None) keep view().
7341        let match_rs = {
7342            let mask_opt = self.pending_write_authz.as_ref().map(|a| a.mask.clone());
7343            if let Some(ref mask) = mask_opt {
7344                execute(&self.view_masked(mask), &ops, &Params(params))
7345            } else {
7346                execute(&self.view(), &ops, &Params(params))
7347            }
7348        }
7349        .map_err(|e| GraphError::QueryError {
7350            detail: format!("execute: {e}"),
7351        })?;
7352
7353        // Collect (key, field, value) for each matched row × each SET clause.
7354        let mut set_ops: Vec<(String, String, Value)> = Vec::new();
7355        for row_i in 0..match_rs.len() {
7356            for (sc, col) in stmt.sets.iter().zip(&set_val_cols) {
7357                let key = match match_rs.get(row_i, &sc.var) {
7358                    Some(Value::Str(k)) => k.clone(),
7359                    _ => {
7360                        return Err(GraphError::QueryError {
7361                            detail: format!(
7362                                "SET variable '{}' did not resolve to a node key",
7363                                sc.var
7364                            ),
7365                        })
7366                    }
7367                };
7368                // The SET value was already evaluated by the executor.
7369                let value = match match_rs.get(row_i, col) {
7370                    Some(v) => v.clone(),
7371                    None => {
7372                        return Err(GraphError::QueryError {
7373                            detail: format!(
7374                                "SET value for {}.{} evaluated to null",
7375                                sc.var, sc.field
7376                            ),
7377                        })
7378                    }
7379                };
7380                set_ops.push((key, sc.field.clone(), value));
7381            }
7382        }
7383
7384        // Apply as one atomic batch.
7385        let props_set = set_ops.len();
7386        let mut batch = self.batch();
7387        for (key, field, value) in set_ops {
7388            batch.set_prop(&key, &field, value);
7389        }
7390        batch.commit()?;
7391
7392        if let Some(returns) = project_returns {
7393            return project_set_return_rows(self, &rel_vars, &match_rs, &returns, params);
7394        }
7395
7396        let mut rs = write_result_set();
7397        rs.push_row(vec![
7398            Some(Value::Int(0)),
7399            Some(Value::Int(props_set as i64)),
7400            Some(Value::Int(0)),
7401        ]);
7402        Ok(rs)
7403    }
7404
7405    fn exec_match_delete(
7406        &mut self,
7407        stmt: core_query::cypher::MatchDeleteStmt,
7408        params: &BTreeMap<String, Value>,
7409    ) -> Result<ResultSet> {
7410        // Collect unique node vars needed to identify edge endpoints.
7411        let mut node_vars: Vec<String> = Vec::new();
7412        for ed in &stmt.deletes {
7413            if !node_vars.contains(&ed.src_var) {
7414                node_vars.push(ed.src_var.clone());
7415            }
7416            if !node_vars.contains(&ed.dst_var) {
7417                node_vars.push(ed.dst_var.clone());
7418            }
7419        }
7420
7421        // Synthesize read query.
7422        let returns: Vec<RetItem> = node_vars
7423            .iter()
7424            .map(|v| RetItem {
7425                value: RetVal::Var(v.clone()),
7426                alias: None,
7427            })
7428            .collect();
7429        let read_q = Query {
7430            matches: stmt.matches,
7431            optional_clauses: vec![],
7432            where_expr: stmt.where_expr,
7433            unwinds: vec![],
7434            post_unwind_where: None,
7435            stages: vec![],
7436            returns,
7437            distinct: false,
7438            order_by: vec![],
7439            skip: None,
7440            limit: None,
7441        };
7442        let ops = plan(&read_q).map_err(|e| GraphError::QueryError {
7443            detail: format!("plan: {e}"),
7444        })?;
7445        // Role-scoped writes: mask the MATCH read phase so hidden nodes are
7446        // invisible → hidden ≡ absent ≡ zero-rows (spec §3.1, hidden ≡ absent).
7447        let match_rs = {
7448            let mask_opt = self.pending_write_authz.as_ref().map(|a| a.mask.clone());
7449            if let Some(ref mask) = mask_opt {
7450                execute(&self.view_masked(mask), &ops, &Params(params))
7451            } else {
7452                execute(&self.view(), &ops, &Params(params))
7453            }
7454        }
7455        .map_err(|e| GraphError::QueryError {
7456            detail: format!("execute: {e}"),
7457        })?;
7458
7459        // Collect (etype, src_key, dst_key) for each row × each delete target.
7460        let mut del_ops: Vec<(String, String, String)> = Vec::new();
7461        for row_i in 0..match_rs.len() {
7462            for ed in &stmt.deletes {
7463                let src_key = match match_rs.get(row_i, &ed.src_var) {
7464                    Some(Value::Str(k)) => k.clone(),
7465                    _ => {
7466                        return Err(GraphError::QueryError {
7467                            detail: format!(
7468                                "DELETE src variable '{}' did not resolve to a node key",
7469                                ed.src_var
7470                            ),
7471                        })
7472                    }
7473                };
7474                let dst_key = match match_rs.get(row_i, &ed.dst_var) {
7475                    Some(Value::Str(k)) => k.clone(),
7476                    _ => {
7477                        return Err(GraphError::QueryError {
7478                            detail: format!(
7479                                "DELETE dst variable '{}' did not resolve to a node key",
7480                                ed.dst_var
7481                            ),
7482                        })
7483                    }
7484                };
7485                del_ops.push((ed.etype.clone(), src_key, dst_key));
7486            }
7487        }
7488
7489        // Apply as one atomic batch.
7490        let deleted = del_ops.len();
7491        let mut batch = self.batch();
7492        for (etype, src_key, dst_key) in del_ops {
7493            batch.delete_edge(&etype, &src_key, &dst_key);
7494        }
7495        batch.commit().map_err(|e| match e {
7496            GraphError::RuleOwned { .. } => GraphError::QueryError {
7497                detail: "cannot delete derived edge; retract via the rule or change the property"
7498                    .to_string(),
7499            },
7500            other => other,
7501        })?;
7502
7503        let mut rs = write_result_set();
7504        rs.push_row(vec![
7505            Some(Value::Int(0)),
7506            Some(Value::Int(0)),
7507            Some(Value::Int(deleted as i64)),
7508        ]);
7509        Ok(rs)
7510    }
7511
7512    /// Execute `MATCH … [DETACH] DELETE <node_var> [, …]`.
7513    ///
7514    /// Collects the matching node keys via an ephemeral read query, then calls
7515    /// `delete_node` on each one.  When `stmt.detach` is `false` (bare DELETE)
7516    /// the executor first checks that the node has no incident edges; if any
7517    /// remain it returns a named error matching openCypher semantics.
7518    fn exec_match_delete_node(
7519        &mut self,
7520        stmt: MatchDeleteNodeStmt,
7521        params: &BTreeMap<String, Value>,
7522    ) -> Result<ResultSet> {
7523        // Build a read query returning only the node keys we need.
7524        let returns: Vec<RetItem> = stmt
7525            .node_vars
7526            .iter()
7527            .map(|v| RetItem {
7528                value: RetVal::Var(v.clone()),
7529                alias: None,
7530            })
7531            .collect();
7532        let read_q = Query {
7533            matches: stmt.matches,
7534            optional_clauses: vec![],
7535            where_expr: stmt.where_expr,
7536            unwinds: vec![],
7537            post_unwind_where: None,
7538            stages: vec![],
7539            returns,
7540            distinct: false,
7541            order_by: vec![],
7542            skip: None,
7543            limit: None,
7544        };
7545        let ops = plan(&read_q).map_err(|e| GraphError::QueryError {
7546            detail: format!("plan: {e}"),
7547        })?;
7548        // Role-scoped writes: mask the MATCH read phase so hidden nodes are
7549        // invisible → hidden ≡ absent ≡ zero-rows (spec §3.1, hidden ≡ absent).
7550        let match_rs = {
7551            let mask_opt = self.pending_write_authz.as_ref().map(|a| a.mask.clone());
7552            if let Some(ref mask) = mask_opt {
7553                execute(&self.view_masked(mask), &ops, &Params(params))
7554            } else {
7555                execute(&self.view(), &ops, &Params(params))
7556            }
7557        }
7558        .map_err(|e| GraphError::QueryError {
7559            detail: format!("execute: {e}"),
7560        })?;
7561
7562        // Collect unique node keys to delete (deduplicate across rows × vars).
7563        let mut keys: Vec<String> = Vec::new();
7564        for row_i in 0..match_rs.len() {
7565            for var in &stmt.node_vars {
7566                if let Some(Value::Str(k)) = match_rs.get(row_i, var) {
7567                    if !keys.contains(k) {
7568                        keys.push(k.clone());
7569                    }
7570                }
7571            }
7572        }
7573
7574        if !stmt.detach {
7575            // openCypher bare DELETE: error if any matched node has incident edges.
7576            for key in &keys {
7577                if let Some(id) = self.ids.get(key) {
7578                    let tv = self.topo_view();
7579                    let has_edges = tv.etypes().any(|et| {
7580                        !tv.neighbors(et, Direction::Out, id).is_empty()
7581                            || !tv.neighbors(et, Direction::In, id).is_empty()
7582                    });
7583                    if has_edges {
7584                        return Err(GraphError::QueryError {
7585                            detail: format!(
7586                                "Cannot delete node `{key}` because it still has incident edges. \
7587                                 Use DETACH DELETE to remove the node and all its edges."
7588                            ),
7589                        });
7590                    }
7591                }
7592            }
7593        }
7594
7595        let mut nodes_deleted = 0i64;
7596        let mut edges_deleted = 0i64;
7597        for key in keys {
7598            match self.delete_node(&key) {
7599                Ok(report) => {
7600                    nodes_deleted += 1;
7601                    edges_deleted += (report.manual_edges + report.derived_edges) as i64;
7602                }
7603                Err(GraphError::KeyNotFound { .. }) => {
7604                    // Node may have been deleted by an earlier iteration (e.g., via
7605                    // multiple MATCH rows for the same node).  Safe to skip.
7606                }
7607                Err(e) => return Err(e),
7608            }
7609        }
7610
7611        let mut rs = write_result_set();
7612        rs.push_row(vec![
7613            Some(Value::Int(0)),
7614            Some(Value::Int(0)),
7615            Some(Value::Int(nodes_deleted + edges_deleted)),
7616        ]);
7617        Ok(rs)
7618    }
7619
7620    fn exec_merge(
7621        &mut self,
7622        stmt: core_query::cypher::MergeStmt,
7623        params: &BTreeMap<String, Value>,
7624    ) -> Result<ResultSet> {
7625        // MERGE: check if a node with the given key already exists.
7626        let key = match &stmt.key_value {
7627            Value::Str(s) => s.clone(),
7628            _ => {
7629                return Err(GraphError::QueryError {
7630                    detail: format!(
7631                        "MERGE key value must be a string (got {:?})",
7632                        stmt.key_value
7633                    ),
7634                })
7635            }
7636        };
7637
7638        if let Some(var) = stmt.var.as_deref() {
7639            for sc in stmt.on_create.iter().chain(&stmt.on_match) {
7640                if sc.var != var {
7641                    return Err(GraphError::QueryError {
7642                        detail: format!(
7643                            "SET variable '{}' does not match MERGE variable '{var}'",
7644                            sc.var
7645                        ),
7646                    });
7647                }
7648            }
7649        }
7650
7651        // ── MERGE authz pre-check (when role-scoped) ─────────────────────────
7652        //
7653        // MERGE scope precondition: check create OR update scope for the
7654        // declared label BEFORE calling `has_node` (timing-oracle closure,
7655        // spec §6.2 "MERGE visibility oracle" item: hidden ≡ absent for
7656        // unscoped roles — the scope denial fires without touching the key store).
7657        //
7658        // Clone to avoid holding a borrow on `self.pending_write_authz` while
7659        // also calling `self.ids.get(key)`.
7660        let merge_existed: bool = if let Some(authz) = self.pending_write_authz.clone() {
7661            let has_create = authz.scope.create_labels.contains(&stmt.label);
7662            let has_update = authz.scope.update_labels.contains(&stmt.label);
7663            if !has_create && !has_update {
7664                // Scope-before-lookup: 403 without has_node call (timing oracle
7665                // closure — see test_merge_unscoped_no_key_lookup).
7666                return Err(GraphError::RoleWriteDenied {
7667                    reason: format!(
7668                        "role-bound token: label '{}' not in write scope (create_labels)",
7669                        stmt.label
7670                    ),
7671                });
7672            }
7673            // Key lookup under mask.
7674            match self.ids.get(key.as_str()) {
7675                Some(id) if authz.mask.contains_id(id) => {
7676                    // Visible: must have update scope to proceed to match arm.
7677                    if !has_update {
7678                        return Err(GraphError::RoleWriteDenied {
7679                            reason: format!(
7680                                "role-bound token: label '{}' not in write scope (update_labels)",
7681                                stmt.label
7682                            ),
7683                        });
7684                    }
7685                    true // existed = true → match arm
7686                }
7687                Some(_) => {
7688                    // Hidden: same error as absent to the role (spec §3.1/§3.3).
7689                    return Err(GraphError::RoleWriteDenied {
7690                        reason: "role-bound token: target node not visible".into(),
7691                    });
7692                }
7693                None => {
7694                    // Absent: must have create scope to proceed to the create arm.
7695                    //
7696                    // Update-only roles (create_labels empty, update_labels set):
7697                    // return the SAME "not visible" error as the hidden-key branch
7698                    // so hidden ≡ absent — no distinguishing oracle (spec §6.1
7699                    // "confirm existence of hidden nodes: No").
7700                    //
7701                    // Create-scoped roles (has_create=true): absent → create arm
7702                    // as before.  The accepted structural key-existence disclosure
7703                    // (§THREAT-MODEL) applies only when the role holds create scope.
7704                    if !has_create {
7705                        return Err(GraphError::RoleWriteDenied {
7706                            reason: "role-bound token: target node not visible".into(),
7707                        });
7708                    }
7709                    false // existed = false → create arm
7710                }
7711            }
7712        } else {
7713            // Full authority: use the existing non-masked has_node check.
7714            self.has_node(&key)
7715        };
7716
7717        let existed = merge_existed;
7718        let mut created = 0i64;
7719        if !existed || !stmt.on_match.is_empty() {
7720            let mut batch = self.batch();
7721            if !existed {
7722                let props = vec![(stmt.key_field.clone(), stmt.key_value.clone())];
7723                batch.insert_node(&stmt.label, &key, props);
7724                for sc in &stmt.on_create {
7725                    let value = resolve_merge_set_value(&sc.value, params)?;
7726                    batch.set_prop(&key, &sc.field, value);
7727                }
7728                created = 1;
7729            } else {
7730                for sc in &stmt.on_match {
7731                    let value = resolve_merge_set_value(&sc.value, params)?;
7732                    batch.set_prop(&key, &sc.field, value);
7733                }
7734            }
7735            batch.commit()?;
7736        }
7737
7738        // Refresh the role mask so the just-created node is visible to this
7739        // statement's RETURN (read-after-write). Safe: create_labels ⊆ read labels
7740        // (apply_schema subset rule), so the new node's label is already in the
7741        // role's read scope — this never widens beyond the role's declared labels.
7742        if !existed {
7743            if let Some(role) = self.pending_write_authz.as_ref().map(|a| a.role.clone()) {
7744                let new_mask = self.mask_for_role(&role)?;
7745                if let Some(a) = self.pending_write_authz.as_mut() {
7746                    a.mask = new_mask;
7747                }
7748            }
7749        }
7750
7751        // Optional RETURN clause: project the node (created or matched) as a read result.
7752        if let Some(returns) = stmt.returns {
7753            let var = stmt.var.as_deref().unwrap_or("_mn0");
7754            let q = Query {
7755                matches: vec![Pattern {
7756                    start: NodePat {
7757                        var: Some(var.to_string()),
7758                        label: Some(stmt.label.clone()),
7759                        props: vec![("id".to_string(), Operand::Lit(stmt.key_value.clone()))],
7760                    },
7761                    chain: vec![],
7762                    shortest: false,
7763                }],
7764                optional_clauses: vec![],
7765                where_expr: None,
7766                unwinds: vec![],
7767                post_unwind_where: None,
7768                stages: vec![],
7769                returns,
7770                distinct: false,
7771                order_by: vec![],
7772                skip: None,
7773                limit: None,
7774            };
7775            let ops = plan(&q).map_err(|e| GraphError::QueryError {
7776                detail: format!("plan: {e}"),
7777            })?;
7778            // Use view_masked when a role-scoped write is in flight so the
7779            // post-merge projection is consistent with the masked read phase.
7780            let mask_opt = self.pending_write_authz.as_ref().map(|a| a.mask.clone());
7781            return (if let Some(ref mask) = mask_opt {
7782                execute(&self.view_masked(mask), &ops, &Params(params))
7783            } else {
7784                execute(&self.view(), &ops, &Params(params))
7785            })
7786            .map_err(|e| GraphError::QueryError {
7787                detail: format!("execute: {e}"),
7788            });
7789        }
7790
7791        let mut rs = write_result_set();
7792        rs.push_row(vec![
7793            Some(Value::Int(created)),
7794            Some(Value::Int(0)),
7795            Some(Value::Int(0)),
7796        ]);
7797        Ok(rs)
7798    }
7799
7800    /// Return all rule-owned edges between `key_a` and `key_b` (either direction),
7801    /// annotated with rule name, edge type, direction, and weight.
7802    /// Results are sorted by (rule, edge_type).
7803    /// Returns `Err(KeyNotFound)` if either key is unknown.
7804    pub fn explain(&self, key_a: &str, key_b: &str) -> Result<Vec<Explanation>> {
7805        self.ensure_v8_base_sections_loaded();
7806        let id_a = self
7807            .ids
7808            .get(key_a)
7809            .ok_or_else(|| GraphError::KeyNotFound { key: key_a.into() })?;
7810        let id_b = self
7811            .ids
7812            .get(key_b)
7813            .ok_or_else(|| GraphError::KeyNotFound { key: key_b.into() })?;
7814
7815        let mut results = Vec::new();
7816
7817        // Walk the smaller incident set so explain is O(min(deg(a), deg(b)))
7818        // rather than O(total provenance).
7819        let scan = if self.engine.provenance_touching_len(id_a)
7820            <= self.engine.provenance_touching_len(id_b)
7821        {
7822            id_a
7823        } else {
7824            id_b
7825        };
7826        for (rule_name, etype, src, dst) in self.engine.provenance_touching(scan) {
7827            if !((src == id_a && dst == id_b) || (src == id_b && dst == id_a)) {
7828                continue;
7829            }
7830            let Some(rule_def) = self.engine.rules().find(|r| r.name == rule_name) else {
7831                continue;
7832            };
7833            let edge_type = match self.syms.resolve(etype) {
7834                Some(s) => s.to_string(),
7835                None => continue,
7836            };
7837            // Provenance (src, dst) ids come from the archived PROVENANCE section
7838            // (large, no eager CRC).  A corrupt section can produce ids that are
7839            // out of range; return Corrupt rather than panic.
7840            let src_key = self
7841                .ids
7842                .key_of(src)
7843                .ok_or_else(|| GraphError::Corrupt {
7844                    detail: format!("v8: provenance src id {src} not in id table"),
7845                })?
7846                .to_string();
7847            let dst_key = self
7848                .ids
7849                .key_of(dst)
7850                .ok_or_else(|| GraphError::Corrupt {
7851                    detail: format!("v8: provenance dst id {dst} not in id table"),
7852                })?
7853                .to_string();
7854            let stored = rule_def.weight_prop.as_deref().and_then(|prop| {
7855                self.edge_props_view()
7856                    .get(etype, src, dst, prop)
7857                    .and_then(|v| {
7858                        if let Value::Float(f) = v {
7859                            Some(f)
7860                        } else {
7861                            None
7862                        }
7863                    })
7864            });
7865            // Rules that store no weight (KeyMatch/FieldEqual defaults, auto-FK)
7866            // still have a score: recompute it from the predicate so explain
7867            // never reports "no score" for an edge the engine scored.  Via-hop
7868            // rules score over their via set, not over (src, dst), so leave
7869            // those None rather than report a number the rule did not produce.
7870            let weight = stored.or_else(|| {
7871                if rule_def.via_edge.is_some() {
7872                    return None;
7873                }
7874                let props_view = build_props_view(&self.props, &self.base);
7875                let src_get = |field: &str| props_view.get(src, field).map(|vr| vr.into_value());
7876                let dst_get = |field: &str| props_view.get(dst, field).map(|vr| vr.into_value());
7877                let src_view = NodeView {
7878                    key: &src_key,
7879                    props: &src_get,
7880                };
7881                let dst_view = NodeView {
7882                    key: &dst_key,
7883                    props: &dst_get,
7884                };
7885                evaluate(&rule_def.predicate, &src_view, &dst_view)
7886            });
7887            results.push(Explanation {
7888                rule: rule_name.to_string(),
7889                edge_type,
7890                src_key,
7891                dst_key,
7892                weight,
7893                predicate: PredicateSummary {
7894                    approximate: rule_def.approximate,
7895                    ..PredicateSummary::from(&rule_def.predicate)
7896                },
7897                via_edge: rule_def.via_edge.clone(),
7898            });
7899        }
7900
7901        results.sort_by(|a, b| a.rule.cmp(&b.rule).then(a.edge_type.cmp(&b.edge_type)));
7902        Ok(results)
7903    }
7904
7905    pub fn neighbors(&self, key: &str, edge_type: &str, dir: Direction) -> Result<Vec<String>> {
7906        let id = self
7907            .ids
7908            .get(key)
7909            .ok_or_else(|| GraphError::KeyNotFound { key: key.into() })?;
7910        let Some(sym) = self.syms.get(edge_type) else {
7911            return Ok(Vec::new());
7912        };
7913        self.topo_view()
7914            .neighbors(sym, dir, id)
7915            .iter()
7916            .map(|&n| {
7917                self.ids
7918                    .key_of(n)
7919                    .map(|k| k.to_string())
7920                    .ok_or_else(|| GraphError::Corrupt {
7921                        detail: format!("topology id {n} has no key"),
7922                    })
7923            })
7924            .collect::<Result<Vec<_>>>()
7925    }
7926
7927    /// Return the last-change commit sequence for `key`, or `None` if the node
7928    /// does not exist or has never been mutated since the last V5-V7 snapshot
7929    /// (horizon-bounded for legacy stores).
7930    ///
7931    /// The returned sequence is a monotonically increasing counter that starts
7932    /// at 1 for the first commit after `open` and increments with every
7933    /// successful write.  WAL replay at open also assigns sequences (1..N for N
7934    /// replayed frames), so sequences are consistent across snapshot+WAL cycles.
7935    ///
7936    /// For V5-V7 stores opened without a V8 snapshot, nodes that were present
7937    /// in the snapshot but not touched by any WAL frame will return `None`
7938    /// (horizon-bounded: CAS against such nodes is only safe after the first
7939    /// V8 snapshot or after the node is next mutated).
7940    pub fn last_changed(&self, key: &str) -> Option<u64> {
7941        let id = self.ids.get(key)?;
7942        self.last_change.get(&id).copied()
7943    }
7944
7945    /// The current commit sequence (number of successful commits since open,
7946    /// including WAL replay frames).  Useful for recording a baseline before
7947    /// a read-modify-write cycle.
7948    pub fn commit_seq(&self) -> u64 {
7949        self.commit_seq
7950    }
7951
7952    /// Check that all `preconds` are satisfied against the current db state.
7953    /// Returns `Err(GraphError::CasConflict)` on the first failing precondition.
7954    pub(crate) fn check_preconditions(&self, preconds: &[Precondition]) -> Result<()> {
7955        for precond in preconds {
7956            match precond {
7957                Precondition::NodeUnchangedSince { key, expected } => {
7958                    // Missing entry means the node predates the WAL window or
7959                    // does not exist; treat as 0 (before any commit).
7960                    let actual = self.last_changed(key).unwrap_or_default();
7961                    if actual != *expected {
7962                        return Err(GraphError::CasConflict {
7963                            key: key.clone(),
7964                            expected: *expected,
7965                            actual,
7966                        });
7967                    }
7968                }
7969                Precondition::NodeAbsent { key } => {
7970                    // Node must not exist (not live).
7971                    if self.ids.get(key).is_some() {
7972                        let actual = self.last_changed(key).unwrap_or(0);
7973                        return Err(GraphError::CasConflict {
7974                            key: key.clone(),
7975                            expected: u64::MAX,
7976                            actual,
7977                        });
7978                    }
7979                }
7980            }
7981        }
7982        Ok(())
7983    }
7984
7985    /// Apply a batch of mutations with compare-and-set preconditions.
7986    ///
7987    /// All preconditions are checked atomically before any operation is applied.
7988    /// If any precondition fails, the entire batch is rejected with
7989    /// [`GraphError::CasConflict`] and no WAL frame is written.
7990    ///
7991    /// # Returns
7992    /// `(nodes_inserted, edges_inserted)` on success, same as [`write_batch`].
7993    ///
7994    /// # Errors
7995    /// - [`GraphError::CasConflict`] if any precondition is not satisfied.
7996    /// - Any error that [`write_batch`] would return for the ops themselves.
7997    pub fn write_batch_cas(
7998        &mut self,
7999        preconds: Vec<Precondition>,
8000        ops: Vec<BatchOp>,
8001    ) -> Result<(usize, usize)> {
8002        self.check_preconditions(&preconds)?;
8003        self.commit_logged_batch(ops, None, None)
8004    }
8005
8006    /// Update the per-node last-change map for a WAL record at commit `seq`.
8007    ///
8008    /// Called after a successful apply to record which nodes were touched.
8009    /// For replay, called with the WAL-frame's replayed seq.
8010    ///
8011    /// Touch definition (see [`Precondition`] doc):
8012    /// - InsertNode / InsertNodeId / SetProp / SetPropId / RemoveProp → the node.
8013    /// - InsertEdge / InsertEdgeId / DeleteEdge → both src and dst.
8014    /// - DeleteNode → node tombstoned; last_changed() returns None so no update needed.
8015    /// - DerivedEdge markers, Intern, rule/view records → no-ops.
8016    /// - Batch → recurse into inner records.
8017    fn update_last_change_from_rec(&mut self, rec: &WalRecord, seq: u64) {
8018        match rec {
8019            WalRecord::InsertNode { key, .. }
8020            | WalRecord::SetProp { key, .. }
8021            | WalRecord::RemoveProp { key, .. } => {
8022                if let Some(id) = self.ids.get(key) {
8023                    self.last_change.insert(id, seq);
8024                }
8025            }
8026            WalRecord::InsertNodeId { key, .. } => {
8027                if let Some(id) = self.ids.get(key) {
8028                    self.last_change.insert(id, seq);
8029                }
8030            }
8031            WalRecord::SetPropId { id, .. } => {
8032                self.last_change.insert(*id, seq);
8033            }
8034            WalRecord::InsertEdge {
8035                src_key, dst_key, ..
8036            }
8037            | WalRecord::DeleteEdge {
8038                src_key, dst_key, ..
8039            } => {
8040                if let Some(src_id) = self.ids.get(src_key) {
8041                    self.last_change.insert(src_id, seq);
8042                }
8043                if let Some(dst_id) = self.ids.get(dst_key) {
8044                    self.last_change.insert(dst_id, seq);
8045                }
8046            }
8047            WalRecord::InsertEdgeId { src, dst, .. } => {
8048                self.last_change.insert(*src, seq);
8049                self.last_change.insert(*dst, seq);
8050            }
8051            // DeleteNode: node is tombstoned; last_changed(key) returns None for
8052            // deleted keys (ids.get() returns None post-tombstone), so no update needed.
8053            // History markers: state no-ops; the underlying mutation already
8054            // touched the relevant nodes' last_change entries.
8055            WalRecord::DeleteNode { .. }
8056            | WalRecord::DerivedEdgeAdded { .. }
8057            | WalRecord::DerivedEdgeRetracted { .. }
8058            | WalRecord::Intern { .. }
8059            | WalRecord::CreateRule { .. }
8060            | WalRecord::DeleteRule { .. }
8061            | WalRecord::RebuildRule { .. }
8062            | WalRecord::CreateView { .. }
8063            | WalRecord::DeleteView { .. }
8064            | WalRecord::EnableFulltext { .. }
8065            | WalRecord::DisableFulltext { .. }
8066            | WalRecord::EnableIndex { .. }
8067            | WalRecord::DisableIndex { .. } => {}
8068            // RenameNode: node id is stable; update last_change via the new key.
8069            // Called after apply(), so ids already reflects new_key.
8070            WalRecord::RenameNode { new_key, .. } => {
8071                if let Some(id) = self.ids.get(new_key) {
8072                    self.last_change.insert(id, seq);
8073                }
8074            }
8075            WalRecord::Batch(inner) => {
8076                for inner_rec in inner {
8077                    self.update_last_change_from_rec(inner_rec, seq);
8078                }
8079            }
8080        }
8081    }
8082
8083    pub fn node_count(&self) -> usize {
8084        self.ids.len()
8085    }
8086
8087    /// Configure archive retention: keep the `N` newest WAL archives at each
8088    /// [`snapshot_with`] call when `archive_wal: true`.
8089    ///
8090    /// `Some(N)` where N > 0 → prune oldest archives keeping the newest N.
8091    /// `Some(0)` or `None` → unlimited (no pruning).
8092    ///
8093    /// Pruning only ever happens inside [`snapshot_with`]; this method only
8094    /// stores the policy.  Archives below the retention limit are deleted
8095    /// oldest-first.  The horizon floor is updated so that
8096    /// [`was_linked`] / history APIs return `CommitOutOfRange` for commits
8097    /// in pruned archives rather than silently returning wrong data.
8098    pub fn set_wal_archive_retention(&mut self, keep: Option<u32>) {
8099        self.wal_archive_retention = keep;
8100    }
8101
8102    /// Delete any WAL archives that are fully below the current horizon floor.
8103    ///
8104    /// Orphaned archives arise when the floor is written first during retention
8105    /// pruning and then a crash interrupts the archive-delete sequence.  The
8106    /// opening cleanup ensures no subsequent read path sees stale data.
8107    ///
8108    /// Under the monotonic naming scheme, the archive name N equals the
8109    /// cumulative end-frame index of the archive in global commit space (i.e.
8110    /// the archive covers global frames `[prev_n, N)`).  An archive is
8111    /// fully orphaned when `N <= wal_horizon_floor`: all of its frames fall
8112    /// below the floor and have already been counted in it.
8113    fn cleanup_orphaned_archives(&mut self) -> Result<()> {
8114        if self.wal_horizon_floor == 0 {
8115            // Floor at 0 means no pruning has ever occurred; nothing to clean.
8116            return Ok(());
8117        }
8118        let archive_ns = self.fs.list_archives()?;
8119        for n in archive_ns {
8120            if n <= self.wal_horizon_floor {
8121                // Archive N ends at global frame N; all its frames are below
8122                // the floor (floor already accounts for them) → orphaned.
8123                self.fs.delete_archive(n).map_err(GraphError::Io)?;
8124            } else {
8125                // Archives are sorted ascending; first one above floor stops scan.
8126                break;
8127            }
8128        }
8129        Ok(())
8130    }
8131
8132    /// Collect all WAL frames from surviving archives (oldest-first) then the
8133    /// live WAL into one flat list, and return the total along with the number
8134    /// of archive frames at the front of the list.
8135    ///
8136    /// Commit indices into the returned list are LOCAL (0 = first frame of
8137    /// oldest surviving archive).  To obtain the GLOBAL index add
8138    /// `self.wal_horizon_floor`.
8139    fn all_frames(&self) -> Result<(Vec<WalRecord>, u64)> {
8140        let archive_ns = self.fs.list_archives()?;
8141        let mut all: Vec<WalRecord> = Vec::new();
8142        for n in archive_ns {
8143            let bytes = self.fs.read_archive(n)?;
8144            let (frames, _) = decode_all(&bytes);
8145            all.extend(frames);
8146        }
8147        let archive_count = all.len() as u64;
8148        let live_bytes = self.fs.read(FileId::Wal)?;
8149        let (live_frames, _) = decode_all(&live_bytes);
8150        all.extend(live_frames);
8151        Ok((all, archive_count))
8152    }
8153
8154    /// Return the total number of committed WAL frames visible in the current
8155    /// horizon window, including frames in surviving WAL archives.
8156    ///
8157    /// This is the exclusive upper bound for valid `at_commit` indices in
8158    /// `was_linked`. Valid indices are `wal_horizon_floor()..wal_total_commits()`.
8159    ///
8160    /// Returns the horizon floor when all surviving history is empty.
8161    pub fn wal_total_commits(&self) -> Result<u64> {
8162        let (frames, _) = self.all_frames()?;
8163        Ok(self.wal_horizon_floor + frames.len() as u64)
8164    }
8165
8166    /// The global frame index of the first commit reachable through surviving
8167    /// archives (0 when no archives have been pruned).
8168    pub fn wal_horizon_floor(&self) -> u64 {
8169        self.wal_horizon_floor
8170    }
8171
8172    /// Return the per-node change history for `key` by scanning the on-disk WAL.
8173    ///
8174    /// ## Horizon
8175    ///
8176    /// History reaches back only to the last WAL-truncating snapshot, exactly like `open_at`.
8177    /// Snapshots written with `keep_wal: true` preserve deeper history. This is the honest,
8178    /// zero-cost contract; a durable history log is out of scope.
8179    ///
8180    /// ## Derived edges
8181    ///
8182    /// Rule-created (derived) edges are **not** in the WAL and therefore do not appear in
8183    /// history. Only edges written directly by the application are recorded.
8184    ///
8185    /// ## Deleted nodes
8186    ///
8187    /// For nodes that have been deleted, dense-id records (SetPropId, InsertEdgeId) that
8188    /// predate the deletion may not resolve (the id is tombstoned in the live map). The
8189    /// string-keyed `DeleteNode` record still matches and produces a `NodeDeleted` entry.
8190    /// Prop/edge history of a deleted node may therefore be partially unresolvable.
8191    ///
8192    /// ## Dense-id edge entries and tombstoned partners
8193    ///
8194    /// Edge entries from dense-id WAL records (`InsertEdgeId`) are omitted when the partner
8195    /// endpoint's dense id is tombstoned. As a result, a live node's history can contain an
8196    /// `EdgeRemoved` (string-keyed, always resolves) without a corresponding `EdgeAdded`.
8197    /// Build commit-bounded alias intervals for `queried_key`.
8198    ///
8199    /// Returns a list of `(key, valid_from_inclusive, valid_until_exclusive)` tuples.
8200    /// A record written under `key` at commit `c` matches the queried identity iff
8201    /// `c >= valid_from && (valid_until.is_none() || c < valid_until)`.
8202    ///
8203    /// Each alias entry carries both a lower and an upper bound so that key-reuse
8204    /// after a rename is handled correctly: if "a" is renamed to "b" at commit 5,
8205    /// then a NEW node is created as "a" at commit 7 and renamed to "c" at commit 10,
8206    /// querying "c" must NOT surface identity-1's events (commits 0–4 under "a");
8207    /// only identity-2's events (commits 7–9 under "a") are in scope.
8208    ///
8209    /// Only **forward aliasing**: querying the *new* key surfaces events written
8210    /// under the *old* key.  The reverse direction is not supported.
8211    fn build_key_alias_intervals(
8212        &self,
8213        frames: &[core_storage::wal::WalRecord],
8214        queried_key: &str,
8215    ) -> Vec<(String, u64, Option<u64>)> {
8216        use core_storage::wal::WalRecord;
8217
8218        // Pre-pass: build reverse_rename and key_starts maps.
8219        let mut reverse_rename: HashMap<String, (String, u64)> = HashMap::new();
8220        let mut key_starts: HashMap<String, Vec<u64>> = HashMap::new();
8221
8222        for (local_i, frame) in frames.iter().enumerate() {
8223            let commit = self.wal_horizon_floor + local_i as u64;
8224            let records: &[WalRecord] = match frame {
8225                WalRecord::Batch(inner) => inner.as_slice(),
8226                single => std::slice::from_ref(single),
8227            };
8228            for rec in records {
8229                match rec {
8230                    WalRecord::InsertNode { key, .. } | WalRecord::InsertNodeId { key, .. } => {
8231                        key_starts.entry(key.clone()).or_default().push(commit);
8232                    }
8233                    WalRecord::RenameNode { old_key, new_key } => {
8234                        // new_key came into existence at this commit.
8235                        key_starts.entry(new_key.clone()).or_default().push(commit);
8236                        // Record the reverse rename: new_key was introduced by renaming old_key.
8237                        reverse_rename.insert(new_key.clone(), (old_key.clone(), commit));
8238                    }
8239                    _ => {}
8240                }
8241            }
8242        }
8243
8244        // Build alias intervals by following the reverse rename chain.
8245        let mut result: Vec<(String, u64, Option<u64>)> = Vec::new();
8246        let mut current_key = queried_key.to_string();
8247        let mut current_valid_until: Option<u64> = None;
8248
8249        loop {
8250            // valid_from: the most recent commit where current_key was assigned to this
8251            // identity.  For aliases (valid_until = Some(vu)), find the last start event
8252            // for the key strictly before vu — this is where the alias's occupancy by
8253            // this identity began, correctly excluding prior identities that reused the key.
8254            let valid_from = if let Some(vu) = current_valid_until {
8255                key_starts
8256                    .get(&current_key)
8257                    .and_then(|starts| starts.iter().rev().find(|&&s| s < vu).copied())
8258                    .unwrap_or(self.wal_horizon_floor)
8259            } else {
8260                // Queried key — no upper bound; may have been introduced at any commit.
8261                self.wal_horizon_floor
8262            };
8263
8264            result.push((current_key.clone(), valid_from, current_valid_until));
8265
8266            match reverse_rename.get(&current_key) {
8267                Some((old_key, rename_commit)) => {
8268                    current_valid_until = Some(*rename_commit);
8269                    current_key = old_key.clone();
8270                }
8271                None => break,
8272            }
8273        }
8274
8275        result
8276    }
8277
8278    /// Returns true if `record_key` matches any alias interval that covers `commit`.
8279    fn aliases_match(
8280        intervals: &[(String, u64, Option<u64>)],
8281        record_key: &str,
8282        commit: u64,
8283    ) -> bool {
8284        intervals
8285            .iter()
8286            .any(|(k, vf, vu)| k == record_key && commit >= *vf && vu.is_none_or(|u| commit < u))
8287    }
8288
8289    pub fn node_history(&self, key: &str) -> Result<Vec<crate::history::HistoryEntry>> {
8290        use crate::history::{HistoryChange, HistoryEntry};
8291        use core_storage::wal::WalRecord;
8292
8293        let (frames, _) = self.all_frames()?;
8294
8295        // Resolve commit-bounded alias intervals for `key` (handles renames in the WAL).
8296        let alias_intervals = self.build_key_alias_intervals(&frames, key);
8297
8298        let mut out: Vec<HistoryEntry> = Vec::new();
8299
8300        for (local_i, frame) in frames.iter().enumerate() {
8301            let commit = self.wal_horizon_floor + local_i as u64;
8302            // Collect the inner records to process — Batch is one commit, single records are one commit.
8303            let records: &[WalRecord] = match frame {
8304                WalRecord::Batch(inner) => inner.as_slice(),
8305                single => std::slice::from_ref(single),
8306            };
8307
8308            for rec in records {
8309                let change = match rec {
8310                    WalRecord::InsertNode { label, key: k, .. }
8311                        if Self::aliases_match(&alias_intervals, k, commit) =>
8312                    {
8313                        Some(HistoryChange::NodeInserted {
8314                            label: label.clone(),
8315                        })
8316                    }
8317                    WalRecord::InsertNodeId { label, key: k, .. }
8318                        if Self::aliases_match(&alias_intervals, k, commit) =>
8319                    {
8320                        let label_str = match self.syms.resolve(*label) {
8321                            Some(s) => s.to_string(),
8322                            None => continue,
8323                        };
8324                        Some(HistoryChange::NodeInserted { label: label_str })
8325                    }
8326                    WalRecord::SetProp {
8327                        key: k,
8328                        field,
8329                        value,
8330                    } if Self::aliases_match(&alias_intervals, k, commit) => {
8331                        Some(HistoryChange::PropSet {
8332                            field: field.clone(),
8333                            value: value.clone(),
8334                        })
8335                    }
8336                    WalRecord::SetPropId { id, field, value } => match self.ids.key_of(*id) {
8337                        // key_of returns the current (post-rename) key; compare to queried key.
8338                        Some(resolved) if resolved == key => {
8339                            let field_str = match self.syms.resolve(*field) {
8340                                Some(s) => s.to_string(),
8341                                None => continue,
8342                            };
8343                            Some(HistoryChange::PropSet {
8344                                field: field_str,
8345                                value: value.clone(),
8346                            })
8347                        }
8348                        _ => None,
8349                    },
8350                    WalRecord::RemoveProp { key: k, field }
8351                        if Self::aliases_match(&alias_intervals, k, commit) =>
8352                    {
8353                        Some(HistoryChange::PropRemoved {
8354                            field: field.clone(),
8355                        })
8356                    }
8357                    WalRecord::InsertEdge {
8358                        edge_type,
8359                        src_key,
8360                        dst_key,
8361                    } => {
8362                        if Self::aliases_match(&alias_intervals, src_key, commit) {
8363                            Some(HistoryChange::EdgeAdded {
8364                                edge_type: edge_type.clone(),
8365                                other: dst_key.clone(),
8366                                outgoing: true,
8367                            })
8368                        } else if Self::aliases_match(&alias_intervals, dst_key, commit) {
8369                            Some(HistoryChange::EdgeAdded {
8370                                edge_type: edge_type.clone(),
8371                                other: src_key.clone(),
8372                                outgoing: false,
8373                            })
8374                        } else {
8375                            None
8376                        }
8377                    }
8378                    WalRecord::InsertEdgeId { etype, src, dst } => {
8379                        let etype_str = match self.syms.resolve(*etype) {
8380                            Some(s) => s.to_string(),
8381                            None => continue,
8382                        };
8383                        let src_key = self.ids.key_of(*src);
8384                        let dst_key = self.ids.key_of(*dst);
8385                        if src_key == Some(key) {
8386                            let other = match dst_key {
8387                                Some(s) => s.to_string(),
8388                                None => continue,
8389                            };
8390                            Some(HistoryChange::EdgeAdded {
8391                                edge_type: etype_str,
8392                                other,
8393                                outgoing: true,
8394                            })
8395                        } else if dst_key == Some(key) {
8396                            let other = match src_key {
8397                                Some(s) => s.to_string(),
8398                                None => continue,
8399                            };
8400                            Some(HistoryChange::EdgeAdded {
8401                                edge_type: etype_str,
8402                                other,
8403                                outgoing: false,
8404                            })
8405                        } else {
8406                            None
8407                        }
8408                    }
8409                    WalRecord::DeleteEdge {
8410                        edge_type,
8411                        src_key,
8412                        dst_key,
8413                    } => {
8414                        if Self::aliases_match(&alias_intervals, src_key, commit) {
8415                            Some(HistoryChange::EdgeRemoved {
8416                                edge_type: edge_type.clone(),
8417                                other: dst_key.clone(),
8418                                outgoing: true,
8419                            })
8420                        } else if Self::aliases_match(&alias_intervals, dst_key, commit) {
8421                            Some(HistoryChange::EdgeRemoved {
8422                                edge_type: edge_type.clone(),
8423                                other: src_key.clone(),
8424                                outgoing: false,
8425                            })
8426                        } else {
8427                            None
8428                        }
8429                    }
8430                    WalRecord::DeleteNode { key: k }
8431                        if Self::aliases_match(&alias_intervals, k, commit) =>
8432                    {
8433                        Some(HistoryChange::NodeDeleted)
8434                    }
8435                    // Skip: rule/view/fulltext/intern metadata; Batch wrapper handled above.
8436                    _ => None,
8437                };
8438
8439                if let Some(change) = change {
8440                    out.push(HistoryEntry { commit, change });
8441                }
8442            }
8443        }
8444
8445        Ok(out)
8446    }
8447
8448    /// Return the per-edge change history between nodes `a` and `b` by scanning
8449    /// the on-disk WAL.
8450    ///
8451    /// ## Horizon
8452    ///
8453    /// History reaches back only to the last WAL-truncating snapshot, exactly
8454    /// like `node_history` and `open_at`. The returned [`HistoryResult`] carries
8455    /// `total_commits` (= number of WAL frames), which is the exclusive upper
8456    /// bound for valid commit indices.
8457    ///
8458    /// ## Derived edges
8459    ///
8460    /// Rule-derived edges appear via `DerivedEdgeAdded` / `DerivedEdgeRetracted`
8461    /// WAL markers written by `log_then_apply_with` after each rule-firing
8462    /// mutation. The `rule` field of those events carries the rule name.
8463    ///
8464    /// ## DeleteNode
8465    ///
8466    /// When a node is deleted, its manual incident edges are swept inline without
8467    /// individual `DeleteEdge` WAL records. `edge_history` detects `DeleteNode`
8468    /// events for either endpoint and synthesises `Retracted(rule:None)` events
8469    /// for each manual edge that was active at that point. Derived edges active at
8470    /// the time of deletion are handled by the `DerivedEdgeRetracted` marker that
8471    /// the engine appends immediately after the `DeleteNode` record; those events
8472    /// carry correct rule attribution and are emitted by the marker arm, not the
8473    /// synthetic sweep.
8474    ///
8475    /// ## Masks
8476    ///
8477    /// Like `node_history`, this method has no mask parameter and returns WAL
8478    /// history regardless of any role mask. For masked history semantics, apply
8479    /// the mask at the caller level.
8480    pub fn edge_history(
8481        &self,
8482        a: &str,
8483        b: &str,
8484    ) -> Result<crate::history::HistoryResult<crate::history::EdgeHistoryEvent>> {
8485        use crate::history::{EdgeEvent, EdgeHistoryEvent, HistoryResult};
8486        use core_storage::wal::WalRecord;
8487
8488        let (frames, _) = self.all_frames()?;
8489        let total_commits = self.wal_horizon_floor + frames.len() as u64;
8490
8491        // Resolve all historical names for a and b (handles RenameNode in the WAL).
8492        // Intervals are commit-bounded so recycled keys don't contaminate histories.
8493        let alias_a = self.build_key_alias_intervals(&frames, a);
8494        let alias_b = self.build_key_alias_intervals(&frames, b);
8495
8496        // Active edges between a and b tracked as (edge_type, src_key, dst_key, is_derived).
8497        // The is_derived flag is used by the DeleteNode sweep: manual edges are
8498        // swept with a synthetic Retracted(rule:None); derived edges are skipped
8499        // because the engine writes a DerivedEdgeRetracted marker immediately after
8500        // the DeleteNode record, which carries the correct rule attribution.
8501        let mut active: Vec<(String, String, String, bool)> = Vec::new();
8502        let mut out: Vec<EdgeHistoryEvent> = Vec::new();
8503
8504        for (local_i, frame) in frames.iter().enumerate() {
8505            let commit = self.wal_horizon_floor + local_i as u64;
8506            let records: &[WalRecord] = match frame {
8507                WalRecord::Batch(inner) => inner.as_slice(),
8508                single => std::slice::from_ref(single),
8509            };
8510
8511            for rec in records {
8512                match rec {
8513                    WalRecord::InsertEdge {
8514                        edge_type,
8515                        src_key,
8516                        dst_key,
8517                    } => {
8518                        let is_ab = Self::aliases_match(&alias_a, src_key, commit)
8519                            && Self::aliases_match(&alias_b, dst_key, commit);
8520                        let is_ba = Self::aliases_match(&alias_b, src_key, commit)
8521                            && Self::aliases_match(&alias_a, dst_key, commit);
8522                        if is_ab || is_ba {
8523                            active.push((
8524                                edge_type.clone(),
8525                                src_key.clone(),
8526                                dst_key.clone(),
8527                                false,
8528                            ));
8529                            out.push(EdgeHistoryEvent {
8530                                edge_type: edge_type.clone(),
8531                                commit,
8532                                event: EdgeEvent::Added,
8533                                rule: None,
8534                            });
8535                        }
8536                    }
8537                    WalRecord::InsertEdgeId { etype, src, dst } => {
8538                        let etype_str = match self.syms.resolve(*etype) {
8539                            Some(s) => s.to_string(),
8540                            None => continue,
8541                        };
8542                        // Use key_of_historical so tombstoned nodes (deleted
8543                        // later in the WAL) still resolve during the scan.
8544                        let src_key = self.ids.key_of_historical(*src);
8545                        let dst_key = self.ids.key_of_historical(*dst);
8546                        let is_ab = src_key == Some(a) && dst_key == Some(b);
8547                        let is_ba = src_key == Some(b) && dst_key == Some(a);
8548                        if is_ab || is_ba {
8549                            let src_str = src_key.unwrap().to_string();
8550                            let dst_str = dst_key.unwrap().to_string();
8551                            active.push((etype_str.clone(), src_str, dst_str, false));
8552                            out.push(EdgeHistoryEvent {
8553                                edge_type: etype_str,
8554                                commit,
8555                                event: EdgeEvent::Added,
8556                                rule: None,
8557                            });
8558                        }
8559                    }
8560                    WalRecord::DeleteEdge {
8561                        edge_type,
8562                        src_key,
8563                        dst_key,
8564                    } => {
8565                        let is_ab = Self::aliases_match(&alias_a, src_key, commit)
8566                            && Self::aliases_match(&alias_b, dst_key, commit);
8567                        let is_ba = Self::aliases_match(&alias_b, src_key, commit)
8568                            && Self::aliases_match(&alias_a, dst_key, commit);
8569                        if is_ab || is_ba {
8570                            // Remove the first matching active entry (flag ignored).
8571                            if let Some(pos) = active.iter().position(|(et, s, d, _)| {
8572                                et == edge_type && s == src_key && d == dst_key
8573                            }) {
8574                                active.remove(pos);
8575                            }
8576                            out.push(EdgeHistoryEvent {
8577                                edge_type: edge_type.clone(),
8578                                commit,
8579                                event: EdgeEvent::Retracted,
8580                                rule: None,
8581                            });
8582                        }
8583                    }
8584                    WalRecord::DeleteNode { key: k }
8585                        if Self::aliases_match(&alias_a, k, commit)
8586                            || Self::aliases_match(&alias_b, k, commit) =>
8587                    {
8588                        // Sweep: implicitly retract only MANUAL active edges.
8589                        // Derived active edges are skipped here because the rule
8590                        // engine appends a DerivedEdgeRetracted marker immediately
8591                        // after this DeleteNode record; that marker produces the
8592                        // single correctly-attributed Retracted event.  Derived
8593                        // entries are dropped from `active` (the marker arm's
8594                        // idempotent retain finds nothing to remove).
8595                        for (et, _, _, is_derived) in active.drain(..) {
8596                            if !is_derived {
8597                                out.push(EdgeHistoryEvent {
8598                                    edge_type: et,
8599                                    commit,
8600                                    event: EdgeEvent::Retracted,
8601                                    rule: None,
8602                                });
8603                            }
8604                            // Derived: drop silently; marker carries the Retracted event.
8605                        }
8606                    }
8607                    WalRecord::DerivedEdgeAdded {
8608                        rule,
8609                        edge_type: et,
8610                        src_key,
8611                        dst_key,
8612                    } => {
8613                        let is_ab = Self::aliases_match(&alias_a, src_key, commit)
8614                            && Self::aliases_match(&alias_b, dst_key, commit);
8615                        let is_ba = Self::aliases_match(&alias_b, src_key, commit)
8616                            && Self::aliases_match(&alias_a, dst_key, commit);
8617                        if is_ab || is_ba {
8618                            active.push((et.clone(), src_key.clone(), dst_key.clone(), true));
8619                            out.push(EdgeHistoryEvent {
8620                                edge_type: et.clone(),
8621                                commit,
8622                                event: EdgeEvent::Added,
8623                                rule: Some(rule.clone()),
8624                            });
8625                        }
8626                    }
8627                    WalRecord::DerivedEdgeRetracted {
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                            // Push unconditionally: a derived edge whose Added marker
8639                            // predates the history horizon has no `active` entry, but
8640                            // the retraction is still a real in-window event.
8641                            // Remove from active idempotently if present.
8642                            active.retain(|(aet, s, d, _)| {
8643                                !(aet == et && s == src_key && d == dst_key)
8644                            });
8645                            out.push(EdgeHistoryEvent {
8646                                edge_type: et.clone(),
8647                                commit,
8648                                event: EdgeEvent::Retracted,
8649                                rule: Some(rule.clone()),
8650                            });
8651                        }
8652                    }
8653                    // All other records (InsertNode, SetProp, CreateRule, etc.)
8654                    // do not affect edges between a and b.
8655                    _ => {}
8656                }
8657            }
8658        }
8659
8660        Ok(HistoryResult {
8661            items: out,
8662            total_commits,
8663        })
8664    }
8665
8666    /// Return `true` iff an edge of `edge_type` existed between `a` and `b`
8667    /// (in either direction) at the WAL commit `at_commit`.
8668    ///
8669    /// ## Horizon
8670    ///
8671    /// Valid commit indices are `0..total_commits` where `total_commits` is the
8672    /// number of WAL frames. An `at_commit >= total_commits` is outside the
8673    /// visible horizon and returns [`GraphError::CommitOutOfRange`].
8674    ///
8675    /// ## Derived edges
8676    ///
8677    /// Rule-derived edges are tracked via `DerivedEdgeAdded` / `DerivedEdgeRetracted`
8678    /// WAL markers appended at firing time (Task 1). `was_linked` reads these markers
8679    /// and therefore includes derived edges in its point-in-time evaluation,
8680    /// matching `edge_history`'s fidelity.
8681    pub fn was_linked(&self, a: &str, b: &str, edge_type: &str, at_commit: u64) -> Result<bool> {
8682        use core_storage::wal::WalRecord;
8683
8684        let (frames, _) = self.all_frames()?;
8685        let total_commits = self.wal_horizon_floor + frames.len() as u64;
8686
8687        // Horizon floor: commits in pruned archives are unreachable.
8688        if at_commit < self.wal_horizon_floor {
8689            return Err(GraphError::CommitOutOfRange {
8690                commit: at_commit,
8691                total: total_commits,
8692            });
8693        }
8694        if at_commit >= total_commits {
8695            return Err(GraphError::CommitOutOfRange {
8696                commit: at_commit,
8697                total: total_commits,
8698            });
8699        }
8700
8701        // Resolve all historical names for a and b (handles RenameNode in the WAL).
8702        // Intervals are commit-bounded so recycled keys don't contaminate point-in-time reads.
8703        let alias_a = self.build_key_alias_intervals(&frames, a);
8704        let alias_b = self.build_key_alias_intervals(&frames, b);
8705
8706        // Local index into surviving frames (0 = first frame of oldest archive).
8707        let local_commit = at_commit - self.wal_horizon_floor;
8708
8709        // Replay local frames 0..=local_commit, tracking active edges.
8710        let mut active: BTreeSet<(String, String, String)> = BTreeSet::new();
8711
8712        for (local_i, frame) in frames.iter().enumerate().take((local_commit + 1) as usize) {
8713            let commit = self.wal_horizon_floor + local_i as u64;
8714            let records: &[WalRecord] = match frame {
8715                WalRecord::Batch(inner) => inner.as_slice(),
8716                single => std::slice::from_ref(single),
8717            };
8718
8719            for rec in records {
8720                match rec {
8721                    WalRecord::InsertEdge {
8722                        edge_type: et,
8723                        src_key,
8724                        dst_key,
8725                    } => {
8726                        let is_ab = Self::aliases_match(&alias_a, src_key, commit)
8727                            && Self::aliases_match(&alias_b, dst_key, commit);
8728                        let is_ba = Self::aliases_match(&alias_b, src_key, commit)
8729                            && Self::aliases_match(&alias_a, dst_key, commit);
8730                        if is_ab || is_ba {
8731                            active.insert((et.clone(), src_key.clone(), dst_key.clone()));
8732                        }
8733                    }
8734                    WalRecord::InsertEdgeId { etype, src, dst } => {
8735                        let etype_str = match self.syms.resolve(*etype) {
8736                            Some(s) => s.to_string(),
8737                            None => continue,
8738                        };
8739                        // Use key_of_historical so tombstoned nodes resolve.
8740                        let src_key = self.ids.key_of_historical(*src);
8741                        let dst_key = self.ids.key_of_historical(*dst);
8742                        let is_ab = src_key == Some(a) && dst_key == Some(b);
8743                        let is_ba = src_key == Some(b) && dst_key == Some(a);
8744                        if is_ab || is_ba {
8745                            active.insert((
8746                                etype_str,
8747                                src_key.unwrap().to_string(),
8748                                dst_key.unwrap().to_string(),
8749                            ));
8750                        }
8751                    }
8752                    WalRecord::DeleteEdge {
8753                        edge_type: et,
8754                        src_key,
8755                        dst_key,
8756                    } => {
8757                        let is_ab = Self::aliases_match(&alias_a, src_key, commit)
8758                            && Self::aliases_match(&alias_b, dst_key, commit);
8759                        let is_ba = Self::aliases_match(&alias_b, src_key, commit)
8760                            && Self::aliases_match(&alias_a, dst_key, commit);
8761                        if is_ab || is_ba {
8762                            active.remove(&(et.clone(), src_key.clone(), dst_key.clone()));
8763                        }
8764                    }
8765                    WalRecord::DeleteNode { key: k }
8766                        if Self::aliases_match(&alias_a, k, commit)
8767                            || Self::aliases_match(&alias_b, k, commit) =>
8768                    {
8769                        // All edges touching the deleted node are gone.
8770                        active.retain(|(_, s, d)| s != k && d != k);
8771                    }
8772                    WalRecord::DerivedEdgeAdded {
8773                        edge_type: et,
8774                        src_key,
8775                        dst_key,
8776                        ..
8777                    } => {
8778                        let is_ab = Self::aliases_match(&alias_a, src_key, commit)
8779                            && Self::aliases_match(&alias_b, dst_key, commit);
8780                        let is_ba = Self::aliases_match(&alias_b, src_key, commit)
8781                            && Self::aliases_match(&alias_a, dst_key, commit);
8782                        if is_ab || is_ba {
8783                            active.insert((et.clone(), src_key.clone(), dst_key.clone()));
8784                        }
8785                    }
8786                    WalRecord::DerivedEdgeRetracted {
8787                        edge_type: et,
8788                        src_key,
8789                        dst_key,
8790                        ..
8791                    } => {
8792                        let is_ab = Self::aliases_match(&alias_a, src_key, commit)
8793                            && Self::aliases_match(&alias_b, dst_key, commit);
8794                        let is_ba = Self::aliases_match(&alias_b, src_key, commit)
8795                            && Self::aliases_match(&alias_a, dst_key, commit);
8796                        if is_ab || is_ba {
8797                            active.remove(&(et.clone(), src_key.clone(), dst_key.clone()));
8798                        }
8799                    }
8800                    _ => {}
8801                }
8802            }
8803        }
8804
8805        Ok(active.iter().any(|(et, _, _)| et == edge_type))
8806    }
8807
8808    pub fn edge_count(&self) -> u64 {
8809        self.topo_view().edge_count()
8810    }
8811
8812    /// Live/tombstone/edge counts plus per-rule provenance size, trip latch,
8813    /// and fire counter (includes rebuild evaluations). Rules are sorted by name.
8814    pub fn stats(&self) -> Stats {
8815        self.ensure_v8_base_sections_loaded();
8816        let rules: Vec<RuleStats> = self
8817            .engine
8818            .rules()
8819            .map(|r| RuleStats {
8820                name: r.name.clone(),
8821                edges: self
8822                    .engine
8823                    .provenance()
8824                    .get(&r.name)
8825                    .map(|s| s.len() as u64)
8826                    .unwrap_or(0),
8827                tripped: self.engine.is_tripped(&r.name),
8828                fires: self.engine.fire_count(&r.name),
8829                approximate: r.approximate,
8830            })
8831            .collect();
8832        Stats {
8833            nodes_live: self.ids.live_len(),
8834            nodes_tombstoned: self.ids.len() - self.ids.live_len(),
8835            edges: self.topo_view().edge_count(),
8836            rules,
8837            chain_truncations: self.engine.chain_truncations(),
8838        }
8839    }
8840
8841    /// On-disk size of the WAL file in bytes.
8842    ///
8843    /// Reads file metadata without loading WAL contents.  Returns `Err` for
8844    /// in-memory (`SimFs`) databases where no WAL file exists on disk.
8845    pub fn wal_size_bytes(&self) -> std::io::Result<u64> {
8846        let path = self.fs.wal_path().ok_or_else(|| {
8847            std::io::Error::new(
8848                std::io::ErrorKind::Unsupported,
8849                "wal_path not available for this Fs implementation",
8850            )
8851        })?;
8852        Ok(std::fs::metadata(path)?.len())
8853    }
8854
8855    /// Set the slow-query threshold.  Queries whose execution time equals or
8856    /// exceeds `ms` milliseconds are logged.  Pass `0` to disable.
8857    ///
8858    /// Use this setter in tests — the environment variable
8859    /// `MUSHROOMDB_SLOW_QUERY_MS` is process-global and races parallel test
8860    /// threads.
8861    pub fn set_slow_query_threshold_ms(&mut self, ms: u64) {
8862        self.slow_query_threshold_ms = ms;
8863    }
8864
8865    /// Snapshot of the slow-query ring buffer and lifetime counter.
8866    pub fn slow_query_snapshot(&self) -> SlowQuerySnapshot {
8867        let log = self.slow_queries.lock().unwrap_or_else(|e| e.into_inner());
8868        SlowQuerySnapshot {
8869            threshold_ms: self.slow_query_threshold_ms,
8870            count: log.total,
8871            last: log.entries.iter().cloned().collect(),
8872        }
8873    }
8874
8875    /// Instant the database was opened.  Used by consumers (e.g. `/metrics`)
8876    /// to compute uptime.
8877    pub fn started_at(&self) -> std::time::Instant {
8878        self.started_at
8879    }
8880
8881    /// On-disk snapshot format version this binary writes and reads.
8882    pub fn format_version() -> u16 {
8883        core_storage::snapshot::VERSION
8884    }
8885
8886    /// Test-support: total bytes appended (SimFs only usage).
8887    pub fn fs_total_appended(&self) -> usize
8888    where
8889        F: FsIntrospect,
8890    {
8891        self.fs.total_appended()
8892    }
8893
8894    /// Test-support: successful `Fs::sync` calls (SimFs / counting fs).
8895    pub fn fs_sync_count(&self) -> usize
8896    where
8897        F: FsIntrospect,
8898    {
8899        self.fs.sync_count()
8900    }
8901
8902    /// Consume the db, returning its fs (for crash simulation).
8903    pub fn into_fs(self) -> F {
8904        self.fs
8905    }
8906
8907    pub fn snapshot(&mut self) -> Result<()> {
8908        self.snapshot_with(SnapshotOptions::default())
8909    }
8910
8911    /// Snapshot with explicit options.
8912    ///
8913    /// # `keep_wal`
8914    ///
8915    /// When `keep_wal` is `false` (the default, same as [`snapshot`]):
8916    ///   - The WAL is replaced with a minimal baseline containing one
8917    ///     `EnableFulltext` record per active declaration.  All pre-snapshot
8918    ///     history is discarded; `open_at` can only reach post-snapshot commits.
8919    ///
8920    /// When `keep_wal` is `true`:
8921    ///   - The WAL is left intact.  All pre-snapshot commits remain reachable
8922    ///     via `open_at`.  The existing WAL already contains the original
8923    ///     `EnableFulltext` records, so no baseline re-write is needed; the
8924    ///     recovery guards in `apply()` silently skip any duplicate records on
8925    ///     replay.
8926    ///   - Crash window: a crash after the snapshot write but before the next
8927    ///     WAL write leaves the full pre-snapshot WAL intact.  On reopen the
8928    ///     snapshot is loaded and the WAL replayed idempotently over it — safe
8929    ///     because every `apply()` arm is idempotent when replayed over an
8930    ///     already-current snapshot.
8931    pub fn snapshot_with(&mut self, opts: SnapshotOptions) -> Result<()> {
8932        if self.read_only {
8933            return Err(GraphError::ReadOnly);
8934        }
8935        // Capture whether snapshot.bin already existed BEFORE this snapshot write.
8936        // Used by the archive path's conservative genesis-chain check: if a prior
8937        // snapshot exists but wal.truncated does not, we cannot distinguish a
8938        // legacy store (may have been truncated in an older code version) from a
8939        // new store that only used keep_wal=true.  Conservative: refuse genesis in
8940        // both cases.  Must be sampled here, before the snapshot write below.
8941        let had_prior_snapshot = self.fs.snapshot_path().map(|p| p.exists()).unwrap_or(false);
8942        self.ensure_v8_base_sections_loaded();
8943        // Ensure provenance is decoded before to_persist() clones it.
8944        self.engine.ensure_provenance_loaded_mut();
8945        let (rule_defs_typed, provenance, rule_tripped, rule_fires) = self.engine.to_persist();
8946        let rule_defs = rule_defs_typed
8947            .iter()
8948            .map(|r| bincode::serialize(r).expect("RuleDef serialize cannot fail"))
8949            .collect();
8950        // Collect HNSW state and IVF state.  When indexes are not yet
8951        // populated (clean open, no mutation since open), pass the retained
8952        // raw bytes through directly so that migrate/snapshot does not
8953        // silently discard fitted approximate-rule indexes.
8954        let hnsw_state = self.engine.export_hnsw_state_passthrough();
8955        let ivf_bytes = if !self.engine.indexes_populated() {
8956            // Pass retained IVF bytes through unchanged (no re-encode).
8957            self.engine.retained_ivf_bytes_clone().unwrap_or_default()
8958        } else {
8959            // Indexes live: encode from current state.
8960            let raw_ivf = self.engine.export_ivf_state();
8961            let ivf_state_map: BTreeMap<String, core_storage::snapshot::PerRuleIvfState> = raw_ivf
8962                .into_iter()
8963                .map(|(name, ((sc, sa, sd), (dc, da, dd)))| {
8964                    (
8965                        name,
8966                        core_storage::snapshot::PerRuleIvfState {
8967                            src: core_storage::snapshot::SideIvfState {
8968                                centroids: sc,
8969                                clusters: sa,
8970                                drift: sd,
8971                            },
8972                            dst: core_storage::snapshot::SideIvfState {
8973                                centroids: dc,
8974                                clusters: da,
8975                                drift: dd,
8976                            },
8977                        },
8978                    )
8979                })
8980                .collect();
8981            if ivf_state_map.is_empty() {
8982                Vec::new()
8983            } else {
8984                bincode::serialize(&ivf_state_map).expect("IVF state serialize cannot fail")
8985            }
8986        };
8987        let view_defs: Vec<Vec<u8>> = self
8988            .view_store
8989            .views()
8990            .map(|v| bincode::serialize(v).expect("ViewDef serialize cannot fail"))
8991            .collect();
8992        if self.base.is_some() {
8993            // V8 merge-snapshot path: encode base+overlay into a new V8 snapshot,
8994            // write it atomically, remap it as the new base, then clear the overlay.
8995            let meta = V8Meta {
8996                labels: self.labels.clone(),
8997                edge_props: self.edge_props.clone(),
8998                rule_defs,
8999                provenance,
9000                rule_tripped,
9001                rule_fires,
9002                ivf_bytes,
9003                view_defs,
9004                wal_truncated: !opts.keep_wal,
9005                hnsw: hnsw_state,
9006                last_change: self.last_change.clone(),
9007            };
9008            let mut buf: Vec<u8> = Vec::new();
9009            {
9010                // Clone the Arc so the old base stays alive while we encode.
9011                // The borrow of archived_csr (into old_base's mmap) is released
9012                // at the end of this block, before we replace self.base.
9013                let old_base = self.base.clone().expect("is_some checked above");
9014                let archived_csr = old_base.topology().map_err(|e| GraphError::Corrupt {
9015                    detail: format!("v8 snapshot: topology section: {e:?}"),
9016                })?;
9017                let archived_cols = old_base.columns().map_err(|e| GraphError::Corrupt {
9018                    detail: format!("v8 snapshot: columns section: {e:?}"),
9019                })?;
9020                let archived_edge_props =
9021                    old_base
9022                        .edge_props_section()
9023                        .map_err(|e| GraphError::Corrupt {
9024                            detail: format!("v8 snapshot: edge_props section: {e:?}"),
9025                        })?;
9026                let edge_props_raw =
9027                    old_base
9028                        .edge_props_raw_bytes()
9029                        .map_err(|e| GraphError::Corrupt {
9030                            detail: format!("v8 snapshot: edge_props raw bytes: {e:?}"),
9031                        })?;
9032                let prov_raw =
9033                    old_base
9034                        .provenance_raw_bytes()
9035                        .map_err(|e| GraphError::Corrupt {
9036                            detail: format!("v8 snapshot: provenance raw bytes: {e:?}"),
9037                        })?;
9038                encode_v8(
9039                    Some(archived_csr),
9040                    Some(archived_cols),
9041                    Some((archived_edge_props, edge_props_raw)),
9042                    Some(prov_raw),
9043                    &self.topo,
9044                    &self.props,
9045                    &self.ids,
9046                    &self.syms,
9047                    &meta,
9048                    &mut buf,
9049                )?;
9050            }
9051            self.fs.write_atomic(FileId::Snapshot, &buf)?;
9052            // Remap the freshly-written snapshot as the new base.
9053            // C2: use file mmap on RealFs; fall back to from_bytes on SimFs.
9054            let new_base = if let Some(snap_path) = self.fs.snapshot_path() {
9055                core_storage::v8::MappedBase::map(&snap_path)
9056            } else {
9057                core_storage::v8::MappedBase::from_bytes(buf)
9058            }
9059            .map_err(|e| GraphError::Corrupt {
9060                detail: format!("v8 snapshot: remap new base: {e:?}"),
9061            })?;
9062            self.base = Some(Arc::new(new_base));
9063            // Clear the overlay and prop tombstones — all data is now in the new base.
9064            self.topo = Topology::new();
9065            self.props = core_storage::columns::ColumnStore::new();
9066        } else {
9067            // Legacy path (V5–V7 stores without a V8 base).
9068            //
9069            // Memory-diet path: build V8Meta directly from &self — no SnapshotState
9070            // clone and no encode_v8_from_state intermediate clones.  The big
9071            // structures (self.topo, self.props) are borrowed, not cloned.
9072            // self.edge_props is moved (not cloned) because we immediately clear it
9073            // when we remap the new V8 snapshot as self.base (see below).
9074            //
9075            // Eliminates from peak RSS vs. the old SnapshotState path:
9076            //   • self.topo.clone()      (~topology HashMap footprint)
9077            //   • self.props.clone()     (~column-store footprint)
9078            //   • encode_v8_from_state V8Meta secondary clones (labels, edge_props, …)
9079            let meta = V8Meta {
9080                labels: self.labels.clone(),
9081                wal_truncated: !opts.keep_wal,
9082                // Move edge_props out so the large overlay is freed when meta
9083                // drops at end of this block (self.edge_props is now empty; reads
9084                // after base assignment go through the mmap'd base section).
9085                edge_props: std::mem::take(&mut self.edge_props),
9086                rule_defs,
9087                provenance,
9088                rule_tripped,
9089                rule_fires,
9090                ivf_bytes,
9091                view_defs,
9092                hnsw: hnsw_state,
9093                last_change: self.last_change.clone(),
9094            };
9095            let mut buf = Vec::new();
9096            encode_v8(
9097                None,
9098                None,
9099                None,
9100                None,
9101                &self.topo,
9102                &self.props,
9103                &self.ids,
9104                &self.syms,
9105                &meta,
9106                &mut buf,
9107            )?;
9108            // meta (and the moved edge_props inside it) is no longer needed;
9109            // drop it before the write to keep the peak window narrow.
9110            drop(meta);
9111            self.fs.write_atomic(FileId::Snapshot, &buf)?;
9112            // Remap the freshly-written V8 snapshot as self.base.
9113            // On RealFs: drop the encode buffer before mmap to recover ~1.9 GiB.
9114            // On SimFs (tests): pass buf to from_bytes.
9115            let new_base = if let Some(snap_path) = self.fs.snapshot_path() {
9116                drop(buf);
9117                core_storage::v8::MappedBase::map(&snap_path)
9118            } else {
9119                core_storage::v8::MappedBase::from_bytes(buf)
9120            }
9121            .map_err(|e| GraphError::Corrupt {
9122                detail: format!("v8 snapshot: remap new base (legacy path): {e:?}"),
9123            })?;
9124            self.base = Some(Arc::new(new_base));
9125            // Free the large heap-allocated decoded state — all data is now in the
9126            // mmap'd base.  Mirrors the V8 merge-snapshot path (see above).
9127            // self.edge_props was already moved into meta and is effectively empty.
9128            self.topo = Topology::new();
9129            self.props = core_storage::columns::ColumnStore::new();
9130        }
9131
9132        if opts.archive_wal {
9133            // History-preserving snapshot (Task 4):
9134            //   1. Snapshot already written above (write_atomic → fsynced).
9135            //   2. Rename WAL → wal.<commit_seq>.archive  (atomic, same fs).
9136            //      Crash window B: crash here leaves archive present, WAL
9137            //      absent.  Reopen: snapshot loaded (full state), no WAL
9138            //      replay.  Archive is NOT replayed into live state — it is
9139            //      pre-snapshot by construction.  Safe.
9140            //   3. Optionally write genesis marker (first archive only, no
9141            //      prior WAL truncation).
9142            //   4. Prune old archives (retention), update horizon floor.
9143            //      Pruning invalidates the genesis chain; delete marker.
9144            //   5. Write new minimal baseline WAL (write_atomic).
9145            //      Crash window C: crash here leaves new archive plus no live
9146            //      WAL.  Same as window B — handled above.
9147            //
9148            // Sample existing archives BEFORE the rename so we can detect
9149            // whether this is the first archive.
9150            let existing_archives = self.fs.list_archives()?;
9151            let is_first_archive = existing_archives.is_empty();
9152
9153            // Compute a globally-monotonic archive name: the name equals the
9154            // cumulative end-frame index of the archive in global commit space.
9155            //
9156            // Using `commit_seq` directly is UNSOUND across sessions: on reopen
9157            // commit_seq is seeded from max(last_change), which underestimates
9158            // the WAL depth when trailing commits (e.g. insert_edge) do not
9159            // update last_change.  A session-2 archive could then receive a name
9160            // ≤ the session-1 archive, causing incorrect sort order or collision.
9161            //
9162            // Instead: read and decode the live WAL here (before the rename) to
9163            // get its exact frame count, then add it to the last known global
9164            // end-frame index (the name of the most recent existing archive, or
9165            // wal_horizon_floor if no archives exist).  This is O(WAL size) but
9166            // snapshot is already serialising the full graph state, so the cost
9167            // is dominated.
9168            let live_wal_bytes_for_name = self.fs.read(FileId::Wal)?;
9169            let (live_frames_for_name, _) = decode_all(&live_wal_bytes_for_name);
9170            let archive_n = existing_archives
9171                .last()
9172                .copied()
9173                .unwrap_or(self.wal_horizon_floor)
9174                + live_frames_for_name.len() as u64;
9175            self.fs.archive_wal(archive_n)?;
9176
9177            // Genesis marker: written once when the first archive is taken
9178            // from a store that has never undergone a WAL-truncating snapshot.
9179            // When present, `open_at` may replay archive-resident commits from
9180            // empty state (the archive chain covers from global index 0).
9181            //
9182            // Two conditions must ALL hold:
9183            //   1. This is the first archive (existing_archives was empty).
9184            //   2. No snapshot.bin existed before this operation (had_prior_snapshot=false).
9185            //      A WAL-truncating snapshot (keep_wal=false) always writes snapshot.bin
9186            //      before truncating the WAL, so if any prior truncating snapshot was taken
9187            //      — even in a previous session — snapshot.bin is present and this condition
9188            //      is false.  This subsumes the cross-session truncation case without
9189            //      requiring a separate wal.truncated sidecar file.
9190            //      For legacy stores (snapshot.bin written by an older code version that
9191            //      may have truncated the WAL), the same conservative refusal applies:
9192            //      we cannot prove the chain is complete, so we refuse genesis (cost =
9193            //      no as-of-through-archives; never silent wrong data).
9194            //      On SimFs (snapshot_path() == None) had_prior_snapshot is always false,
9195            //      so SimFs always passes this check.
9196            if is_first_archive && !had_prior_snapshot {
9197                self.fs.write_genesis_marker()?;
9198                self.archive_genesis_chain = true;
9199            }
9200
9201            // Retention pruning: keep newest `keep` archives; delete oldest.
9202            // Pruning is the ONLY deletion site for archives.
9203            //
9204            // Crash-safety ordering (C1 fix):
9205            //   1. Count frames in surplus archives (reads only — no mutation).
9206            //   2. Advance and PERSIST the horizon floor FIRST via write-then-
9207            //      rename (atomic).  A crash after this point leaves orphaned
9208            //      archives on disk, but the floor is correct.  The opening
9209            //      cleanup sweep (`cleanup_orphaned_archives`) removes them on
9210            //      the next open, so the store is always safe to reopen.
9211            //   3. Delete the genesis marker (floor > 0 already blocks open_at
9212            //      via the conjunctive gate; marker cleanup is belt-and-suspenders).
9213            //   4. Delete surplus archives.  A crash between any two deletes
9214            //      leaves the floor committed and orphaned archives cleaned at
9215            //      next open — never a stale floor with a missing archive prefix.
9216            if let Some(keep) = self.wal_archive_retention {
9217                if keep > 0 {
9218                    let archives = self.fs.list_archives()?;
9219                    // archives is sorted ascending (oldest first)
9220                    if archives.len() as u32 > keep {
9221                        let surplus = archives.len() - keep as usize;
9222                        // Step 1: count pruned frames (reads, no mutation).
9223                        let mut pruned_frames = 0u64;
9224                        for &n in &archives[..surplus] {
9225                            let bytes = self.fs.read_archive(n)?;
9226                            let (frames, _) = decode_all(&bytes);
9227                            pruned_frames += frames.len() as u64;
9228                        }
9229                        // Step 2: advance and persist floor FIRST.
9230                        self.wal_horizon_floor += pruned_frames;
9231                        self.fs.write_horizon_floor(self.wal_horizon_floor)?;
9232                        // Step 3: delete genesis marker (floor > 0 already
9233                        // blocks open_at; this is belt-and-suspenders cleanup).
9234                        if pruned_frames > 0 && self.archive_genesis_chain {
9235                            self.fs.delete_genesis_marker()?;
9236                            self.archive_genesis_chain = false;
9237                        }
9238                        // Step 4: delete surplus archives.  Crash here →
9239                        // orphaned archives; cleaned at next open.
9240                        for &n in &archives[..surplus] {
9241                            self.fs.delete_archive(n)?;
9242                        }
9243                    }
9244                }
9245            }
9246
9247            // Write new minimal baseline WAL (mirrors the keep_wal=false path).
9248            let mut baseline_wal: Vec<u8> = Vec::new();
9249            for (label, field) in self.fulltext.enabled_pairs() {
9250                let rec = WalRecord::EnableFulltext {
9251                    label: label.clone(),
9252                    field: field.clone(),
9253                };
9254                baseline_wal.extend_from_slice(&encode_record(&rec));
9255            }
9256            for (label, field) in self.prop_index.enabled_pairs() {
9257                let rec = WalRecord::EnableIndex {
9258                    label: label.clone(),
9259                    field: field.clone(),
9260                };
9261                baseline_wal.extend_from_slice(&encode_record(&rec));
9262            }
9263            self.fs.write_atomic(FileId::Wal, &baseline_wal)?;
9264        } else if opts.keep_wal {
9265            // keep_wal=true: WAL is left untouched.  The existing WAL already
9266            // contains the EnableFulltext records from the original enable calls;
9267            // replay is idempotent (guards in apply() skip already-live entries).
9268            // No baseline re-write is needed or safe here — the full WAL history
9269            // must remain intact for open_at to reach pre-snapshot commits.
9270        } else {
9271            // keep_wal=false (default): truncate by replacing the WAL with a
9272            // minimal baseline of one EnableFulltext record per active pair.
9273            //
9274            // Crash-ordering: write_atomic is atomic.
9275            //   • Crash before snapshot write  → WAL unchanged.  Safe.
9276            //   • Crash after snapshot write but before this WAL write → full
9277            //     pre-snapshot WAL still present; open_with replays idempotently.
9278            //   • Crash after both writes → normal post-snapshot state.
9279            //
9280            // Genesis chain: a WAL-truncating snapshot breaks the archive chain
9281            // for any archives taken AFTER this point (their WAL slices would
9282            // not start at genesis).  Delete any existing genesis marker so that
9283            // open_at refuses archive-resident commits.  Future sessions are
9284            // covered by had_prior_snapshot: snapshot.bin written here persists
9285            // across sessions and prevents a later archiving session from
9286            // incorrectly claiming a complete genesis chain.
9287            if self.archive_genesis_chain {
9288                self.fs.delete_genesis_marker()?;
9289                self.archive_genesis_chain = false;
9290            }
9291            let mut baseline_wal: Vec<u8> = Vec::new();
9292            for (label, field) in self.fulltext.enabled_pairs() {
9293                let rec = WalRecord::EnableFulltext {
9294                    label: label.clone(),
9295                    field: field.clone(),
9296                };
9297                baseline_wal.extend_from_slice(&encode_record(&rec));
9298            }
9299            for (label, field) in self.prop_index.enabled_pairs() {
9300                let rec = WalRecord::EnableIndex {
9301                    label: label.clone(),
9302                    field: field.clone(),
9303                };
9304                baseline_wal.extend_from_slice(&encode_record(&rec));
9305            }
9306            self.fs.write_atomic(FileId::Wal, &baseline_wal)?;
9307        }
9308        // After snapshot the overlay may have changed (V8 merge path clears
9309        // self.topo and self.props). Refresh the MVCC fold so future readers
9310        // see the post-snapshot state rather than stale overlay data.
9311        self.fold_now();
9312        Ok(())
9313    }
9314}
9315
9316/// Queued mutation for a [`BatchBuilder`] or [`GraphDb::commit_group`].
9317///
9318/// The `submit_batch` / `commit_group` APIs accept `Vec<BatchOp>` so that
9319/// callers can build a set of mutations without holding `&mut GraphDb` and
9320/// hand them off to the group-committing writer for durable, batched I/O.
9321pub enum BatchOp {
9322    InsertNode {
9323        label: String,
9324        key: String,
9325        props: Vec<(String, Value)>,
9326    },
9327    InsertEdge {
9328        edge_type: String,
9329        src_key: String,
9330        dst_key: String,
9331    },
9332    SetProp {
9333        key: String,
9334        field: String,
9335        value: Value,
9336    },
9337    RemoveProp {
9338        key: String,
9339        field: String,
9340    },
9341    DeleteEdge {
9342        edge_type: String,
9343        src_key: String,
9344        dst_key: String,
9345    },
9346    DeleteNode {
9347        key: String,
9348    },
9349    CreateRule(RuleDef),
9350    DeleteRule {
9351        name: String,
9352    },
9353    /// Rename a node's key. Validated: old must exist, new must not.
9354    RenameNode {
9355        old_key: String,
9356        new_key: String,
9357    },
9358    /// Insert an edge, auto-creating any missing endpoint as a plain node with
9359    /// `placeholder_label` and no props. Rules fire and last-change is updated
9360    /// for each created endpoint (normal InsertNode semantics in the batch frame).
9361    InsertEdgeUpsert {
9362        edge_type: String,
9363        src_key: String,
9364        dst_key: String,
9365        placeholder_label: String,
9366    },
9367}
9368
9369/// Three-way node visibility status used by `check_single_op_authz`.
9370enum NodeAuthzStatus {
9371    /// Node exists in the store and is in the role's read mask.
9372    Visible(String), // carries the node's label
9373    /// Node exists in the store but is NOT in the role's read mask.
9374    Hidden,
9375    /// Node does not exist in the store.
9376    Absent,
9377}
9378
9379/// Overlay of ops already accepted earlier in the same batch. Never written
9380/// back to the database — validation only.
9381#[derive(Default)]
9382struct Overlay {
9383    extra_keys: BTreeSet<String>,
9384    deleted_keys: BTreeSet<String>,
9385    extra_props: BTreeMap<(String, String), Value>,
9386    removed_props: BTreeSet<(String, String)>,
9387    extra_edges: BTreeSet<(String, String, String)>,
9388    deleted_edges: BTreeSet<(String, String, String)>,
9389    extra_rules: BTreeSet<String>,
9390    deleted_rules: BTreeSet<String>,
9391    /// `rule name → (via_edge, edge_type)` for every via-hop rule accepted
9392    /// earlier in this batch. Feeds the rule-chain cycle check, which otherwise
9393    /// sees only the rules already committed to the engine. Keyed by name so a
9394    /// later `DeleteRule` in the same batch drops the arc with the rule.
9395    extra_rule_arcs: BTreeMap<String, (String, String)>,
9396}
9397
9398/// Read-only view of live db state plus a batch overlay. Shared by single-op
9399/// public methods (empty overlay) and `commit_batch`.
9400struct MutPreview<'a, F: Fs> {
9401    db: &'a GraphDb<F>,
9402    overlay: Overlay,
9403}
9404
9405/// Shortest path from `start` to `target` following `arcs` (`from → to`), or
9406/// `None` if `target` is unreachable.
9407///
9408/// Used for rule-chain cycle detection, where an arc is "a rule hops over
9409/// `from` and writes `to`". Breadth-first over BTree-ordered adjacency, so the
9410/// reported path is stable for a given rule set, and iterative so a pathological
9411/// rule graph cannot overflow the stack.
9412fn find_cycle_through(arcs: &[(String, String)], start: &str, target: &str) -> Option<Vec<String>> {
9413    let mut adj: BTreeMap<&str, BTreeSet<&str>> = BTreeMap::new();
9414    for (from, to) in arcs {
9415        adj.entry(from.as_str()).or_default().insert(to.as_str());
9416    }
9417    let mut parent: BTreeMap<&str, &str> = BTreeMap::new();
9418    let mut visited: BTreeSet<&str> = BTreeSet::new();
9419    let mut queue: std::collections::VecDeque<&str> = std::collections::VecDeque::new();
9420    visited.insert(start);
9421    queue.push_back(start);
9422    while let Some(node) = queue.pop_front() {
9423        if node == target {
9424            let mut path = vec![node.to_string()];
9425            let mut cur = node;
9426            while let Some(&p) = parent.get(cur) {
9427                path.push(p.to_string());
9428                cur = p;
9429            }
9430            path.reverse();
9431            return Some(path);
9432        }
9433        for &next in adj.get(node).into_iter().flatten() {
9434            if visited.insert(next) {
9435                parent.insert(next, node);
9436                queue.push_back(next);
9437            }
9438        }
9439    }
9440    None
9441}
9442
9443impl<'a, F: Fs> MutPreview<'a, F> {
9444    fn new(db: &'a GraphDb<F>) -> Self {
9445        Self {
9446            db,
9447            overlay: Overlay::default(),
9448        }
9449    }
9450
9451    fn has_key(&self, key: &str) -> bool {
9452        if self.overlay.extra_keys.contains(key) {
9453            return true;
9454        }
9455        if self.overlay.deleted_keys.contains(key) {
9456            return false;
9457        }
9458        self.db.ids.get(key).is_some()
9459    }
9460
9461    fn has_prop(&self, key: &str, field: &str) -> bool {
9462        if !self.has_key(key) {
9463            return false;
9464        }
9465        let k = (key.to_string(), field.to_string());
9466        if self.overlay.removed_props.contains(&k) {
9467            return false;
9468        }
9469        if self.overlay.extra_props.contains_key(&k) {
9470            return true;
9471        }
9472        // Fresh identity (first insert in this batch, or delete+reinsert):
9473        // ignore props still sitting on the soon-to-be-tombstoned slot.
9474        if self.overlay.extra_keys.contains(key) {
9475            return false;
9476        }
9477        self.db.get_prop(key, field).is_some()
9478    }
9479
9480    fn has_edge(&self, edge_type: &str, src_key: &str, dst_key: &str) -> bool {
9481        let k = (
9482            edge_type.to_string(),
9483            src_key.to_string(),
9484            dst_key.to_string(),
9485        );
9486        if self.overlay.deleted_edges.contains(&k) {
9487            return false;
9488        }
9489        if self.overlay.extra_edges.contains(&k) {
9490            return true;
9491        }
9492        // A key created in this batch (including reinsert) has no db edges.
9493        if self.overlay.extra_keys.contains(src_key) || self.overlay.extra_keys.contains(dst_key) {
9494            return false;
9495        }
9496        if self.overlay.deleted_keys.contains(src_key)
9497            || self.overlay.deleted_keys.contains(dst_key)
9498        {
9499            return false;
9500        }
9501        let Some(src) = self.db.ids.get(src_key) else {
9502            return false;
9503        };
9504        let Some(dst) = self.db.ids.get(dst_key) else {
9505            return false;
9506        };
9507        let Some(sym) = self.db.syms.get(edge_type) else {
9508            return false;
9509        };
9510        self.db
9511            .topo_view()
9512            .neighbors(sym, Direction::Out, src)
9513            .binary_search(&dst)
9514            .is_ok()
9515    }
9516
9517    fn has_rule(&self, name: &str) -> bool {
9518        if self.overlay.extra_rules.contains(name) {
9519            return true;
9520        }
9521        if self.overlay.deleted_rules.contains(name) {
9522            return false;
9523        }
9524        self.db.engine.rules().any(|r| r.name == name)
9525    }
9526
9527    fn is_rule_owned(&self, edge_type: &str, src_key: &str, dst_key: &str) -> bool {
9528        if self.overlay.extra_keys.contains(src_key) || self.overlay.extra_keys.contains(dst_key) {
9529            return false;
9530        }
9531        if self.overlay.deleted_keys.contains(src_key)
9532            || self.overlay.deleted_keys.contains(dst_key)
9533        {
9534            return false;
9535        }
9536        let Some(src) = self.db.ids.get(src_key) else {
9537            return false;
9538        };
9539        let Some(dst) = self.db.ids.get(dst_key) else {
9540            return false;
9541        };
9542        let Some(et) = self.db.syms.get(edge_type) else {
9543            return false;
9544        };
9545        // extra_rules is deliberately not consulted: a CreateRule earlier in
9546        // this batch has not fired, so it contributes no provenance. That is
9547        // the documented rule-window gap (see GraphDb::batch).
9548        if self.overlay.deleted_rules.is_empty() {
9549            return self.db.engine.is_owned(et, src, dst);
9550        }
9551        for (rule, triples) in self.db.engine.provenance() {
9552            if self.overlay.deleted_rules.contains(rule) {
9553                continue;
9554            }
9555            if triples.contains(&(et, src, dst)) {
9556                return true;
9557            }
9558        }
9559        false
9560    }
9561
9562    fn check_insert_node(&self, key: &str) -> Result<()> {
9563        if self.has_key(key) {
9564            Err(GraphError::DuplicateKey { key: key.into() })
9565        } else {
9566            Ok(())
9567        }
9568    }
9569
9570    fn check_live_key(&self, key: &str) -> Result<()> {
9571        if self.has_key(key) {
9572            Ok(())
9573        } else {
9574            Err(GraphError::KeyNotFound { key: key.into() })
9575        }
9576    }
9577
9578    fn prepare_insert_edge(&self, edge_type: &str, src_key: &str, dst_key: &str) -> Result<bool> {
9579        for k in [src_key, dst_key] {
9580            if !self.has_key(k) {
9581                return Err(GraphError::KeyNotFound { key: k.into() });
9582            }
9583        }
9584        if self.is_rule_owned(edge_type, src_key, dst_key) {
9585            return Err(GraphError::RuleOwned {
9586                detail: format!("edge {edge_type} {src_key}→{dst_key} is rule-owned"),
9587            });
9588        }
9589        Ok(!self.has_edge(edge_type, src_key, dst_key))
9590    }
9591
9592    fn prepare_remove_prop(&self, key: &str, field: &str) -> Result<bool> {
9593        self.check_live_key(key)?;
9594        Ok(self.has_prop(key, field))
9595    }
9596
9597    fn prepare_delete_edge(&self, edge_type: &str, src_key: &str, dst_key: &str) -> Result<bool> {
9598        for k in [src_key, dst_key] {
9599            if !self.has_key(k) {
9600                return Err(GraphError::KeyNotFound { key: k.into() });
9601            }
9602        }
9603        // Provenance-owned OR a live rule would derive this pair. User-first
9604        // edges that a later rule matches are not in `owned`, but deleting
9605        // them would leave a hole `rebuild_rule` immediately fills.
9606        if self.is_rule_owned(edge_type, src_key, dst_key) {
9607            return Err(GraphError::RuleOwned {
9608                detail: format!(
9609                    "edge {edge_type} {src_key}→{dst_key} is rule-owned; \
9610                     delete or change the owning rule"
9611                ),
9612            });
9613        }
9614        if self.would_derive(edge_type, src_key, dst_key) {
9615            return Err(GraphError::RuleOwned {
9616                detail: format!(
9617                    "edge {edge_type} {src_key}→{dst_key} is rule-owned; \
9618                     delete or change the owning rule, or a live rule would re-derive it"
9619                ),
9620            });
9621        }
9622        Ok(self.has_edge(edge_type, src_key, dst_key))
9623    }
9624
9625    /// True if any live rule (minus overlay-deleted names) would derive
9626    /// `(edge_type, src, dst)` from current overlay-visible props/labels.
9627    /// CreateRule names in `extra_rules` are ignored — same documented
9628    /// same-batch rule-window as [`Self::is_rule_owned`].
9629    fn would_derive(&self, edge_type: &str, src_key: &str, dst_key: &str) -> bool {
9630        if src_key == dst_key {
9631            return false;
9632        }
9633        let Some(src_label) = self.label_of(src_key) else {
9634            return false;
9635        };
9636        let Some(dst_label) = self.label_of(dst_key) else {
9637            return false;
9638        };
9639        for rule in self.db.engine.rules() {
9640            if self.overlay.deleted_rules.contains(&rule.name) {
9641                continue;
9642            }
9643            if rule.edge_type != edge_type {
9644                continue;
9645            }
9646            if rule.src_label != src_label || rule.dst_label != dst_label {
9647                continue;
9648            }
9649            let src_props = |f: &str| self.prop_value(src_key, f);
9650            let dst_props = |f: &str| self.prop_value(dst_key, f);
9651            let src_view = NodeView {
9652                key: src_key,
9653                props: &src_props,
9654            };
9655            let dst_view = NodeView {
9656                key: dst_key,
9657                props: &dst_props,
9658            };
9659            if evaluate(&rule.predicate, &src_view, &dst_view).is_some() {
9660                return true;
9661            }
9662        }
9663        false
9664    }
9665
9666    fn label_of(&self, key: &str) -> Option<String> {
9667        if self.overlay.deleted_keys.contains(key) {
9668            return None;
9669        }
9670        // Fresh identities created in this batch have no stored label in the
9671        // overlay; they cannot be provenance-owned yet either.
9672        let id = self.db.ids.get(key)?;
9673        let sym = self.db.labels.get(id as usize).copied()?;
9674        if sym == u32::MAX {
9675            return None;
9676        }
9677        self.db.syms.resolve(sym).map(str::to_string)
9678    }
9679
9680    fn prop_value(&self, key: &str, field: &str) -> Option<Value> {
9681        if !self.has_key(key) {
9682            return None;
9683        }
9684        let k = (key.to_string(), field.to_string());
9685        if self.overlay.removed_props.contains(&k) {
9686            return None;
9687        }
9688        if let Some(v) = self.overlay.extra_props.get(&k) {
9689            return Some(v.clone());
9690        }
9691        if self.overlay.extra_keys.contains(key) {
9692            return None;
9693        }
9694        self.db.get_prop(key, field)
9695    }
9696
9697    fn check_create_rule(&self, def: &RuleDef) -> Result<()> {
9698        def.validate()
9699            .map_err(|e| GraphError::RuleInvalid { detail: e })?;
9700        if self.has_rule(&def.name) {
9701            return Err(GraphError::RuleInvalid {
9702                detail: format!("rule {:?} already exists", def.name),
9703            });
9704        }
9705        // Rule-chain cycle rejection. Derived edges feed via-hop rules, so a
9706        // rule set forms a graph whose arcs are "hops over `via_edge`, writes
9707        // `edge_type`". A cycle in that graph is a rule set that would re-fire
9708        // itself forever; the engine's depth cap would silently truncate it
9709        // instead, leaving an arbitrary partial result. Reject it here, the one
9710        // place that sees the whole rule set.
9711        //
9712        // Rules accepted earlier in the same batch count too: the overlay
9713        // carries their arcs, so a cycle cannot be assembled one op at a time.
9714        if let Some(via) = def.via_edge.as_deref() {
9715            if via == def.edge_type {
9716                return Err(GraphError::RuleInvalid {
9717                    detail: format!("rule chain cycle: {} -> {}", via, def.edge_type),
9718                });
9719            }
9720            let mut arcs: Vec<(String, String)> = self
9721                .db
9722                .engine
9723                .rules()
9724                .filter(|r| !self.overlay.deleted_rules.contains(&r.name))
9725                .filter_map(|r| r.via_edge.clone().map(|v| (v, r.edge_type.clone())))
9726                .collect();
9727            arcs.extend(self.overlay.extra_rule_arcs.values().cloned());
9728            arcs.push((via.to_string(), def.edge_type.clone()));
9729            if let Some(path) = find_cycle_through(&arcs, &def.edge_type, via) {
9730                return Err(GraphError::RuleInvalid {
9731                    detail: format!("rule chain cycle: {} -> {}", via, path.join(" -> ")),
9732                });
9733            }
9734        }
9735        Ok(())
9736    }
9737
9738    fn check_delete_rule(&self, name: &str) -> Result<()> {
9739        if self.has_rule(name) {
9740            Ok(())
9741        } else {
9742            Err(GraphError::RuleNotFound { name: name.into() })
9743        }
9744    }
9745
9746    fn note_insert_node(&mut self, key: &str, props: &[(String, Value)]) {
9747        self.overlay.deleted_keys.remove(key);
9748        self.overlay.extra_keys.insert(key.to_string());
9749        self.overlay.extra_props.retain(|(k, _), _| k != key);
9750        self.overlay.removed_props.retain(|(k, _)| k != key);
9751        for (field, value) in props {
9752            self.overlay
9753                .extra_props
9754                .insert((key.to_string(), field.clone()), value.clone());
9755        }
9756    }
9757
9758    fn note_insert_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) {
9759        let k = (
9760            edge_type.to_string(),
9761            src_key.to_string(),
9762            dst_key.to_string(),
9763        );
9764        self.overlay.deleted_edges.remove(&k);
9765        self.overlay.extra_edges.insert(k);
9766    }
9767
9768    fn note_set_prop(&mut self, key: &str, field: &str, value: &Value) {
9769        let k = (key.to_string(), field.to_string());
9770        self.overlay.removed_props.remove(&k);
9771        self.overlay.extra_props.insert(k, value.clone());
9772    }
9773
9774    fn note_remove_prop(&mut self, key: &str, field: &str) {
9775        let k = (key.to_string(), field.to_string());
9776        self.overlay.extra_props.remove(&k);
9777        self.overlay.removed_props.insert(k);
9778    }
9779
9780    fn note_delete_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) {
9781        let k = (
9782            edge_type.to_string(),
9783            src_key.to_string(),
9784            dst_key.to_string(),
9785        );
9786        self.overlay.extra_edges.remove(&k);
9787        self.overlay.deleted_edges.insert(k);
9788    }
9789
9790    fn note_delete_node(&mut self, key: &str) {
9791        self.overlay.extra_keys.remove(key);
9792        self.overlay.deleted_keys.insert(key.to_string());
9793        self.overlay.extra_props.retain(|(k, _), _| k != key);
9794        self.overlay.removed_props.retain(|(k, _)| k != key);
9795        self.overlay
9796            .extra_edges
9797            .retain(|(_, s, d)| s != key && d != key);
9798        self.overlay
9799            .deleted_edges
9800            .retain(|(_, s, d)| s != key && d != key);
9801    }
9802
9803    fn note_create_rule(&mut self, def: &RuleDef) {
9804        self.overlay.deleted_rules.remove(&def.name);
9805        self.overlay.extra_rules.insert(def.name.clone());
9806        // Rules accepted earlier in this batch are not in the engine yet, so
9807        // the cycle check would not see their arcs. Keep the arc, not just the
9808        // name, so a batch cannot smuggle in a cycle one op at a time.
9809        if let Some(via) = def.via_edge.clone() {
9810            self.overlay
9811                .extra_rule_arcs
9812                .insert(def.name.clone(), (via, def.edge_type.clone()));
9813        }
9814    }
9815
9816    fn check_rename_node(&self, old: &str, new: &str) -> Result<()> {
9817        if !self.has_key(old) {
9818            return Err(GraphError::KeyNotFound { key: old.into() });
9819        }
9820        if self.has_key(new) {
9821            return Err(GraphError::DuplicateKey { key: new.into() });
9822        }
9823        Ok(())
9824    }
9825
9826    fn note_rename_node(&mut self, old: &str, new: &str) {
9827        // Mark old as deleted so subsequent batch ops cannot reference it.
9828        self.overlay.extra_keys.remove(old);
9829        self.overlay.deleted_keys.insert(old.to_string());
9830        // Mark new as extra so subsequent batch ops can reference it.
9831        self.overlay.deleted_keys.remove(new);
9832        self.overlay.extra_keys.insert(new.to_string());
9833        // Migrate any overlay props from old key to new key.
9834        let new_str = new.to_string();
9835        let transferred: Vec<((String, String), Value)> = self
9836            .overlay
9837            .extra_props
9838            .iter()
9839            .filter(|((k, _), _)| k.as_str() == old)
9840            .map(|((_, f), v)| ((new_str.clone(), f.clone()), v.clone()))
9841            .collect();
9842        self.overlay
9843            .extra_props
9844            .retain(|(k, _), _| k.as_str() != old);
9845        for (k, v) in transferred {
9846            self.overlay.extra_props.insert(k, v);
9847        }
9848        // Migrate removed_props.
9849        let transferred_removed: Vec<(String, String)> = self
9850            .overlay
9851            .removed_props
9852            .iter()
9853            .filter(|(k, _)| k.as_str() == old)
9854            .map(|(_, f)| (new_str.clone(), f.clone()))
9855            .collect();
9856        self.overlay
9857            .removed_props
9858            .retain(|(k, _)| k.as_str() != old);
9859        for k in transferred_removed {
9860            self.overlay.removed_props.insert(k);
9861        }
9862    }
9863
9864    fn note_delete_rule(&mut self, name: &str) {
9865        self.overlay.extra_rules.remove(name);
9866        // Drop its chain arc too: a rule created and then deleted in the same
9867        // batch must not make a later, legal rule look like a cycle.
9868        self.overlay.extra_rule_arcs.remove(name);
9869        self.overlay.deleted_rules.insert(name.to_string());
9870        // Treat the deleted rule's current provenance as gone so a later
9871        // delete_edge of those triples is a no-op (matches sequential).
9872        if let Some(triples) = self.db.engine.provenance().get(name) {
9873            for &(et, s, d) in triples {
9874                let Some(etype) = self.db.syms.resolve(et) else {
9875                    continue;
9876                };
9877                let Some(src) = self.db.ids.key_of(s) else {
9878                    continue;
9879                };
9880                let Some(dst) = self.db.ids.key_of(d) else {
9881                    continue;
9882                };
9883                let k = (etype.to_string(), src.to_string(), dst.to_string());
9884                self.overlay.extra_edges.remove(&k);
9885                self.overlay.deleted_edges.insert(k);
9886            }
9887        }
9888    }
9889}
9890
9891/// Collects mutations and commits them as one WAL `Batch` frame.
9892///
9893/// Holds `&mut GraphDb` for its lifetime. Queue with the same method names
9894/// as [`GraphDb`]; call [`commit`](Self::commit) to validate, log, and apply.
9895/// See [`GraphDb::batch`] for validation and atomicity rules.
9896pub struct BatchBuilder<'a, F: Fs> {
9897    db: &'a mut GraphDb<F>,
9898    ops: Vec<BatchOp>,
9899}
9900
9901impl<'a, F: Fs> BatchBuilder<'a, F> {
9902    pub fn insert_node(
9903        &mut self,
9904        label: &str,
9905        key: &str,
9906        props: Vec<(String, Value)>,
9907    ) -> &mut Self {
9908        self.ops.push(BatchOp::InsertNode {
9909            label: label.into(),
9910            key: key.into(),
9911            props,
9912        });
9913        self
9914    }
9915
9916    pub fn insert_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) -> &mut Self {
9917        self.ops.push(BatchOp::InsertEdge {
9918            edge_type: edge_type.into(),
9919            src_key: src_key.into(),
9920            dst_key: dst_key.into(),
9921        });
9922        self
9923    }
9924
9925    pub fn set_prop(&mut self, key: &str, field: &str, value: Value) -> &mut Self {
9926        self.ops.push(BatchOp::SetProp {
9927            key: key.into(),
9928            field: field.into(),
9929            value,
9930        });
9931        self
9932    }
9933
9934    pub fn remove_prop(&mut self, key: &str, field: &str) -> &mut Self {
9935        self.ops.push(BatchOp::RemoveProp {
9936            key: key.into(),
9937            field: field.into(),
9938        });
9939        self
9940    }
9941
9942    pub fn delete_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) -> &mut Self {
9943        self.ops.push(BatchOp::DeleteEdge {
9944            edge_type: edge_type.into(),
9945            src_key: src_key.into(),
9946            dst_key: dst_key.into(),
9947        });
9948        self
9949    }
9950
9951    pub fn delete_node(&mut self, key: &str) -> &mut Self {
9952        self.ops.push(BatchOp::DeleteNode { key: key.into() });
9953        self
9954    }
9955
9956    pub fn create_rule(&mut self, def: RuleDef) -> &mut Self {
9957        self.ops.push(BatchOp::CreateRule(def));
9958        self
9959    }
9960
9961    pub fn delete_rule(&mut self, name: &str) -> &mut Self {
9962        self.ops.push(BatchOp::DeleteRule { name: name.into() });
9963        self
9964    }
9965
9966    /// Queue a node-rename in this batch.
9967    ///
9968    /// Validation (old exists, new not taken) runs at commit time.
9969    pub fn rename_node(&mut self, old_key: &str, new_key: &str) -> &mut Self {
9970        self.ops.push(BatchOp::RenameNode {
9971            old_key: old_key.into(),
9972            new_key: new_key.into(),
9973        });
9974        self
9975    }
9976
9977    /// Queue an edge insert with endpoint auto-creation.
9978    ///
9979    /// Any missing endpoint is created as a plain node `{key, label:
9980    /// placeholder_label, no props}` inside this batch frame. Rules fire and
9981    /// last-change is updated for each auto-created node.
9982    pub fn insert_edge_upsert(
9983        &mut self,
9984        edge_type: &str,
9985        src_key: &str,
9986        dst_key: &str,
9987        placeholder_label: &str,
9988    ) -> &mut Self {
9989        self.ops.push(BatchOp::InsertEdgeUpsert {
9990            edge_type: edge_type.into(),
9991            src_key: src_key.into(),
9992            dst_key: dst_key.into(),
9993            placeholder_label: placeholder_label.into(),
9994        });
9995        self
9996    }
9997
9998    /// Validate every queued op, then log one `Batch` frame and apply.
9999    /// Empty / all-noop batches return `Ok(())` without writing the WAL.
10000    /// A second `commit()` after a successful one is an empty-batch no-op
10001    /// (queued ops were taken).
10002    /// Takes `&mut self` so it chains after the queue methods (`b.insert_node(..).commit()`)
10003    /// and also works as `let mut b = db.batch(); b.insert_node(..); b.commit()`.
10004    ///
10005    /// **Rule-window limitation:** batch validation cannot see edges that a
10006    /// rule created earlier in the *same* batch will derive at apply time, so
10007    /// a `delete_edge` / `insert_edge` in that window is silently no-oped
10008    /// where sequential calls would return `Err(RuleOwned)`. State integrity
10009    /// is unaffected (idempotent apply, provenance intact). Create rules in
10010    /// their own batch, or sequentially, when later ops may touch derived
10011    /// edges.
10012    /// Validate every queued op and commit atomically.
10013    ///
10014    /// Returns `(nodes_inserted, edges_inserted)` — the counts of node and edge
10015    /// WAL records actually written (duplicate edges are silent no-ops and are
10016    /// NOT counted). Both are 0 when the batch is empty or all-noop.
10017    pub fn commit(&mut self) -> Result<(usize, usize)> {
10018        let ops = std::mem::take(&mut self.ops);
10019        self.db.commit_batch(ops)
10020    }
10021
10022    /// Same as [`commit`](Self::commit) but tail the inner events with
10023    /// [`MutationEvent::Ingested`] instead of [`MutationEvent::BatchApplied`].
10024    pub(crate) fn commit_ingest(&mut self, label: &str, inserted: usize) -> Result<(usize, usize)> {
10025        let ops = std::mem::take(&mut self.ops);
10026        self.db
10027            .commit_logged_batch(ops, Some((label.to_string(), inserted)), None)
10028    }
10029}
10030
10031pub struct NodeRef<'a, F: Fs> {
10032    db: &'a GraphDb<F>,
10033    id: u32,
10034}
10035
10036impl<'a, F: Fs> NodeRef<'a, F> {
10037    pub fn key(&self) -> &str {
10038        self.db.ids.key_of(self.id).expect("dense ids")
10039    }
10040
10041    pub fn label(&self) -> &str {
10042        let sym = self
10043            .db
10044            .labels
10045            .get(self.id as usize)
10046            .copied()
10047            .filter(|&s| s != u32::MAX)
10048            .expect("real nodes always have a label; u32::MAX sentinel cannot occur");
10049        self.db.syms.resolve(sym).expect("interned label symbol")
10050    }
10051
10052    pub fn prop(&self, field: &str) -> Option<Value> {
10053        self.db
10054            .props_view()
10055            .get(self.id, field)
10056            .map(|vr| vr.into_value())
10057    }
10058
10059    /// All stored fields for this node, sorted by field name.
10060    ///
10061    /// Reads from the full base+overlay view so that props stored only in the
10062    /// V8 snapshot base (i.e. before any post-snapshot WAL writes) are visible.
10063    pub fn props(&self) -> BTreeMap<String, Value> {
10064        let mut out = BTreeMap::new();
10065        let pv = self.db.props_view();
10066        for field in pv.field_names() {
10067            if let Some(vr) = pv.get(self.id, &field) {
10068                out.insert(field, vr.into_value());
10069            }
10070        }
10071        out
10072    }
10073
10074    /// depth-N BFS as a ResultSet: columns ["key","label","depth"], BFS order.
10075    pub fn neighborhood(&self, depth: u32, edge_types: Option<&[&str]>, dir: Dir) -> ResultSet {
10076        let view = self.db.view();
10077        let resolved: Option<Vec<u32>> = edge_types.map(|names| {
10078            names
10079                .iter()
10080                .filter_map(|name| view.syms.get(name))
10081                .collect()
10082        });
10083        let nb = neighborhood(&view, self.id, depth, resolved.as_deref(), dir);
10084        let mut rs = ResultSet::new(vec!["key".into(), "label".into(), "depth".into()]);
10085        for (nid, d) in nb.nodes {
10086            let key = view.key_of(nid);
10087            let label = view
10088                .label_of(nid)
10089                .expect("real nodes always have a label; u32::MAX sentinel cannot occur");
10090            rs.push_row(vec![
10091                Some(Value::Str(key.to_string())),
10092                Some(Value::Str(label.to_string())),
10093                Some(Value::Int(d as i64)),
10094            ]);
10095        }
10096        rs
10097    }
10098
10099    /// 1-hop, Both directions: edge-type name → sorted unique neighbor keys.
10100    pub fn grouped_by_edge_type(&self) -> BTreeMap<String, Vec<String>> {
10101        let view = self.db.view();
10102        let mut groups: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
10103        for e in expand(&view, self.id, None, Dir::Both) {
10104            // Skip edges with unknown etypes (only possible from corrupt large
10105            // TOPOLOGY section; function returns BTreeMap not Result).
10106            let Some(etype) = view.syms.resolve(e.etype) else {
10107                continue;
10108            };
10109            let etype = etype.to_string();
10110            let nbr = if e.src == self.id { e.dst } else { e.src };
10111            groups
10112                .entry(etype)
10113                .or_default()
10114                .insert(view.key_of(nbr).to_string());
10115        }
10116        groups
10117            .into_iter()
10118            .map(|(k, v)| (k, v.into_iter().collect()))
10119            .collect()
10120    }
10121}
10122
10123#[cfg(test)]
10124mod tests {
10125    use super::*;
10126    use core_rules::Predicate;
10127
10128    fn tmp_dir(name: &str) -> std::path::PathBuf {
10129        let d =
10130            std::env::temp_dir().join(format!("graphdb-db-unit-{}-{}", name, std::process::id()));
10131        let _ = std::fs::remove_dir_all(&d);
10132        d
10133    }
10134
10135    fn fk_rule() -> RuleDef {
10136        RuleDef {
10137            name: "works_at".into(),
10138            src_label: "Person".into(),
10139            dst_label: "Org".into(),
10140            predicate: Predicate::KeyMatch {
10141                field: "org_id".into(),
10142            },
10143            edge_type: "WORKS_AT".into(),
10144            weight_prop: None,
10145            max_edges: None,
10146            approximate: false,
10147            via_label: None,
10148            via_edge: None,
10149            via_dir: None,
10150        }
10151    }
10152
10153    /// Regression guard for the no-views delta-copy fast path.
10154    ///
10155    /// When no views are defined, `pending_deltas_since().to_vec()` must never
10156    /// be called — even during a large CreateRule backfill. The DELTA_COPY_COUNT
10157    /// thread-local is incremented inside every `if !view_store.is_empty()` block;
10158    /// a count of 0 after the entire sequence proves the guard fires correctly.
10159    #[test]
10160    fn no_delta_copy_when_no_views() {
10161        DELTA_COPY_COUNT.with(|c| c.set(0));
10162        let dir = tmp_dir("no-delta-copy");
10163        {
10164            let mut db = GraphDb::open(&dir).unwrap();
10165            // Insert 50 Org + 50 Person nodes with FK links.
10166            for i in 0..50u32 {
10167                db.insert_node("Org", &format!("o{i}"), vec![]).unwrap();
10168            }
10169            for i in 0..50u32 {
10170                db.insert_node(
10171                    "Person",
10172                    &format!("p{i}"),
10173                    vec![("org_id".into(), Value::Str(format!("o{i}")))],
10174                )
10175                .unwrap();
10176            }
10177            // CreateRule backfill should NOT invoke to_vec() when no views are defined.
10178            db.create_rule(fk_rule()).unwrap();
10179
10180            // Counter must stay 0 — no views, no copies.
10181            let copies = DELTA_COPY_COUNT.with(|c| c.get());
10182            assert_eq!(
10183                copies, 0,
10184                "pending_deltas_since().to_vec() called despite no views"
10185            );
10186
10187            // Derived edges must still be correct (the guard skips only the
10188            // empty delta propagation loop, not the rule application itself).
10189            let nbrs = db.neighbors("p0", "WORKS_AT", Direction::Out).unwrap();
10190            assert_eq!(
10191                nbrs,
10192                vec!["o0"],
10193                "rule must derive edges even with no views"
10194            );
10195        }
10196        let _ = std::fs::remove_dir_all(&dir);
10197    }
10198
10199    /// Gating regression: subscribe AFTER a backfill must see no stale events.
10200    /// subscribe BEFORE a backfill must see every edge-fire event.
10201    #[test]
10202    fn subscribe_after_backfill_no_stale_events() {
10203        let dir = tmp_dir("sub-after-backfill");
10204        {
10205            let mut db = GraphDb::open(&dir).unwrap();
10206            for i in 0..10u32 {
10207                db.insert_node("Org", &format!("o{i}"), vec![]).unwrap();
10208                db.insert_node(
10209                    "Person",
10210                    &format!("p{i}"),
10211                    vec![("org_id".into(), Value::Str(format!("o{i}")))],
10212                )
10213                .unwrap();
10214            }
10215            // Create rule BEFORE subscribing — emit_deltas is false during backfill.
10216            db.create_rule(fk_rule()).unwrap();
10217
10218            // Subscribe AFTER the backfill — queue must be empty (no stale events).
10219            let sub = db.subscribe_all_rules().unwrap();
10220            // No events should have queued for the prior backfill.
10221            assert!(
10222                sub.try_recv().is_none(),
10223                "subscribe after backfill must see no stale events"
10224            );
10225
10226            // Inserting a new node now should fire an event (emit_deltas is now true).
10227            db.insert_node("Org", "o_new", vec![]).unwrap();
10228            db.insert_node(
10229                "Person",
10230                "p_new",
10231                vec![("org_id".into(), Value::Str("o_new".into()))],
10232            )
10233            .unwrap();
10234            let ev = sub.recv_timeout(std::time::Duration::from_millis(200));
10235            assert!(
10236                ev.is_some(),
10237                "edge-fire event must arrive after subscribe (emit_deltas=true)"
10238            );
10239        }
10240        let _ = std::fs::remove_dir_all(&dir);
10241    }
10242
10243    /// Gating regression: subscribe BEFORE a backfill → events flow.
10244    #[test]
10245    fn subscribe_before_backfill_events_flow() {
10246        let dir = tmp_dir("sub-before-backfill");
10247        {
10248            let mut db = GraphDb::open(&dir).unwrap();
10249            // Subscribe FIRST — emit_deltas becomes true.
10250            let sub = db.subscribe_all_rules().unwrap();
10251
10252            for i in 0..5u32 {
10253                db.insert_node("Org", &format!("o{i}"), vec![]).unwrap();
10254                db.insert_node(
10255                    "Person",
10256                    &format!("p{i}"),
10257                    vec![("org_id".into(), Value::Str(format!("o{i}")))],
10258                )
10259                .unwrap();
10260            }
10261            // Backfill fires with emit_deltas=true → events queued.
10262            db.create_rule(fk_rule()).unwrap();
10263
10264            // Should receive at least one edge-fired event from the backfill.
10265            let mut received = 0usize;
10266            while sub.try_recv().is_some() {
10267                received += 1;
10268            }
10269            assert!(
10270                received > 0,
10271                "subscribe before backfill must receive edge-fire events (got 0)"
10272            );
10273        }
10274        let _ = std::fs::remove_dir_all(&dir);
10275    }
10276
10277    /// Companion: when a view IS defined, the delta path fires and view values update.
10278    #[test]
10279    fn delta_copy_fires_when_view_exists() {
10280        use core_rules::ViewSource;
10281        DELTA_COPY_COUNT.with(|c| c.set(0));
10282        let dir = tmp_dir("delta-copy-with-view");
10283        {
10284            let mut db = GraphDb::open(&dir).unwrap();
10285            db.insert_node("Org", "o1", vec![]).unwrap();
10286            db.insert_node(
10287                "Person",
10288                "p1",
10289                vec![("org_id".into(), Value::Str("o1".into()))],
10290            )
10291            .unwrap();
10292            // Declare a Degree view so is_empty() returns false.
10293            db.create_view(ViewDef {
10294                name: "degree_out".into(),
10295                label: "Person".into(),
10296                view_prop: "degree_out".into(),
10297                source: ViewSource::Degree {
10298                    edge_type: "WORKS_AT".into(),
10299                    direction: Direction::Out,
10300                },
10301            })
10302            .unwrap();
10303            db.create_rule(fk_rule()).unwrap();
10304
10305            // At least one delta copy should have happened (CreateRule backfill).
10306            let copies = DELTA_COPY_COUNT.with(|c| c.get());
10307            assert!(
10308                copies > 0,
10309                "expected delta copy to fire when a view is defined"
10310            );
10311
10312            // View value should be computed: p1 has one WORKS_AT out-edge.
10313            let info = db.node_info("p1").unwrap();
10314            let degree = info.props.get("degree_out");
10315            assert!(
10316                degree.is_some(),
10317                "view prop should be written to node props"
10318            );
10319        }
10320        let _ = std::fs::remove_dir_all(&dir);
10321    }
10322
10323    /// Regression: `open_at_with` must call `rebuild_all` after WAL replay so
10324    /// derived-edge-driven view values reflect the as-of state rather than just
10325    /// the initial backfill written at `CreateView` time.
10326    ///
10327    /// Base WAL frames (indices 0..=5 before history markers):
10328    ///   0: insert Org "o1"
10329    ///   1: create_view "employee_count" (Degree / WORKS_AT / In) on Org
10330    ///   2: create_rule fk_rule (WORKS_AT, Person→Org via org_id)
10331    ///   3: insert Person "p1" → rule fires WORKS_AT p1→o1 (degree = 1)  ← mid
10332    ///   4: insert Person "p2" → rule fires WORKS_AT p2→o1 (degree = 2)
10333    ///   5: insert Person "p3" → rule fires WORKS_AT p3→o1 (degree = 3)  ← latest
10334    ///
10335    /// Each rule-fire also appends a DerivedEdgeAdded history-marker frame (state
10336    /// no-op), so the total commit count is higher than the base frame count.
10337    /// The "latest" open_at commit is computed dynamically via `wal_commit_count_at`.
10338    ///
10339    /// Without `rebuild_all`, the as-of instance's "emp" view stays at the
10340    /// initial backfill value (0) instead of reflecting the replayed derived edges.
10341    #[test]
10342    fn open_at_derived_edge_view_values_correct() {
10343        use core_rules::ViewSource;
10344        let dir = tmp_dir("open-at-view-rebuild");
10345        {
10346            let mut db = GraphDb::open(&dir).unwrap();
10347            // frame 0
10348            db.insert_node("Org", "o1", vec![]).unwrap();
10349            // frame 1: create view — initial backfill sees 0 derived edges (none fired yet)
10350            db.create_view(ViewDef {
10351                name: "employee_count".into(),
10352                label: "Org".into(),
10353                view_prop: "emp".into(),
10354                source: ViewSource::Degree {
10355                    edge_type: "WORKS_AT".into(),
10356                    direction: Direction::In,
10357                },
10358            })
10359            .unwrap();
10360            // frame 2: create rule — no Persons yet; backfill is a no-op
10361            db.create_rule(fk_rule()).unwrap();
10362            // frame 3: p1 — rule fires WORKS_AT p1→o1; degree = 1
10363            db.insert_node(
10364                "Person",
10365                "p1",
10366                vec![("org_id".into(), Value::Str("o1".into()))],
10367            )
10368            .unwrap();
10369            // frame 4: p2 — degree = 2
10370            db.insert_node(
10371                "Person",
10372                "p2",
10373                vec![("org_id".into(), Value::Str("o1".into()))],
10374            )
10375            .unwrap();
10376            // frame 5: p3 — degree = 3
10377            db.insert_node(
10378                "Person",
10379                "p3",
10380                vec![("org_id".into(), Value::Str("o1".into()))],
10381            )
10382            .unwrap();
10383            // Sanity: normal open sees degree = 3.
10384            assert_eq!(
10385                db.get_view_prop("o1", "emp"),
10386                Some(Value::Int(3)),
10387                "normal db must show degree 3 after 3 derived edges"
10388            );
10389        } // WAL flushed
10390
10391        // Re-open normally to get the authoritative reference value.
10392        let normal_db = GraphDb::open(&dir).unwrap();
10393        let normal_emp = normal_db.get_view_prop("o1", "emp");
10394        assert_eq!(
10395            normal_emp,
10396            Some(Value::Int(3)),
10397            "re-opened normal db must show degree 3"
10398        );
10399
10400        // Latest as-of (last WAL commit): must match the normal open.
10401        // History-marker frames are appended after each rule-fire, so the total
10402        // commit count is computed dynamically rather than hardcoded.
10403        let total = crate::wal_commit_count_at(&dir).unwrap();
10404        let aof_latest = GraphDb::open_at(&dir, total - 1).unwrap();
10405        assert_eq!(
10406            aof_latest.get_view_prop("o1", "emp"),
10407            normal_emp,
10408            "open_at latest: derived-edge view must equal normal open (rebuild_all required)"
10409        );
10410
10411        // Mid-history as-of (commit 3 = p1 insert Batch frame): only p1; degree = 1.
10412        // The DerivedEdgeAdded marker for p1 is at frame 4 (state no-op on replay),
10413        // so replaying 0..=3 correctly re-derives only the p1→o1 edge.
10414        let aof_mid = GraphDb::open_at(&dir, 3).unwrap();
10415        assert_eq!(
10416            aof_mid.get_view_prop("o1", "emp"),
10417            Some(Value::Int(1)),
10418            "open_at mid-history: only p1 exists at frame 3, degree must be 1"
10419        );
10420
10421        let _ = std::fs::remove_dir_all(&dir);
10422    }
10423
10424    /// Pin: subscribe_* on an as-of instance must return Err(ReadOnly) —
10425    /// as-of instances never commit, so distribute_events never runs and any
10426    /// subscription would wait forever.
10427    #[test]
10428    fn subscribe_on_as_of_returns_read_only_error() {
10429        let dir = tmp_dir("sub-as-of-read-only");
10430        {
10431            let mut db = GraphDb::open(&dir).unwrap();
10432            db.insert_node("Org", "o1", vec![]).unwrap();
10433            db.create_rule(fk_rule()).unwrap();
10434        }
10435        let mut aof = GraphDb::open_at(&dir, 0).unwrap();
10436
10437        assert!(
10438            matches!(
10439                aof.subscribe_all_rules(),
10440                Err(core_storage::GraphError::ReadOnly)
10441            ),
10442            "subscribe_all_rules on as-of must return ReadOnly"
10443        );
10444        assert!(
10445            matches!(
10446                aof.subscribe_writes(),
10447                Err(core_storage::GraphError::ReadOnly)
10448            ),
10449            "subscribe_writes on as-of must return ReadOnly"
10450        );
10451        assert!(
10452            matches!(
10453                aof.subscribe_rule("works_at"),
10454                Err(core_storage::GraphError::ReadOnly)
10455            ),
10456            "subscribe_rule on as-of must return ReadOnly"
10457        );
10458        let _ = std::fs::remove_dir_all(&dir);
10459    }
10460
10461    /// Regression: a failed dense WAL rewrite must not leave speculative
10462    /// interns in `syms`. If it does, the next successful mutation logs an
10463    /// `Intern` record with an inflated id; replay (which never saw the
10464    /// orphans) assigns a smaller id and the WAL becomes unreplayable.
10465    #[test]
10466    fn dense_rewrite_error_rolls_back_speculative_interns() {
10467        let dir = tmp_dir("dense-rewrite-rollback");
10468        {
10469            let mut db = GraphDb::open(&dir).unwrap();
10470            db.insert_node("Person", "a", vec![]).unwrap();
10471
10472            // Bypass MutPreview validation to hit the rewrite's own error path
10473            // (same shape as an id-exhaustion failure mid-rewrite). The
10474            // InsertEdge arm interns the edge type before it resolves keys.
10475            let err = db.rewrite_wal_dense(vec![WalRecord::InsertEdge {
10476                edge_type: "ORPHAN_TYPE".into(),
10477                src_key: "missing".into(),
10478                dst_key: "a".into(),
10479            }]);
10480            assert!(err.is_err(), "rewrite of a missing src key must fail");
10481            assert_eq!(
10482                db.syms.get("ORPHAN_TYPE"),
10483                None,
10484                "failed rewrite must roll back speculative interns"
10485            );
10486
10487            // A later successful mutation must produce a replayable WAL.
10488            db.set_prop("a", "later_field", Value::Int(2)).unwrap();
10489        }
10490        let db = GraphDb::open(&dir).expect("WAL must replay after failed rewrite");
10491        assert_eq!(db.get_prop("a", "later_field"), Some(Value::Int(2)));
10492        let _ = std::fs::remove_dir_all(&dir);
10493    }
10494}