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///
549/// Does not derive `Eq`/`Ord`: `weight` is an `f64` and NaN breaks a total
550/// order. Callers that need a stable edge ordering already sort by
551/// `(edge_type, src, dst)` explicitly (see `all_edges_for_export`).
552#[derive(Debug, Clone, PartialEq, PartialOrd)]
553pub struct ExportEdge {
554    pub edge_type: String,
555    pub src: String,
556    pub dst: String,
557    pub derived: bool,
558    /// Rule name that created this edge, if derived. `None` for manual edges.
559    pub rule: Option<String>,
560    /// The creating rule's declared `weight_prop`, read off this edge, when
561    /// derived and numeric (`Int`/`Float`). `None` for manual edges, derived
562    /// edges whose rule declares no `weight_prop`, or a non-numeric value.
563    pub weight: Option<f64>,
564}
565
566/// Construct the standard write-query result set (columns: created, properties_set, deleted).
567fn write_result_set() -> ResultSet {
568    ResultSet::new(vec![
569        "created".into(),
570        "properties_set".into(),
571        "deleted".into(),
572    ])
573}
574
575fn resolve_merge_set_value(op: &Operand, params: &BTreeMap<String, Value>) -> Result<Value> {
576    match op {
577        Operand::Lit(v) => Ok(v.clone()),
578        Operand::Param(name) => params
579            .get(name)
580            .cloned()
581            .ok_or_else(|| GraphError::QueryError {
582                detail: format!("missing parameter `{name}`"),
583            }),
584        _ => Err(GraphError::QueryError {
585            detail: "ON CREATE/ON MATCH SET value must be a literal or $parameter".into(),
586        }),
587    }
588}
589
590fn operand_node_vars(op: &Operand, out: &mut Vec<String>) {
591    match op {
592        Operand::Prop { var, .. } | Operand::Var(var) => {
593            if !out.contains(var) {
594                out.push(var.clone());
595            }
596        }
597        Operand::FuncCall { args, .. } => {
598            for arg in args {
599                operand_node_vars(arg, out);
600            }
601        }
602        Operand::BinArith { left, right, .. } => {
603            operand_node_vars(left, out);
604            operand_node_vars(right, out);
605        }
606        Operand::Case { branches, default } => {
607            // Branch conditions reference vars already bound (and mask-filtered)
608            // by the MATCH phase, so collecting from the value operands + ELSE
609            // is sufficient for RETURN-projection var discovery.
610            for (_, value) in branches {
611                operand_node_vars(value, out);
612            }
613            if let Some(d) = default {
614                operand_node_vars(d, out);
615            }
616        }
617        Operand::Lit(_) | Operand::Param(_) => {}
618    }
619}
620
621fn ret_node_vars(items: &[RetItem]) -> Vec<String> {
622    let mut out = Vec::new();
623    for item in items {
624        match &item.value {
625            RetVal::Var(v) | RetVal::Prop { var: v, .. } => {
626                if !out.contains(v) {
627                    out.push(v.clone());
628                }
629            }
630            RetVal::FuncCall { args, .. } => {
631                for arg in args {
632                    operand_node_vars(arg, &mut out);
633                }
634            }
635            RetVal::ScalarExpr(op) => operand_node_vars(op, &mut out),
636            RetVal::Agg { .. } => {}
637        }
638    }
639    out
640}
641
642fn add_var(out: &mut Vec<String>, v: &str) {
643    if !out.iter().any(|x| x == v) {
644        out.push(v.to_string());
645    }
646}
647
648fn pattern_node_vars(pats: &[Pattern]) -> Vec<String> {
649    let mut out = Vec::new();
650    for p in pats {
651        if let Some(v) = &p.start.var {
652            add_var(&mut out, v);
653        }
654        for (_, dest) in &p.chain {
655            if let Some(v) = &dest.var {
656                add_var(&mut out, v);
657            }
658        }
659    }
660    out
661}
662
663fn pattern_rel_vars(pats: &[Pattern]) -> Vec<String> {
664    let mut out = Vec::new();
665    for p in pats {
666        for (rel, _) in &p.chain {
667            if rel.hops.is_none() {
668                if let Some(v) = &rel.var {
669                    add_var(&mut out, v);
670                }
671            }
672        }
673    }
674    out
675}
676
677fn rel_type_alias(var: &str) -> String {
678    format!("__rt_{var}")
679}
680
681fn ret_column_name(item: &RetItem) -> String {
682    if let Some(alias) = &item.alias {
683        return alias.clone();
684    }
685    match &item.value {
686        RetVal::Var(v) => v.clone(),
687        RetVal::Prop { var, field } => format!("{var}.{field}"),
688        RetVal::FuncCall { name, args } => {
689            let arg_strs: Vec<String> = args
690                .iter()
691                .map(|a| match a {
692                    Operand::Var(v) => v.clone(),
693                    Operand::Prop { var, field } => format!("{var}.{field}"),
694                    Operand::Lit(_) => "<lit>".to_string(),
695                    Operand::Param(p) => format!("${p}"),
696                    Operand::FuncCall { name: n, .. } => format!("{n}(...)"),
697                    Operand::BinArith { .. } => "<arith>".to_string(),
698                    Operand::Case { .. } => "<case>".to_string(),
699                })
700                .collect();
701            format!("{name}({})", arg_strs.join(", "))
702        }
703        RetVal::ScalarExpr(_) => "<expr>".to_string(),
704        RetVal::Agg { .. } => "<agg>".to_string(),
705    }
706}
707
708fn eval_set_return_operand<F: Fs>(
709    db: &GraphDb<F>,
710    match_rs: &ResultSet,
711    row: usize,
712    rel_vars: &[String],
713    op: &Operand,
714    params: &BTreeMap<String, Value>,
715) -> Result<Option<Value>> {
716    match op {
717        Operand::Lit(v) => Ok(Some(v.clone())),
718        Operand::Param(name) => params.get(name).cloned().ok_or_else(|| GraphError::QueryError {
719            detail: format!("missing parameter `{name}`"),
720        }).map(Some),
721        Operand::Var(name) if rel_vars.iter().any(|r| r == name) => Err(GraphError::QueryError {
722            detail: format!(
723                "cannot return relationship variable '{name}' bare; return its properties ({name}.field) instead"
724            ),
725        }),
726        Operand::Var(name) => Ok(match_rs.get(row, name).cloned()),
727        Operand::Prop { var, field } => {
728            if rel_vars.iter().any(|r| r == var) {
729                return Ok(None);
730            }
731            let Some(Value::Str(key)) = match_rs.get(row, var) else {
732                return Ok(None);
733            };
734            Ok(db.get_prop(key, field))
735        }
736        Operand::FuncCall { name, args } => {
737            eval_set_return_func(db, match_rs, row, rel_vars, name, args, params)
738        }
739        Operand::BinArith { op, left, right } => {
740            let lv = eval_set_return_operand(db, match_rs, row, rel_vars, left, params)?;
741            let rv = eval_set_return_operand(db, match_rs, row, rel_vars, right, params)?;
742            eval_set_return_arith(op, lv, rv)
743        }
744        // CASE is supported in read-query RETURN; in a write-statement RETURN
745        // projection (CREATE/MERGE/SET … RETURN) it is not yet wired.
746        Operand::Case { .. } => Err(GraphError::QueryError {
747            detail: "CASE is not supported in a write-statement RETURN projection; \
748                     use a read query"
749                .into(),
750        }),
751    }
752}
753
754fn eval_set_return_arith(
755    op: &ArithOp,
756    lv: Option<Value>,
757    rv: Option<Value>,
758) -> Result<Option<Value>> {
759    match (lv, rv) {
760        (None, _) | (_, None) => Ok(None),
761        (Some(Value::Int(a)), Some(Value::Int(b))) => {
762            let result = match op {
763                ArithOp::Sub => a.saturating_sub(b),
764                ArithOp::Mul => a.saturating_mul(b),
765                ArithOp::Add => a.saturating_add(b),
766                ArithOp::Div => {
767                    if b == 0 {
768                        return Err(GraphError::QueryError {
769                            detail: "division by zero".into(),
770                        });
771                    }
772                    a.checked_div(b).unwrap_or(i64::MAX)
773                }
774            };
775            Ok(Some(Value::Int(result)))
776        }
777        (Some(lv), Some(rv)) => {
778            let a = match &lv {
779                Value::Float(f) => *f,
780                Value::Int(i) => *i as f64,
781                _ => {
782                    return Err(GraphError::QueryError {
783                        detail: format!("arithmetic operand must be numeric, got {lv:?}"),
784                    })
785                }
786            };
787            let b = match &rv {
788                Value::Float(f) => *f,
789                Value::Int(i) => *i as f64,
790                _ => {
791                    return Err(GraphError::QueryError {
792                        detail: format!("arithmetic operand must be numeric, got {rv:?}"),
793                    })
794                }
795            };
796            let result = match op {
797                ArithOp::Sub => a - b,
798                ArithOp::Mul => a * b,
799                ArithOp::Add => a + b,
800                ArithOp::Div => {
801                    if b == 0.0 {
802                        return Err(GraphError::QueryError {
803                            detail: "division by zero".into(),
804                        });
805                    }
806                    a / b
807                }
808            };
809            Ok(Some(Value::Float(result)))
810        }
811    }
812}
813
814fn eval_set_return_func<F: Fs>(
815    db: &GraphDb<F>,
816    match_rs: &ResultSet,
817    row: usize,
818    rel_vars: &[String],
819    name: &str,
820    args: &[Operand],
821    params: &BTreeMap<String, Value>,
822) -> Result<Option<Value>> {
823    let norm = name.to_ascii_lowercase();
824    if norm == "type" {
825        if args.len() != 1 {
826            return Err(GraphError::QueryError {
827                detail: format!("type() requires exactly 1 argument, got {}", args.len()),
828            });
829        }
830        let Operand::Var(rel) = &args[0] else {
831            return Err(GraphError::QueryError {
832                detail: "type() argument must be a relationship variable (e.g. type(r))".into(),
833            });
834        };
835        return Ok(match_rs.get(row, &rel_type_alias(rel)).cloned());
836    }
837    if norm == "key" {
838        if args.len() != 1 {
839            return Err(GraphError::QueryError {
840                detail: format!("key() requires exactly 1 argument, got {}", args.len()),
841            });
842        }
843        let Operand::Var(var) = &args[0] else {
844            return Err(GraphError::QueryError {
845                detail: "key() argument must be a node variable (e.g. key(n))".into(),
846            });
847        };
848        if rel_vars.iter().any(|r| r == var) {
849            return Err(GraphError::QueryError {
850                detail: format!("key() argument `{var}` is a relationship, not a node"),
851            });
852        }
853        // MATCH rows bind node variables to their key string, so the column
854        // value *is* the key.
855        return Ok(match_rs.get(row, var).cloned());
856    }
857    let mut vals = Vec::with_capacity(args.len());
858    for arg in args {
859        vals.push(eval_set_return_operand(
860            db, match_rs, row, rel_vars, arg, params,
861        )?);
862    }
863    match norm.as_str() {
864        "tolower" => {
865            if vals.len() != 1 {
866                return Err(GraphError::QueryError {
867                    detail: format!("toLower() requires exactly 1 argument, got {}", vals.len()),
868                });
869            }
870            Ok(vals[0].clone().map(|val| match val {
871                Value::Str(s) => Value::Str(s.to_ascii_lowercase()),
872                other => other,
873            }))
874        }
875        "toupper" => {
876            if vals.len() != 1 {
877                return Err(GraphError::QueryError {
878                    detail: format!("toUpper() requires exactly 1 argument, got {}", vals.len()),
879                });
880            }
881            Ok(vals[0].clone().map(|val| match val {
882                Value::Str(s) => Value::Str(s.to_ascii_uppercase()),
883                other => other,
884            }))
885        }
886        "size" => match vals.first().cloned().flatten() {
887            None => Ok(None),
888            Some(Value::Str(s)) => Ok(Some(Value::Int(s.len() as i64))),
889            Some(Value::List(items)) => Ok(Some(Value::Int(items.len() as i64))),
890            Some(_) => Ok(None),
891        },
892        "coalesce" => Ok(vals.into_iter().flatten().next()),
893        "abs" => match vals.first().cloned().flatten() {
894            None => Ok(None),
895            Some(Value::Int(n)) => Ok(Some(Value::Int(n.saturating_abs()))),
896            Some(Value::Float(f)) => Ok(Some(Value::Float(f.abs()))),
897            Some(_) => Ok(None),
898        },
899        "round" => match vals.first().cloned().flatten() {
900            None => Ok(None),
901            Some(Value::Float(f)) => Ok(Some(Value::Float(f.round()))),
902            Some(Value::Int(n)) => Ok(Some(Value::Int(n))),
903            Some(_) => Ok(None),
904        },
905        "decay" => {
906            if vals.len() != 3 {
907                return Err(GraphError::QueryError {
908                    detail: format!("decay() requires exactly 3 arguments, got {}", vals.len()),
909                });
910            }
911            match (vals[0].clone(), vals[1].clone(), vals[2].clone()) {
912                (None, _, _) | (_, None, _) | (_, _, None) => Ok(None),
913                (Some(b), Some(a), Some(h)) => {
914                    let numeric = |v: Value| -> Result<f64> {
915                        match v {
916                            Value::Int(n) => Ok(n as f64),
917                            Value::Float(f) => Ok(f),
918                            other => Err(GraphError::QueryError {
919                                detail: format!(
920                                    "decay() requires numeric arguments, got {other:?}"
921                                ),
922                            }),
923                        }
924                    };
925                    let b = numeric(b)?;
926                    let a = numeric(a)?;
927                    let h = numeric(h)?;
928                    if h <= 0.0 {
929                        return Err(GraphError::QueryError {
930                            detail: "decay() requires halflife > 0".into(),
931                        });
932                    }
933                    Ok(Some(Value::Float(b * 0.5f64.powf(a / h))))
934                }
935            }
936        }
937        _ => Err(GraphError::QueryError {
938            detail: format!(
939                "unknown function `{name}`; supported: toLower, toUpper, size, coalesce, type, abs, round, decay, key"
940            ),
941        }),
942    }
943}
944
945fn eval_set_return_item<F: Fs>(
946    db: &GraphDb<F>,
947    match_rs: &ResultSet,
948    row: usize,
949    rel_vars: &[String],
950    item: &RetItem,
951    params: &BTreeMap<String, Value>,
952) -> Result<Option<Value>> {
953    match &item.value {
954        RetVal::Var(v) => eval_set_return_operand(
955            db,
956            match_rs,
957            row,
958            rel_vars,
959            &Operand::Var(v.clone()),
960            params,
961        ),
962        RetVal::Prop { var, field } => eval_set_return_operand(
963            db,
964            match_rs,
965            row,
966            rel_vars,
967            &Operand::Prop {
968                var: var.clone(),
969                field: field.clone(),
970            },
971            params,
972        ),
973        RetVal::FuncCall { name, args } => {
974            eval_set_return_func(db, match_rs, row, rel_vars, name, args, params)
975        }
976        RetVal::ScalarExpr(op) => eval_set_return_operand(db, match_rs, row, rel_vars, op, params),
977        RetVal::Agg { .. } => Err(GraphError::QueryError {
978            detail: "aggregates are not supported in MATCH … SET … RETURN".into(),
979        }),
980    }
981}
982
983/// Project user RETURN from original MATCH rows after SET. No rematch.
984fn project_set_return_rows<F: Fs>(
985    db: &GraphDb<F>,
986    rel_vars: &[String],
987    match_rs: &ResultSet,
988    returns: &[RetItem],
989    params: &BTreeMap<String, Value>,
990) -> Result<ResultSet> {
991    let columns: Vec<String> = returns.iter().map(ret_column_name).collect();
992    let mut out = ResultSet::new(columns);
993    for row in 0..match_rs.len() {
994        let mut cells = Vec::with_capacity(returns.len());
995        for item in returns {
996            cells.push(eval_set_return_item(
997                db, match_rs, row, rel_vars, item, params,
998            )?);
999        }
1000        out.push_row(cells);
1001    }
1002    Ok(out)
1003}
1004
1005/// Single construction point for a `GraphMut` view over the split-borrowed graph fields.
1006/// Callers use `std::mem::take` on the engine before calling this, then restore it after.
1007/// Extract a `Vec<f64>` from a `Value::List` whose items are all numeric.
1008/// Returns `None` for non-list values or lists with non-numeric elements.
1009fn value_as_float_list(v: &Value) -> Option<Vec<f64>> {
1010    match v {
1011        Value::List(items) => items
1012            .iter()
1013            .map(|item| match item {
1014                Value::Float(f) => Some(*f),
1015                Value::Int(i) => Some(*i as f64),
1016                _ => None,
1017            })
1018            .collect(),
1019        _ => None,
1020    }
1021}
1022
1023fn make_graph_mut<'a>(
1024    ids: &'a IdMap,
1025    syms: &'a mut Interner,
1026    labels: &'a [u32],
1027    props: core_storage::v8::seam::ColumnsView<'a>,
1028    topo: &'a mut Topology,
1029    base: &'a Option<std::sync::Arc<core_storage::v8::MappedBase>>,
1030    edge_props: &'a mut EdgeProps,
1031) -> GraphMut<'a> {
1032    GraphMut {
1033        ids,
1034        syms,
1035        labels,
1036        props,
1037        topo,
1038        base_topo: base_csr(base),
1039        edge_props,
1040    }
1041}
1042
1043/// The archived CSR of an open V8 snapshot, for the rule engine's graph reads.
1044///
1045/// A store opened from a snapshot keeps its edges in the mapping and its
1046/// overlay empty, so a rule that reads the graph's shape has to see both.
1047fn base_csr(
1048    base: &Option<std::sync::Arc<core_storage::v8::MappedBase>>,
1049) -> Option<&core_storage::v8::layout::ArchivedCsr> {
1050    base.as_ref().map(|b| {
1051        b.topology()
1052            .expect("base topology section bounds validated at open")
1053    })
1054}
1055
1056/// Build a `ColumnsView` from the disjoint `props` overlay and optional V8 base.
1057///
1058/// Takes explicit field references rather than `&self` so the caller can hold
1059/// simultaneous mutable borrows of other fields (e.g. `syms`, `topo`).
1060fn build_props_view<'a>(
1061    props: &'a ColumnStore,
1062    base: &'a Option<std::sync::Arc<core_storage::v8::MappedBase>>,
1063) -> core_storage::v8::seam::ColumnsView<'a> {
1064    match base {
1065        None => core_storage::v8::seam::ColumnsView::owned(props),
1066        Some(b) => {
1067            let archived = b
1068                .columns()
1069                .expect("base columns section bounds validated at open");
1070            core_storage::v8::seam::ColumnsView::with_base_cached(props, archived, b.mixed_cache())
1071        }
1072    }
1073}
1074
1075fn build_topo_view<'a>(
1076    overlay: &'a Topology,
1077    base: &'a Option<std::sync::Arc<core_storage::v8::MappedBase>>,
1078) -> core_storage::v8::seam::TopologyView<'a> {
1079    match base {
1080        None => core_storage::v8::seam::TopologyView::owned(overlay),
1081        Some(b) => {
1082            let archived_csr = b
1083                .topology()
1084                .expect("base topology section bounds validated at open");
1085            core_storage::v8::seam::TopologyView::with_base(overlay, archived_csr)
1086        }
1087    }
1088}
1089
1090/// When [`GraphDb`] calls `Fs::sync` after a WAL append.
1091///
1092/// Default is [`Strict`](FsyncPolicy::Strict): every `log_then_apply_with`
1093/// fsyncs (single `insert_node` / `set_prop`). Ingest and `write_batch`
1094/// emit one `WalRecord::Batch` and fsync once at that frame (Batched).
1095/// [`Relaxed`](FsyncPolicy::Relaxed) skips WAL sync; [`GraphDb::snapshot`]
1096/// is still durable via `write_atomic`. Crash-recovery DST stays Strict.
1097#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
1098pub enum FsyncPolicy {
1099    /// Every WAL commit calls `fs.sync` (today's behavior).
1100    #[default]
1101    Strict,
1102    /// Sync only at a `Batch` frame end. Single-op path stays Strict unless
1103    /// this policy is set on the database.
1104    Batched,
1105    /// Never call `fs.sync`. [`GraphDb::snapshot`] still syncs via `write_atomic`.
1106    Relaxed,
1107}
1108
1109/// A precondition for a compare-and-set batch write.
1110///
1111/// All preconditions in a [`GraphDb::write_batch_cas`] or
1112/// [`crate::SharedDb::submit_batch_cas`] call are checked atomically before
1113/// any operation in the batch is applied.  If any precondition fails, the
1114/// entire batch is rejected with [`GraphError::CasConflict`] and no WAL frame
1115/// is written.
1116///
1117/// # Touch definition
1118///
1119/// A node's last-change commit (`last_changed`) is updated when any of the
1120/// following state-changing WAL records touch it:
1121///
1122/// - `InsertNode` / `InsertNodeId` — the newly-inserted node.
1123/// - `SetProp` / `SetPropId` / `RemoveProp` — the property-bearing node.
1124/// - `InsertEdge` / `InsertEdgeId` / `DeleteEdge` — **both** src and dst
1125///   endpoints (an edge change touches both sides).
1126/// - `DeleteNode` — the node is tombstoned; `last_changed` returns `None`
1127///   for deleted keys so the pre-deletion entry is never observed.
1128///
1129/// History markers (`DerivedEdgeAdded` / `DerivedEdgeRetracted`) are
1130/// state no-ops.  The underlying mutation that triggered rule firing already
1131/// updated the relevant nodes' last-change entries.  Rule-management records
1132/// (`CreateRule`, `DeleteRule`, `RebuildRule`) and view/full-text declarations
1133/// do not touch any node's last-change.
1134#[derive(Debug, Clone, PartialEq, Eq)]
1135pub enum Precondition {
1136    /// The node's last-change commit must equal `expected`.
1137    ///
1138    /// Fails with [`GraphError::CasConflict`] when:
1139    /// - The node does not exist (`last_changed` returns `None`), or
1140    /// - The recorded commit seq does not match `expected`.
1141    NodeUnchangedSince { key: String, expected: u64 },
1142    /// The node must not exist (not inserted, or already deleted).
1143    ///
1144    /// Fails with [`GraphError::CasConflict`] (expected=`u64::MAX`,
1145    /// actual=`last_changed(key).unwrap_or(0)`) when the node is live.
1146    NodeAbsent { key: String },
1147}
1148
1149pub struct GraphDb<F: Fs> {
1150    fs: F,
1151    ids: IdMap,
1152    syms: Interner,
1153    topo: Topology,
1154    props: ColumnStore,
1155    labels: Vec<u32>, // node id -> label symbol
1156    edge_props: EdgeProps,
1157    engine: RuleEngine,
1158    view_store: ViewStore,
1159    /// Incremental inverted index for full-text-lite search.
1160    /// Rebuild-on-open: populated from WAL replay + rebuild_all at open end.
1161    fulltext: FulltextIndex,
1162    /// Opt-in equality index over scalar node properties.
1163    /// Rebuild-on-open: declarations replay from the WAL, postings rebuild at
1164    /// open end (mirrors `fulltext`).
1165    prop_index: PropertyIndex,
1166    event_sink: Option<Box<dyn Fn(MutationEvent) + Send + Sync>>,
1167    /// WAL fsync cadence. Default [`FsyncPolicy::Strict`].
1168    fsync: FsyncPolicy,
1169    /// Monotonically increasing per-commit counter.  A single `log_then_apply_with`
1170    /// call increments this once; all events emitted from that call share the same
1171    /// `commit_seq` value.
1172    commit_seq: u64,
1173    /// RBAC role definitions loaded from `roles.json` at open.
1174    ///
1175    /// `Some(roles)` — loaded successfully (may be empty when no roles are defined).
1176    /// `None` — `roles.json` was present but corrupt; `mask_for_role` returns
1177    /// `Err` for any request (fail-loud, never silently grant empty visibility).
1178    roles: Option<Vec<RoleDef>>,
1179    /// Live subscriptions.  Entries with a dead `Weak` are pruned on the next
1180    /// distribute_events call.
1181    subscriptions: Vec<SubEntry>,
1182    /// Live query subscriptions. Re-executed on every commit when non-empty.
1183    /// Dead `Weak` entries are pruned inside `distribute_events`.
1184    query_subscriptions: Vec<QuerySubEntry>,
1185    /// Queue capacity for new subscriptions created by this db.  Default is
1186    /// [`DEFAULT_SUB_CAPACITY`]; can be overridden via [`set_sub_capacity`]
1187    /// to test Lagged behaviour with small queues.
1188    sub_capacity: usize,
1189    /// True for as-of instances opened via [`GraphDb::open_at`].
1190    /// Every mutation method and `snapshot()` returns [`GraphError::ReadOnly`]
1191    /// when this flag is set.
1192    read_only: bool,
1193    /// Total WAL commit count at the time [`open_at`] was called.
1194    /// 0 for normal (non-as-of) instances.
1195    total_wal_commits: u64,
1196    /// Immutable mmap-backed base snapshot (V8).  When `Some`, `self.topo` is
1197    /// the WAL-replay overlay (empty at open time, populated by apply()) and
1198    /// reads go through a merged `TopologyView`.  `self.props` is always
1199    /// fully materialized (base + WAL replay) for HNSW/IVF and view compat.
1200    base: Option<Arc<core_storage::v8::MappedBase>>,
1201    // ── MVCC epoch reader state ───────────────────────────────────────────────
1202    /// Most-recent full overlay clone.  Initialized at end of `open_with` /
1203    /// `open_at_with`; refreshed every `FOLD_EVERY_K` commits.
1204    /// `None` only between struct creation and the first fold.
1205    fold_overlay: Option<Arc<crate::reader::FrozenOverlay>>,
1206    /// Per-commit deltas accumulated since the last fold.
1207    delta_tail: Vec<Arc<crate::reader::CommitDelta>>,
1208    /// How many commits have occurred since the last fold.
1209    commits_since_fold: usize,
1210    /// When true, `log_then_apply_with` buffers event notifications instead of
1211    /// firing them immediately.  Used by the group-commit drain thread to defer
1212    /// events until after the group fsync (R2: durability before notification).
1213    /// Cleared to false once the drain thread flushes or discards the buffer.
1214    defer_events: bool,
1215    /// Buffered events accumulated while `defer_events` is true.
1216    deferred_events: Vec<DeferredEvent>,
1217    /// Set to true by the group-commit drain thread when a group fsync fails
1218    /// after WAL truncation.  All subsequent mutation attempts return an IO
1219    /// error until the database is reopened.
1220    degraded: bool,
1221    /// Set to `true` after `ensure_v8_base_sections_loaded` has read provenance,
1222    /// HNSW, and IVF sections from the mmap base into the engine's retained
1223    /// fields.  `false` on all opens until first use; always `true` for non-V8
1224    /// opens (base is None, fast-path sets flag immediately).
1225    v8_sections_loaded: std::sync::atomic::AtomicBool,
1226    /// Serializes the one-time section population in `ensure_v8_base_sections_loaded`.
1227    v8_sections_mutex: std::sync::Mutex<()>,
1228    /// Per-node last-change commit sequence.  `last_change[node_id] = seq` means
1229    /// the node was last modified by commit `seq`.
1230    ///
1231    /// Loaded from V8 section 11 at open; updated on every state-changing commit
1232    /// and WAL replay frame.  V5-V7 stores start with an empty map; pre-WAL-horizon
1233    /// nodes return `None` from `last_changed` until they are next mutated.
1234    ///
1235    /// See [`Precondition`] for the full touch definition.
1236    last_change: HashMap<u32, u64>,
1237    /// WAL archive retention policy set by [`set_wal_archive_retention`].
1238    /// `None` = unlimited (keep all archives); `Some(N)` = keep N newest archives,
1239    /// pruning older ones at snapshot time.  0 is treated as unlimited.
1240    wal_archive_retention: Option<u32>,
1241    /// Global frame index of the first commit that is still reachable through
1242    /// surviving archives.  Persisted to `wal.floor` sidecar when pruning occurs.
1243    /// Default 0 = all history reachable.
1244    wal_horizon_floor: u64,
1245    /// True when the surviving archive chain forms a continuous WAL history
1246    /// starting from the store's first commit (the genesis chain).
1247    ///
1248    /// `open_at` may replay archive-resident commits from empty state only when
1249    /// this flag is true AND `wal_horizon_floor == 0`.  Cleared whenever:
1250    ///   - a WAL-truncating snapshot (`keep_wal=false`) is taken after archives
1251    ///     already exist (breaks the chain for subsequent archives), or
1252    ///   - any archive is pruned (floor advances past zero).
1253    ///
1254    /// Persisted via the `wal.genesis` marker file; loaded from it at open.
1255    archive_genesis_chain: bool,
1256    /// Transient write-authz context set by `write_batch_authz` /
1257    /// `query_write_authz` for the duration of ONE mutation call.
1258    /// Always `None` at rest.  Never serialized, never WAL-replayed.
1259    pending_write_authz: Option<WriteAuthz>,
1260    /// Slow-query threshold in milliseconds.  0 = disabled.
1261    /// Seeded from `MUSHROOMDB_SLOW_QUERY_MS` at open; override via
1262    /// [`GraphDb::set_slow_query_threshold_ms`] (tests must use the setter
1263    /// — env vars are process-global and race parallel test threads).
1264    slow_query_threshold_ms: u64,
1265    /// Ring buffer of recent slow queries (interior-mutable so `query(&self)`
1266    /// can record entries without requiring `&mut self`).
1267    slow_queries: std::sync::Mutex<SlowQueryLog>,
1268    /// Instant at which the database was opened (used by `/metrics` uptime).
1269    started_at: std::time::Instant,
1270    // ── Multi-process state (cross-process lock + WAL tailing) ────────────────
1271    /// Byte offset of the WAL prefix already applied to in-memory state.
1272    ///
1273    /// Advanced by exactly the encoded length of every frame this handle
1274    /// appends, and by the decoded byte count of every tail
1275    /// [`refresh`](GraphDb::refresh) absorbs. Rewound by
1276    /// [`set_wal_consumed`](GraphDb::set_wal_consumed) when the group-commit
1277    /// drain thread truncates a failed group. Compared against the WAL's
1278    /// on-disk length to decide staleness.
1279    wal_consumed: u64,
1280    /// Identity of the snapshot this handle's base state came from, as
1281    /// `(len, mtime_nanos)`. A different value means another process replaced
1282    /// the snapshot and the WAL no longer continues our state: refresh reloads.
1283    snapshot_ident: Option<(u64, u64)>,
1284    /// The options this handle was opened with. Replayed verbatim when
1285    /// `refresh` has to rebuild from disk.
1286    open_opts: OpenOptions,
1287    /// True when this handle holds the cross-process write lock for its whole
1288    /// lifetime (a plain read-write open). Per-write lock acquisition is a
1289    /// no-op on such a handle, and never releases the lock.
1290    holds_lifetime_lock: bool,
1291    /// True between a failed lock acquisition and the end of the write scope
1292    /// that failed. Makes every WAL-appending mutation in that scope return
1293    /// [`GraphError::Busy`] instead of writing.
1294    lock_denied: bool,
1295    /// True for an as-of view opened via [`GraphDb::open_at`]. Such a view is
1296    /// pinned to one commit, so it is never stale and never refreshes — later
1297    /// commits by any process are deliberately invisible to it.
1298    pinned: bool,
1299}
1300
1301/// One group of deferred event notifications, held until the group fsync
1302/// completes.  Replayed by [`GraphDb::flush_deferred_events`].
1303struct DeferredEvent {
1304    rec: core_storage::WalRecord,
1305    engine_deltas: Vec<EngineEdgeDelta>,
1306    seq: u64,
1307    ingest: Option<(String, usize)>,
1308}
1309
1310/// Options for [`GraphDb::open_with_options`].
1311#[derive(Clone, Copy, Debug)]
1312pub struct OpenOptions {
1313    /// Rewrite an old-format snapshot to the current VERSION after a
1314    /// successful load (default `true`). The old snapshot is kept as
1315    /// `snapshot.bin.bak` until the next clean open at the current version,
1316    /// at which point the `.bak` is deleted.
1317    ///
1318    /// Set to `false` to open a store without touching any on-disk files
1319    /// (useful for read-only inspection of a store at an older format).
1320    pub auto_migrate: bool,
1321
1322    /// Write the valid WAL prefix back over a torn tail on open (default
1323    /// `true`). Truncating a genuinely torn tail is correct crash recovery.
1324    ///
1325    /// Set to `false` for an unattended reader. The valid prefix is still
1326    /// decoded and replayed in memory, but nothing is written: a reader that
1327    /// opens while another process is mid-append would otherwise discard a
1328    /// frame that writer believes durable. `mushroomdb recall`, which runs on
1329    /// every prompt, passes `false` for exactly this reason.
1330    pub repair_wal: bool,
1331
1332    /// Open without ever writing to the store (default `false`).
1333    ///
1334    /// A read-only handle:
1335    /// - returns [`GraphError::ReadOnly`] from every mutation and from
1336    ///   `snapshot()`;
1337    /// - performs no disk write at open — no WAL repair write-back and no
1338    ///   auto-migration rewrite, whatever the other two flags say;
1339    /// - never takes the cross-process write lock, so it opens immediately even
1340    ///   while another process is writing, and never makes a writer wait.
1341    ///
1342    /// [`refresh`](GraphDb::refresh) and [`is_stale`](GraphDb::is_stale) work
1343    /// normally, so a read-only handle can follow another process's commits.
1344    pub read_only: bool,
1345}
1346
1347impl Default for OpenOptions {
1348    fn default() -> Self {
1349        Self {
1350            auto_migrate: true,
1351            repair_wal: true,
1352            read_only: false,
1353        }
1354    }
1355}
1356
1357/// How long a writer polls for the cross-process write lock before giving up
1358/// with [`GraphError::Busy`].
1359///
1360/// Long enough to ride out another process's commit (a batch apply plus one
1361/// fsync), short enough that a stuck peer surfaces as an error rather than a
1362/// hang.
1363pub const WRITE_LOCK_WAIT: std::time::Duration = std::time::Duration::from_secs(2);
1364
1365/// Interval between poll attempts while waiting for the cross-process lock.
1366pub(crate) const LOCK_POLL_INTERVAL: std::time::Duration = std::time::Duration::from_millis(10);
1367
1368/// Why `load_from_disk` is running, which decides whether it may repair.
1369#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1370enum LoadOrigin {
1371    /// A fresh open. Crash recovery is this handle's job: a torn WAL tail is
1372    /// the signature of a crash and truncating it is correct, and archives
1373    /// orphaned by an interrupted prune can be swept.
1374    Open,
1375    /// A reload driven by [`GraphDb::refresh`], because another process
1376    /// replaced the snapshot. Nothing here is crash recovery — the store is
1377    /// live and someone else is writing it — so this origin writes nothing.
1378    Reload,
1379}
1380
1381/// Authorization context carried by `write_batch_authz` / `query_write_authz`.
1382///
1383/// `None` at the call site = full authority (today's zero-cost behavior).
1384/// `Some(WriteAuthz)` = role-scoped: the decision table (plan §"authz decision
1385/// table") is evaluated per-op inside `commit_logged_batch` BEFORE any WAL
1386/// record is built.  A denial returns an error with no WAL frame written.
1387///
1388/// The mask is ALWAYS `Omit`-mode: role-token paths must never acknowledge
1389/// hidden-node existence to callers.
1390#[derive(Clone, Debug)]
1391pub struct WriteAuthz {
1392    pub role: String,
1393    pub scope: WriteScope,
1394    /// Resolved by `mask_for_role` under the same write guard as the mutation.
1395    /// Always `Omit`-mode — never `Stub`.
1396    pub mask: crate::mask::NodeMask,
1397}
1398
1399/// Write `bytes` to `snapshot.bin.bak` atomically with full fsync.
1400///
1401/// Uses [`RealFs::write_atomic`] which applies `F_FULLFSYNC` on macOS and
1402/// `sync_all` on other platforms, then renames the `.tmp` file into place and
1403/// syncs the directory entry. This is the only correct path for writing the
1404/// `.bak` — plain `std::fs::write + sync_all` misses both `F_FULLFSYNC` and
1405/// the directory sync.
1406pub fn write_snapshot_bak(dir: &std::path::Path, bytes: &[u8]) -> crate::Result<()> {
1407    use core_storage::fs::{FileId, Fs as _};
1408    RealFs::new(dir)
1409        .map_err(core_storage::GraphError::Io)?
1410        .write_atomic(FileId::SnapshotBak, bytes)
1411        .map_err(core_storage::GraphError::Io)
1412}
1413
1414/// Return the on-disk snapshot format version without decoding the full snapshot.
1415///
1416/// Reads only the 6-byte header (magic + version LE). Returns `None` when no
1417/// snapshot file exists (WAL-only store). Returns an error if the header is
1418/// malformed.
1419pub fn snapshot_version_at(dir: &std::path::Path) -> crate::Result<Option<u16>> {
1420    use std::io::Read as _;
1421    let path = dir.join("snapshot.bin");
1422    let mut header = [0u8; 6];
1423    let n = match std::fs::File::open(&path) {
1424        Ok(mut f) => f.read(&mut header).map_err(core_storage::GraphError::Io)?,
1425        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
1426        Err(e) => return Err(core_storage::GraphError::Io(e)),
1427    };
1428    core_storage::snapshot::peek_version(&header[..n])
1429}
1430
1431/// Options for [`GraphDb::snapshot_with`].
1432#[derive(Debug, Clone, Default)]
1433pub struct SnapshotOptions {
1434    /// When `true`, the WAL is preserved after the snapshot write.
1435    /// Pre-snapshot commits remain reachable via [`GraphDb::open_at`].
1436    /// When `false` (the default), the WAL is truncated to a minimal
1437    /// baseline so cold-start replay stays fast.
1438    pub keep_wal: bool,
1439    /// When `true`, the current WAL is renamed to `wal.<commit_seq>.archive`
1440    /// before a fresh WAL baseline is written (history-preserving snapshot).
1441    ///
1442    /// This is the feature opt-in: `false` (the default) leaves the existing
1443    /// truncation / keep-wal behaviour byte-identical.  `archive_wal` takes
1444    /// precedence over `keep_wal` when both are set.
1445    ///
1446    /// Archives can be scanned by [`GraphDb::node_history`],
1447    /// [`GraphDb::edge_history`], [`GraphDb::was_linked`], and
1448    /// [`GraphDb::open_at`], extending the reachable history horizon across
1449    /// snapshot boundaries.
1450    pub archive_wal: bool,
1451}
1452
1453/// Derive the scan-label sym for the commit-skip fast-path.
1454///
1455/// Walks `ops` to find the plan's leading scan op (`ScanLabel`, `IndexScan`,
1456/// or `IndexIntersect`) with a concrete label string, then interns it.
1457///
1458/// Returns `None` in all cases where skipping is unsafe:
1459/// - Any `Expand` op is present (edge traversal; edges change results regardless
1460///   of node labels).
1461/// - The leading scan has no label (`ScanLabel { label: None }` — full scan).
1462/// - No recognizable leading scan op is found.
1463///
1464/// This is the conservative v0.4.3 boundary. The caller stores the result in
1465/// [`QuerySubEntry::scan_label`] at subscribe time; `None` means always execute.
1466fn extract_scan_label(ops: &[PlanOp], syms: &mut Interner) -> Option<u32> {
1467    // Any Expand → must always re-execute (edges can change join results).
1468    if ops.iter().any(|op| matches!(op, PlanOp::Expand { .. })) {
1469        return None;
1470    }
1471    for op in ops {
1472        match op {
1473            PlanOp::ScanLabel {
1474                label: Some(label), ..
1475            } => return Some(syms.intern(label)),
1476            PlanOp::IndexScan {
1477                label: Some(label), ..
1478            } => return Some(syms.intern(label)),
1479            PlanOp::IndexIntersect {
1480                label: Some(label), ..
1481            } => return Some(syms.intern(label)),
1482            _ => {}
1483        }
1484    }
1485    None
1486}
1487
1488impl GraphDb<RealFs> {
1489    /// Open the database at `dir` with default options.
1490    ///
1491    /// Equivalent to `open_with_options(dir, OpenOptions::default())`.
1492    /// Old-format snapshots (V5, V6) are automatically migrated to the
1493    /// current version on a successful load (see [`OpenOptions::auto_migrate`]).
1494    pub fn open(dir: &std::path::Path) -> Result<Self> {
1495        Self::open_with_options(dir, OpenOptions::default())
1496    }
1497
1498    /// Open the database at `dir` with explicit options.
1499    ///
1500    /// When `opts.auto_migrate` is `true` (the default) and the on-disk
1501    /// snapshot is an older format version, this function:
1502    ///   1. Copies the current `snapshot.bin` to `snapshot.bin.bak` (atomic
1503    ///      + fsynced) before any modification.
1504    ///   2. Rewrites `snapshot.bin` at the current format version via
1505    ///      [`GraphDb::snapshot_with`] with `keep_wal: true` (WAL preserved).
1506    ///
1507    /// If migration fails the error is returned and the original files are
1508    /// intact (the `.bak` was written before the new snapshot was attempted).
1509    ///
1510    /// A clean open that finds the snapshot already at the current version
1511    /// deletes any leftover `.bak` file.
1512    ///
1513    /// WAL-only stores (no snapshot) are never auto-migrated on open.
1514    ///
1515    /// `opts.repair_wal` controls the other write this function can make; see
1516    /// [`OpenOptions::repair_wal`]. With both flags `false` the open touches
1517    /// no file on disk.
1518    pub fn open_with_options(dir: &std::path::Path, opts: OpenOptions) -> Result<Self> {
1519        Self::open_dir(dir, opts, true)
1520    }
1521
1522    /// Open without taking the cross-process write lock for the handle's
1523    /// lifetime.
1524    ///
1525    /// Only [`SharedDb`](crate::SharedDb) uses this: a long-lived server holds
1526    /// its handle open indefinitely, so it takes the lock per write instead of
1527    /// keeping every other process out of the store for as long as it runs.
1528    pub(crate) fn open_unlocked(dir: &std::path::Path) -> Result<Self> {
1529        Self::open_dir(dir, OpenOptions::default(), false)
1530    }
1531
1532    fn open_dir(dir: &std::path::Path, opts: OpenOptions, hold_lock: bool) -> Result<Self> {
1533        // Header-only peek — 6 bytes, no full decode.
1534        let snap_version = snapshot_version_at(dir)?;
1535
1536        // Full load: decode snapshot + replay WAL + rebuild indexes.
1537        let mut db = Self::open_generic(RealFs::new(dir)?, opts, hold_lock)?;
1538
1539        // A read-only handle writes nothing at open, so it never migrates —
1540        // the old-format snapshot is loaded and left exactly as it is.
1541        if opts.auto_migrate && !opts.read_only {
1542            match snap_version {
1543                Some(ver) if ver < core_storage::snapshot::VERSION => {
1544                    let _tm = std::time::Instant::now();
1545                    // Copy the original snapshot to .bak at OS level — no in-memory
1546                    // buffer required for a 2+ GiB file.
1547                    //
1548                    // Crash-safety: snapshot.bin remains intact (write_atomic inside
1549                    // snapshot_with uses a .tmp+rename) until the V8 write succeeds.
1550                    // A torn .bak on crash is acceptable because the original
1551                    // snapshot.bin is the authoritative source until after the rename.
1552                    std::fs::copy(dir.join("snapshot.bin"), dir.join("snapshot.bin.bak"))
1553                        .map_err(core_storage::GraphError::Io)?;
1554                    trace_migrate!("bak copy done", _tm);
1555                    // Rewrite snapshot at current version; keep WAL intact.
1556                    db.snapshot_with(SnapshotOptions {
1557                        keep_wal: true,
1558                        ..SnapshotOptions::default()
1559                    })?;
1560                    trace_migrate!("snapshot_with done", _tm);
1561                }
1562                Some(_) => {
1563                    // Already current version: remove any leftover .bak.
1564                    let bak = dir.join("snapshot.bin.bak");
1565                    if bak.exists() {
1566                        std::fs::remove_file(&bak).map_err(core_storage::GraphError::Io)?;
1567                    }
1568                }
1569                None => {
1570                    // WAL-only store — nothing to migrate on open.
1571                }
1572            }
1573        }
1574
1575        Ok(db)
1576    }
1577
1578    /// Open a read-only view of the database as it existed after `commit`.
1579    ///
1580    /// Commit indices are 0-based over the current WAL: commit 0 is the state
1581    /// after the first WAL frame, commit N-1 is the state after the N-th (most
1582    /// recent) frame.  Call [`GraphDb::open`] to read the full current state.
1583    ///
1584    /// **Replay base.** [`GraphDb::snapshot`] truncates the WAL when it runs,
1585    /// so as-of can only reach commits recorded in the current WAL (those
1586    /// written after the most recent snapshot, or all commits if no snapshot
1587    /// was ever taken).  Commit 0 in `open_at` always refers to the first
1588    /// frame in the WAL that exists on disk, not the first ever write to the
1589    /// database.  When the on-disk snapshot recorded that it truncated the
1590    /// WAL (V7, default `keep_wal: false`), it is loaded as the base state
1591    /// before frame replay, so the as-of view includes all pre-snapshot data.
1592    /// Snapshots written with `keep_wal: true` (and legacy V5/V6 snapshots)
1593    /// are ignored and replay is WAL-only, as before.
1594    ///
1595    /// **Read-only.** Every mutation method and `snapshot()` on the returned
1596    /// instance returns [`GraphError::ReadOnly`].  Queries, `explain()`, and
1597    /// `stats()` work normally.
1598    ///
1599    /// # Errors
1600    /// - [`GraphError::CommitOutOfRange`] if `commit >= wal_commit_count` (including
1601    ///   when the WAL is empty after a snapshot).
1602    pub fn open_at(dir: &std::path::Path, commit: u64) -> Result<Self> {
1603        Self::open_at_with(RealFs::new(dir)?, commit)
1604    }
1605
1606    /// Run a **read-only** Cypher query against the graph as it existed at
1607    /// `commit` — the "time-travel" / agent-replay query. Opens a temporal view
1608    /// of this store's directory at that commit and executes the read there.
1609    ///
1610    /// The current instance is unaffected. Write statements are rejected (the
1611    /// temporal view is read-only). `commit` is a 0-based WAL commit index;
1612    /// `commit == wal_commit_count` (or `open_at`'s range) yields the newest
1613    /// state. Prefer this over holding many historical instances open.
1614    ///
1615    /// # Errors
1616    /// - [`GraphError::CommitOutOfRange`] if `commit` is past the WAL horizon.
1617    /// - A query error for a malformed or write query.
1618    pub fn query_at(
1619        &self,
1620        commit: u64,
1621        cypher: &str,
1622        params: &std::collections::BTreeMap<String, Value>,
1623    ) -> Result<ResultSet> {
1624        let dir = self.fs.dir().to_path_buf();
1625        let temporal = Self::open_at(&dir, commit)?;
1626        if is_write_tokens(&lex(cypher).map_err(|e| GraphError::QueryError {
1627            detail: format!("lex: {e}"),
1628        })?) {
1629            return Err(GraphError::QueryError {
1630                detail: "query_at is read-only: write statements are not permitted in a \
1631                         time-travel query"
1632                    .into(),
1633            });
1634        }
1635        temporal.query(cypher, params)
1636    }
1637}
1638
1639impl<F: Fs> GraphDb<F> {
1640    /// Open over an arbitrary [`Fs`], repairing a torn WAL tail as usual.
1641    pub fn open_with(fs: F) -> Result<Self> {
1642        Self::open_with_repair(fs, true)
1643    }
1644
1645    /// As [`GraphDb::open_with`], but `repair_wal: false` decodes the valid WAL
1646    /// prefix without writing the truncation back. See
1647    /// [`OpenOptions::repair_wal`].
1648    pub fn open_with_repair(fs: F, repair_wal: bool) -> Result<Self> {
1649        Self::open_generic(
1650            fs,
1651            OpenOptions {
1652                repair_wal,
1653                ..OpenOptions::default()
1654            },
1655            true,
1656        )
1657    }
1658
1659    /// Shared open path.
1660    ///
1661    /// `hold_lock` requests the cross-process write lock for the whole handle
1662    /// lifetime — the right behaviour for a plain read-write `GraphDb`, whose
1663    /// owner writes through it directly. [`SharedDb`](crate::SharedDb) passes
1664    /// `false` and takes the lock per write instead, so that a long-lived
1665    /// server does not keep every other process out of the store.
1666    ///
1667    /// A read-only open never takes the lock regardless of `hold_lock`.
1668    fn open_generic(fs: F, opts: OpenOptions, hold_lock: bool) -> Result<Self> {
1669        let mut db = Self::new_empty(fs, opts);
1670        db.read_only = opts.read_only;
1671        if hold_lock && !opts.read_only {
1672            if !db.poll_lock(WRITE_LOCK_WAIT)? {
1673                return Err(GraphError::Busy { holder: None });
1674            }
1675            db.holds_lifetime_lock = true;
1676        }
1677        db.load_from_disk(LoadOrigin::Open)?;
1678        Ok(db)
1679    }
1680
1681    /// A handle with no state loaded: every field at its empty value, the
1682    /// filesystem and options in place. Only [`load_from_disk`] makes it
1683    /// usable.
1684    fn new_empty(fs: F, opts: OpenOptions) -> Self {
1685        Self {
1686            fs,
1687            ids: IdMap::new(),
1688            syms: Interner::new(),
1689            topo: Topology::new(),
1690            props: ColumnStore::new(),
1691            labels: Vec::new(),
1692            edge_props: EdgeProps::new(),
1693            engine: RuleEngine::new(),
1694            view_store: ViewStore::new(),
1695            fulltext: FulltextIndex::new(),
1696            prop_index: PropertyIndex::new(),
1697            event_sink: None,
1698            fsync: FsyncPolicy::Strict,
1699            commit_seq: 0,
1700            roles: Some(vec![]),
1701            subscriptions: Vec::new(),
1702            query_subscriptions: Vec::new(),
1703            sub_capacity: DEFAULT_SUB_CAPACITY,
1704            read_only: false,
1705            total_wal_commits: 0,
1706            base: None,
1707            fold_overlay: None,
1708            delta_tail: Vec::new(),
1709            commits_since_fold: 0,
1710            defer_events: false,
1711            deferred_events: Vec::new(),
1712            degraded: false,
1713            v8_sections_loaded: std::sync::atomic::AtomicBool::new(false),
1714            v8_sections_mutex: std::sync::Mutex::new(()),
1715            last_change: HashMap::new(),
1716            wal_archive_retention: None,
1717            wal_horizon_floor: 0,
1718            archive_genesis_chain: false,
1719            pending_write_authz: None,
1720            slow_query_threshold_ms: std::env::var("MUSHROOMDB_SLOW_QUERY_MS")
1721                .ok()
1722                .and_then(|v| v.parse().ok())
1723                .unwrap_or(100),
1724            slow_queries: std::sync::Mutex::new(SlowQueryLog {
1725                entries: std::collections::VecDeque::new(),
1726                total: 0,
1727            }),
1728            started_at: std::time::Instant::now(),
1729            wal_consumed: 0,
1730            snapshot_ident: None,
1731            open_opts: opts,
1732            holds_lifetime_lock: false,
1733            lock_denied: false,
1734            pinned: false,
1735        }
1736    }
1737
1738    /// Return every field describing stored graph state to its empty value,
1739    /// leaving this handle's own identity alone.
1740    ///
1741    /// Preserved on purpose: the filesystem, open options, lock ownership, the
1742    /// event sink and subscriptions, fsync policy, degraded flag, and the
1743    /// slow-query configuration and log. A caller that registered a sink or a
1744    /// subscription keeps it across a reload.
1745    fn reset_for_reload(&mut self) {
1746        self.ids = IdMap::new();
1747        self.syms = Interner::new();
1748        self.topo = Topology::new();
1749        self.props = ColumnStore::new();
1750        self.labels = Vec::new();
1751        self.edge_props = EdgeProps::new();
1752        self.engine = RuleEngine::new();
1753        self.view_store = ViewStore::new();
1754        self.fulltext = FulltextIndex::new();
1755        self.prop_index = PropertyIndex::new();
1756        self.commit_seq = 0;
1757        self.roles = Some(vec![]);
1758        self.total_wal_commits = 0;
1759        self.base = None;
1760        self.fold_overlay = None;
1761        self.delta_tail = Vec::new();
1762        self.commits_since_fold = 0;
1763        self.deferred_events = Vec::new();
1764        self.v8_sections_loaded
1765            .store(false, std::sync::atomic::Ordering::Release);
1766        self.last_change = HashMap::new();
1767        self.wal_horizon_floor = 0;
1768        self.archive_genesis_chain = false;
1769        self.pending_write_authz = None;
1770        self.wal_consumed = 0;
1771        self.snapshot_ident = None;
1772    }
1773
1774    /// Load the snapshot base and replay the WAL into an empty handle — the
1775    /// whole of what opening a store does after the struct exists.
1776    ///
1777    /// Split out of the open path so that [`refresh`](GraphDb::refresh) can
1778    /// rebuild a handle in place, without ownership of `F`, when another
1779    /// process replaces the snapshot underneath it.
1780    ///
1781    /// `origin` decides whether the two repair writes this function can make
1782    /// are appropriate; see [`LoadOrigin`].
1783    fn load_from_disk(&mut self, origin: LoadOrigin) -> Result<usize> {
1784        // Both writes below are crash recovery, and only an open is entitled to
1785        // perform them. A read-only handle promises to touch nothing, and a
1786        // reload driven by `refresh` is looking at a store another process is
1787        // actively writing: what looks like a torn tail there is a peer
1788        // mid-append, and what looks like an orphaned archive may be one that
1789        // peer is about to reference.
1790        let may_repair = origin == LoadOrigin::Open && !self.open_opts.read_only;
1791        let repair_wal = self.open_opts.repair_wal && may_repair;
1792        let db = self;
1793        db.wal_horizon_floor = db.fs.read_horizon_floor()?;
1794        db.archive_genesis_chain = db.fs.has_genesis_marker();
1795        // Opening cleanup: remove orphaned archives — archives whose frames all
1796        // fall below the horizon floor.  Orphans arise when a crash interrupted
1797        // the retention-prune sequence after the floor was written but before
1798        // all surplus archives were deleted.  Safe to delete: floor already
1799        // accounts for their frames.
1800        if may_repair {
1801            db.cleanup_orphaned_archives()?;
1802        }
1803        let _t0 = std::time::Instant::now();
1804        // Peek 6 bytes to determine snapshot version without reading the full
1805        // file. For RealFs this is a true partial read (O(1)); for SimFs the
1806        // default impl reads all bytes and truncates (still correct).
1807        let snap_header = db.fs.read_prefix(FileId::Snapshot, 6)?;
1808        let is_v8 = snap_header.len() >= 6
1809            && &snap_header[0..4] == b"GDB1"
1810            && u16::from_le_bytes([snap_header[4], snap_header[5]])
1811                == core_storage::snapshot::VERSION_8;
1812        if is_v8 {
1813            // V8: map the file zero-copy (RealFs) or read full bytes (SimFs).
1814            // No 2.4GB heap Vec is allocated on RealFs.
1815            let mapped = Arc::new(
1816                if let Some(snap_path) = db.fs.snapshot_path() {
1817                    core_storage::v8::MappedBase::map(&snap_path)
1818                } else {
1819                    let snap_bytes = db.fs.read(FileId::Snapshot)?;
1820                    core_storage::v8::MappedBase::from_bytes(snap_bytes)
1821                }
1822                .map_err(|e| GraphError::Corrupt {
1823                    detail: format!("v8: mmap open: {e:?}"),
1824                })?,
1825            );
1826            db.restore_v8_base(Arc::clone(&mapped))?;
1827            trace_open!("restore_v8_base", _t0);
1828            db.base = Some(mapped);
1829            trace_open!("base assigned", _t0);
1830        } else if !snap_header.is_empty() {
1831            // Legacy V5-V7: full read required for decode.
1832            let snap_bytes = db.fs.read(FileId::Snapshot)?;
1833            if let Some(state) = core_storage::snapshot::decode(&snap_bytes)? {
1834                db.restore_snapshot_state(state)?;
1835            }
1836        }
1837        // else: snap_header is empty = no snapshot file, fresh store.
1838        //
1839        // Seed commit_seq from the highest seq persisted in last_change so that
1840        // WAL-replay frames (which start at commit_seq+1) always exceed any seq
1841        // already stored in the snapshot.  Without this, a db with one snapshot
1842        // commit would save last_change["a"]=1, then on reopen the first WAL
1843        // frame would replay at seq=1 again — colliding and making WAL-tail
1844        // mutations indistinguishable from the snapshot baseline.
1845        //
1846        // Safety invariant (seq-recycling):
1847        //   Recycled seqs (those below the seeded baseline) were NEVER stored in
1848        //   last_change because they belonged to a previous db lifetime — a new
1849        //   db starts at commit_seq=0 with an empty last_change.  Therefore no
1850        //   CAS precondition can carry a recycled seq as its `expected` value
1851        //   and accidentally match a live node's last_change entry.
1852        //
1853        // `expected:0` on a deleted-then-reinserted node:
1854        //   After deletion, last_changed() returns None; callers that call
1855        //   last_changed() and then use NodeUnchangedSince get None.unwrap_or(0)
1856        //   = 0.  The reinserted node gets seq > 0, so a subsequent CAS with
1857        //   expected=0 correctly conflicts.  The only way to observe actual=0 in
1858        //   a CasConflict would be a caller that invented expected=0 without ever
1859        //   calling last_changed() — unreachable via the documented API contract.
1860        if let Some(&max_seq) = db.last_change.values().max() {
1861            db.commit_seq = db.commit_seq.max(max_seq);
1862        }
1863        let bytes = db.fs.read(FileId::Wal)?;
1864        let (records, valid_len) = decode_all(&bytes);
1865        // The valid prefix is replayed either way; `repair_wal` only decides
1866        // whether the truncation is written back. A reader that races a live
1867        // appender must not persist a truncation the writer never asked for.
1868        if valid_len < bytes.len() && repair_wal {
1869            db.fs.write_atomic(FileId::Wal, &bytes[..valid_len])?;
1870        }
1871        // WAL-present path: build indexes eagerly BEFORE replay so that the
1872        // first replayed record does not trigger the lazy-init guard (which
1873        // would call reindex_all_load_ivf on an empty graph, defeating the
1874        // point of restoring IVF/HNSW blobs from the snapshot).
1875        if !records.is_empty() {
1876            db.ensure_v8_base_sections_loaded();
1877            trace_open!("lazy sections loaded (WAL path)", _t0);
1878        }
1879        let replayed = db.apply_frames(records)?;
1880        // The cursor sits at the end of the valid prefix, not the end of the
1881        // file: a torn or still-being-written tail is unconsumed by definition
1882        // and stays visible to `is_stale` until it decodes.
1883        db.wal_consumed = valid_len as u64;
1884        db.snapshot_ident = db.fs.snapshot_ident().map_err(GraphError::Io)?;
1885        trace_open!("wal replay done", _t0);
1886        // Rebuild view values after WAL replay only when there is no V8 base.
1887        // With a V8 base, view values are correct in the snapshot and are updated
1888        // incrementally during WAL replay (on_edge_changed / on_prop_changed).
1889        // A full rebuild would read overlay-only props (empty after restore_v8_base)
1890        // and overwrite correct base values with wrong results (e.g. NeighborAgg
1891        // Sum reads no "score" in overlay → writes 0.0, shadowing the correct
1892        // base value).
1893        if db.base.is_none() {
1894            let topo_view = TopologyView::owned(&db.topo);
1895            db.view_store
1896                .rebuild_all(&mut db.props, &topo_view, &db.ids, &db.syms, &db.labels);
1897        }
1898        // Rebuild full-text index after WAL replay.  Corrects drift from
1899        // per-record incremental apply during replay.
1900        db.fulltext.rebuild_all(
1901            &db.ids,
1902            &db.labels,
1903            &db.syms,
1904            build_props_view(&db.props, &db.base),
1905        );
1906        db.prop_index.rebuild_all(
1907            &db.ids,
1908            &db.labels,
1909            &db.syms,
1910            build_props_view(&db.props, &db.base),
1911        );
1912        // Load roles sidecar. Missing file = no roles (Some(vec![])).
1913        // Corrupt/unparseable = poisoned (None); mask_for_role will fail-loud.
1914        db.roles = Self::load_roles_from_fs(&db.fs)?;
1915        // Capture the initial MVCC fold so reader() is ready immediately.
1916        db.fold_now();
1917        trace_open!("open_with complete", _t0);
1918        Ok(replayed)
1919    }
1920
1921    /// Apply decoded WAL frames to in-memory state, exactly as the open-path
1922    /// replay does — same `apply` calls, same per-frame delta drain, same
1923    /// commit-seq and last-change bookkeeping. Rules therefore fire and derived
1924    /// edges appear identically whether a frame arrives at open, from a local
1925    /// commit, or from another process by way of [`refresh`](GraphDb::refresh).
1926    ///
1927    /// Returns the number of frames applied.
1928    ///
1929    /// Deltas are drained and discarded per frame: replayed frames are already
1930    /// reflected on disk, so they are not news to a subscriber, and draining
1931    /// inside the loop keeps `pending_deltas` O(1) over a large WAL (I-2).
1932    fn apply_frames(&mut self, records: Vec<WalRecord>) -> Result<usize> {
1933        if records.is_empty() {
1934            return Ok(0);
1935        }
1936        // Materialize any state retained in the mmap base before the first
1937        // frame lands, so a replayed record cannot trip the lazy-init guard and
1938        // rebuild indexes from an empty graph. Both calls are idempotent.
1939        self.ensure_v8_base_sections_loaded();
1940        self.engine.consume_retained_state_eager(
1941            &self.ids,
1942            &self.syms,
1943            &self.labels,
1944            build_props_view(&self.props, &self.base),
1945        );
1946        let applied = records.len();
1947        for rec in records {
1948            self.apply(&rec)?;
1949            let _ = self.engine.drain_deltas();
1950            // Track commit_seq during replay so last_change entries are
1951            // consistent with the seqs assigned by log_then_apply_with on
1952            // subsequent live commits.  After N replayed frames, commit_seq=N;
1953            // live commits begin at N+1.
1954            self.commit_seq += 1;
1955            let replay_seq = self.commit_seq;
1956            self.update_last_change_from_rec(&rec, replay_seq);
1957        }
1958        // Enforce I-2: if the per-frame drain above is ever removed or skipped,
1959        // this assert catches the regression in debug builds immediately.
1960        debug_assert_eq!(
1961            self.engine.pending_delta_count(),
1962            0,
1963            "pending_deltas non-empty after replay — \
1964             per-frame drain must run inside the loop to keep memory O(1)"
1965        );
1966        // T2 note: the per-frame drain IS the suppression seam for replay.
1967        // Any future as-of replay path (Plan-15 T2) must drain here to feed
1968        // replaying subscribers; the mechanism is already in place.
1969        let _ = self.engine.drain_deltas(); // belt-and-braces no-op after loop drain
1970        Ok(applied)
1971    }
1972
1973    // ── Multi-process safety: cross-process write lock + WAL tailing ──────────
1974    //
1975    // mushroomdb is many-readers / one-writer across processes. Writers take an
1976    // advisory exclusive lock on the store's `LOCK` file; readers never do.
1977    // Every handle tracks how much of the WAL it has consumed, so it can pick
1978    // up another process's commits by decoding only the new tail rather than
1979    // reopening. See `docs/site/concurrency.md`.
1980
1981    /// Whether the store on disk has moved ahead of (or out from under) this
1982    /// handle's in-memory state.
1983    ///
1984    /// True when the WAL's length differs from this handle's cursor — another
1985    /// process committed, or is mid-append — or when the snapshot file's
1986    /// identity changed. Costs two metadata lookups and reads no file contents,
1987    /// so it is cheap enough for a read path to call.
1988    ///
1989    /// Always false for an as-of view from [`GraphDb::open_at`]: such a view is
1990    /// pinned to one commit and later commits are deliberately invisible to it.
1991    pub fn is_stale(&self) -> Result<bool> {
1992        if self.pinned {
1993            return Ok(false);
1994        }
1995        if self.fs.wal_len().map_err(GraphError::Io)? != self.wal_consumed {
1996            return Ok(true);
1997        }
1998        Ok(self.fs.snapshot_ident().map_err(GraphError::Io)? != self.snapshot_ident)
1999    }
2000
2001    /// Bring this handle up to date with every commit other processes have made,
2002    /// and return how many frames were applied.
2003    ///
2004    /// The WAL tail is decoded from this handle's cursor and applied through the
2005    /// same path the open replay uses, so rules fire and derived edges appear
2006    /// exactly as they would on a fresh open. Interners, id maps and indexes
2007    /// stay valid for the same reason.
2008    ///
2009    /// A frame another process is still writing is left alone: a trailing
2010    /// partial frame is a wait, not a corruption, and the handle stays stale
2011    /// until that frame is complete. Nothing is written to disk, so a read-only
2012    /// handle can refresh freely.
2013    ///
2014    /// When the snapshot file's identity changed, or the WAL is shorter than
2015    /// this handle's cursor, the WAL no longer continues our state — another
2016    /// process snapshotted or archived. The handle is then rebuilt from disk
2017    /// with the options it was opened with, and the return value is the number
2018    /// of frames in the new WAL.
2019    ///
2020    /// Returns 0 for an as-of view, which never follows later commits.
2021    ///
2022    /// # Errors
2023    ///
2024    /// An error here leaves the handle **degraded**: it got partway through
2025    /// applying the tail, or partway through a reload, so its in-memory state
2026    /// no longer matches any point on disk. Further mutations are refused and
2027    /// the handle must be reopened. Nothing on disk was damaged — the store
2028    /// itself is fine, and a fresh open recovers it.
2029    pub fn refresh(&mut self) -> Result<u64> {
2030        if self.pinned {
2031            return Ok(0);
2032        }
2033        let disk_ident = self.fs.snapshot_ident().map_err(GraphError::Io)?;
2034        let wal_len = self.fs.wal_len().map_err(GraphError::Io)?;
2035        if disk_ident != self.snapshot_ident || wal_len < self.wal_consumed {
2036            // The WAL no longer continues our state: rebuild from disk. State
2037            // is cleared first, so a failed load leaves an empty handle — mark
2038            // it degraded rather than let a caller read an empty graph as if
2039            // it were the store's contents.
2040            self.reset_for_reload();
2041            return match self.load_from_disk(LoadOrigin::Reload) {
2042                Ok(frames) => Ok(frames as u64),
2043                Err(e) => {
2044                    self.degraded = true;
2045                    Err(e)
2046                }
2047            };
2048        }
2049        if wal_len == self.wal_consumed {
2050            return Ok(0);
2051        }
2052        let tail = self
2053            .fs
2054            .read_range(FileId::Wal, self.wal_consumed)
2055            .map_err(GraphError::Io)?;
2056        let (records, valid_len) = decode_all(&tail);
2057        let applied = match self.apply_frames(records) {
2058            Ok(n) => n,
2059            Err(e) => {
2060                // Some frames landed and some did not, and the cursor cannot
2061                // say how many. Advancing it would skip the rest; leaving it
2062                // would replay what already applied. Neither is recoverable in
2063                // place, so refuse further writes and require a reopen.
2064                self.degraded = true;
2065                return Err(e);
2066            }
2067        };
2068        // Advance by the bytes actually decoded, never by the file length: an
2069        // incomplete trailing frame stays unconsumed for the next refresh.
2070        self.wal_consumed += valid_len as u64;
2071        if applied > 0 {
2072            // Peer commits must reach `reader()` snapshots taken from here on.
2073            // A full fold is what open does; refresh does not build per-commit
2074            // deltas, so there is nothing cheaper that stays correct.
2075            self.fold_now();
2076        }
2077        Ok(applied as u64)
2078    }
2079
2080    /// Byte offset of the WAL prefix this handle has applied.
2081    ///
2082    /// Exposed for tests that assert the cursor tracks appended bytes exactly.
2083    #[doc(hidden)]
2084    pub fn wal_consumed(&self) -> u64 {
2085        self.wal_consumed
2086    }
2087
2088    /// Rewind the WAL cursor after the group-commit drain thread truncated a
2089    /// failed group off the tail, so the cursor still describes the file.
2090    pub(crate) fn set_wal_consumed(&mut self, len: u64) {
2091        self.wal_consumed = len;
2092    }
2093
2094    /// One non-blocking attempt at the cross-process write lock.
2095    ///
2096    /// Takes `&self` so a caller can poll for the lock *before* it acquires the
2097    /// in-process write guard. That ordering is what keeps a busy peer in
2098    /// another process from stalling this process's readers.
2099    ///
2100    /// A handle that owns the lock for its lifetime always succeeds.
2101    pub(crate) fn try_cross_process_lock(&self) -> Result<bool> {
2102        if self.holds_lifetime_lock {
2103            return Ok(true);
2104        }
2105        self.fs.try_lock_exclusive().map_err(GraphError::Io)
2106    }
2107
2108    /// Poll for the cross-process write lock until `wait` elapses.
2109    ///
2110    /// One attempt is always made, so a zero wait is a single try. Returns
2111    /// `false` when the lock is still held elsewhere at the deadline; nothing
2112    /// has been written and retrying later is safe.
2113    ///
2114    /// Only the plain-`GraphDb` open path uses this, where the caller owns the
2115    /// handle outright. [`SharedDb`](crate::SharedDb) polls
2116    /// [`try_cross_process_lock`](GraphDb::try_cross_process_lock) itself so
2117    /// that it holds no in-process guard while it waits.
2118    fn poll_lock(&self, wait: std::time::Duration) -> Result<bool> {
2119        let deadline = std::time::Instant::now() + wait;
2120        loop {
2121            if self.try_cross_process_lock()? {
2122                return Ok(true);
2123            }
2124            let now = std::time::Instant::now();
2125            if now >= deadline {
2126                return Ok(false);
2127            }
2128            std::thread::sleep(LOCK_POLL_INTERVAL.min(deadline.saturating_duration_since(now)));
2129        }
2130    }
2131
2132    /// Open a cross-process write scope, given the outcome of an already-made
2133    /// lock attempt.
2134    ///
2135    /// The caller polls for the lock first — outside any in-process guard — and
2136    /// passes what it got. On success this refreshes, so the writes about to
2137    /// happen land on top of every other process's commits. On failure the
2138    /// handle refuses WAL-appending mutations and `snapshot()` with
2139    /// [`GraphError::Busy`] until [`end_write_lock`](GraphDb::end_write_lock)
2140    /// closes the scope, so a caller holding a guard cannot write behind
2141    /// another process's back.
2142    ///
2143    /// A handle that already owns the lock for its lifetime skips the refresh:
2144    /// no other process can have written, so there is nothing to pick up.
2145    pub(crate) fn enter_write_scope(&mut self, acquired: bool) -> Result<()> {
2146        self.lock_denied = !acquired;
2147        if !acquired || self.holds_lifetime_lock {
2148            return Ok(());
2149        }
2150        if let Err(e) = self.refresh() {
2151            // Do not hold a lock we cannot use: release it and let the caller
2152            // see the underlying failure.
2153            let _ = self.fs.unlock();
2154            self.lock_denied = true;
2155            return Err(e);
2156        }
2157        Ok(())
2158    }
2159
2160    /// Close a cross-process write scope opened by
2161    /// [`enter_write_scope`](GraphDb::enter_write_scope): release the lock and
2162    /// clear the Busy latch. Safe to call when the lock was never taken.
2163    pub(crate) fn end_write_lock(&mut self) {
2164        self.lock_denied = false;
2165        if !self.holds_lifetime_lock {
2166            // Releasing a lock we do not hold is a no-op; a failure to release
2167            // is reported by the OS closing the descriptor at handle drop.
2168            let _ = self.fs.unlock();
2169        }
2170    }
2171
2172    /// As-of replay for [`GraphDb::open_at`]: snapshot base (only when the
2173    /// snapshot truncated the WAL) plus the first `commit + 1` WAL frames;
2174    /// see [`GraphDb::open_at`] for the semantics.  The per-frame drain
2175    /// mirrors `open_with` exactly so pending_delta_count is 0 on exit.
2176    /// Restore all persisted state from a decoded snapshot. Shared by
2177    /// `open_with` and (when the snapshot truncated the WAL) `open_at_with`.
2178    fn restore_snapshot_state(
2179        &mut self,
2180        state: core_storage::snapshot::SnapshotState,
2181    ) -> Result<()> {
2182        self.ids = state.ids;
2183        self.syms = state.syms;
2184        self.topo = state.topo;
2185        self.props = state.props;
2186        self.labels = state.labels;
2187        self.edge_props = state.edge_props;
2188        // Cross-section label integrity for V5/V7 snapshots: same invariants as
2189        // restore_v8_base.  A crafted bincode snapshot with a short `labels` vec,
2190        // out-of-range sym ids, or a sentinel label on a live node would otherwise
2191        // open successfully and panic later in `NodeRef::label()` or
2192        // `neighborhood_masked()`.  Catching it here turns those into typed
2193        // `GraphError::Corrupt` at open time.
2194        {
2195            let ids_len = self.ids.len();
2196            if self.labels.len() != ids_len {
2197                return Err(GraphError::Corrupt {
2198                    detail: format!(
2199                        "snapshot: labels vec has {} entries but id table has {} total slots",
2200                        self.labels.len(),
2201                        ids_len,
2202                    ),
2203                });
2204            }
2205            let syms_len = self.syms.len() as u32;
2206            for (i, &sym) in self.labels.iter().enumerate() {
2207                let is_tombstoned = self.ids.is_tombstoned(i as u32);
2208                if sym == u32::MAX {
2209                    if !is_tombstoned {
2210                        return Err(GraphError::Corrupt {
2211                            detail: format!(
2212                                "snapshot: live node at id slot {i} has sentinel label (u32::MAX)"
2213                            ),
2214                        });
2215                    }
2216                } else if sym >= syms_len {
2217                    return Err(GraphError::Corrupt {
2218                        detail: format!(
2219                            "snapshot: label at id slot {i} references sym {sym} \
2220                             which is out of interner range ({syms_len})"
2221                        ),
2222                    });
2223                }
2224            }
2225        }
2226        let defs: Vec<RuleDef> = state
2227            .rule_defs
2228            .iter()
2229            .map(|b| {
2230                decode_rule_def(b).map_err(|e| GraphError::Corrupt {
2231                    detail: format!("snapshot rule_def deserialize: {e}"),
2232                })
2233            })
2234            .collect::<Result<Vec<_>>>()?;
2235        self.engine =
2236            RuleEngine::from_persist(defs, state.provenance, state.rule_tripped, state.rule_fires);
2237        // Candidate indexes are rebuilt lazily on the first mutation (see
2238        // RuleEngine::on_node_changed).  HNSW blobs and IVF centroids from the
2239        // snapshot are retained without deserializing so that:
2240        //   - clean-open (empty WAL): indexes stay empty; blobs load on first
2241        //     ANN query via ensure_hnsw_loaded, or on first mutation via the
2242        //     lazy-init guard which calls reindex_all_load_ivf + load_hnsw_state.
2243        //   - WAL-present: open_with calls consume_retained_state_eager before
2244        //     replay so HNSW/IVF are live before any record fires the hooks.
2245        let ivf_bytes = if state.ivf_state.is_empty() {
2246            Vec::new()
2247        } else {
2248            bincode::serialize(&state.ivf_state).expect("IVF state serialize cannot fail")
2249        };
2250        // Store blobs without eagerly deserializing them.
2251        self.engine
2252            .store_snapshot_state(state.hnsw_state, ivf_bytes);
2253        // Restore view defs from snapshot (V5).
2254        // The ColumnStore already contains view values from the snapshot;
2255        // use restore_view (no collision check, no backfill) so the store
2256        // is aware of the definitions.  rebuild_all runs after WAL replay.
2257        for def_bytes in &state.view_defs {
2258            let def: ViewDef =
2259                bincode::deserialize(def_bytes).map_err(|e| GraphError::Corrupt {
2260                    detail: format!("snapshot view_def deserialize: {e}"),
2261                })?;
2262            self.view_store
2263                .restore_view(def)
2264                .map_err(|e| GraphError::Corrupt {
2265                    detail: format!("snapshot view restore: {e}"),
2266                })?;
2267        }
2268        Ok(())
2269    }
2270
2271    /// Restore all persisted state from a V8 `MappedBase` snapshot, **except**
2272    /// topology (`self.topo` stays empty and serves as the WAL-replay overlay).
2273    ///
2274    /// `self.props` IS fully materialised from the base so that HNSW/IVF blob
2275    /// deserialization and view rebuild have access to all column data.
2276    fn restore_v8_base(&mut self, mapped: Arc<core_storage::v8::MappedBase>) -> Result<()> {
2277        self.ids = archived_to_idmap(mapped.ids().map_err(|e| GraphError::Corrupt {
2278            detail: format!("v8: ids section: {e:?}"),
2279        })?);
2280        self.syms = archived_to_interner(mapped.syms().map_err(|e| GraphError::Corrupt {
2281            detail: format!("v8: syms section: {e:?}"),
2282        })?);
2283
2284        // C1: self.props is left as an empty overlay. Column reads go through
2285        // props_view() (ColumnsView::with_base), which consults the archived base
2286        // section zero-copy. This avoids the O(columns) heap copy at every open.
2287
2288        // self.topo deliberately left as Topology::new() — overlay path.
2289
2290        let meta = decode_meta(mapped.meta_bytes().map_err(|e| GraphError::Corrupt {
2291            detail: format!("v8: meta section: {e:?}"),
2292        })?)
2293        .map_err(|e| GraphError::Corrupt {
2294            detail: format!("v8: meta decode: {e:?}"),
2295        })?;
2296        self.labels = meta.labels;
2297        // Cross-section label integrity: labels must cover every id slot (live
2298        // and tombstoned), every non-sentinel sym must be within the interner's
2299        // bound, and no live (non-tombstoned) node may carry the u32::MAX
2300        // sentinel label.  Without this check, a crafted snapshot where the META
2301        // section (small, CRC-validated) holds a short `labels` vec, out-of-range
2302        // sym ids, or a sentinel label on a live node, would open successfully
2303        // and then panic in `NodeRef::label()`, `neighborhood_masked()`, and
2304        // related read paths.  Catching the inconsistency here converts those
2305        // panics into typed `GraphError::Corrupt` at open time.
2306        {
2307            let ids_len = self.ids.len();
2308            if self.labels.len() != ids_len {
2309                return Err(GraphError::Corrupt {
2310                    detail: format!(
2311                        "v8: labels section has {} entries but id table has {} total slots",
2312                        self.labels.len(),
2313                        ids_len,
2314                    ),
2315                });
2316            }
2317            let syms_len = self.syms.len() as u32;
2318            for (i, &sym) in self.labels.iter().enumerate() {
2319                let is_tombstoned = self.ids.is_tombstoned(i as u32);
2320                if sym == u32::MAX {
2321                    // Sentinel is only valid for tombstoned slots.
2322                    if !is_tombstoned {
2323                        return Err(GraphError::Corrupt {
2324                            detail: format!(
2325                                "v8: live node at id slot {i} has sentinel label (u32::MAX)"
2326                            ),
2327                        });
2328                    }
2329                } else if sym >= syms_len {
2330                    return Err(GraphError::Corrupt {
2331                        detail: format!(
2332                            "v8: label at id slot {i} references sym {sym} \
2333                             which is out of interner range ({syms_len})"
2334                        ),
2335                    });
2336                }
2337            }
2338        }
2339        // C3: self.edge_props stays as an empty overlay.  Reads go through
2340        // edge_props_view() which consults the mmap'd base section zero-copy
2341        // via EdgePropsView::with_base.  No heap decode at open time.
2342
2343        // Restore rule engine.
2344        let (rule_def_bytes, rule_tripped, rule_fires) =
2345            archived_rules_meta_to_owned(mapped.rules_meta_section().map_err(|e| {
2346                GraphError::Corrupt {
2347                    detail: format!("v8: rules_meta section: {e:?}"),
2348                }
2349            })?);
2350        let defs: Vec<RuleDef> = rule_def_bytes
2351            .iter()
2352            .map(|b| {
2353                decode_rule_def(b).map_err(|e| GraphError::Corrupt {
2354                    detail: format!("v8: rule_def deserialize: {e}"),
2355                })
2356            })
2357            .collect::<Result<Vec<_>>>()?;
2358        self.engine = RuleEngine::from_persist(defs, BTreeMap::new(), rule_tripped, rule_fires);
2359        // C4+C5: provenance, HNSW, and IVF sections are NOT read here.
2360        // `ensure_v8_base_sections_loaded` reads them on first use from
2361        // `self.base` (set by the caller immediately after this returns).
2362        // A clean open touches only: header + IDS + SYMS + META + RULES_META.
2363
2364        // Restore view definitions.
2365        let view_defs =
2366            archived_views_to_owned(mapped.views_section().map_err(|e| GraphError::Corrupt {
2367                detail: format!("v8: views section: {e:?}"),
2368            })?);
2369        for def_bytes in &view_defs {
2370            let def: ViewDef =
2371                bincode::deserialize(def_bytes).map_err(|e| GraphError::Corrupt {
2372                    detail: format!("v8: view_def deserialize: {e}"),
2373                })?;
2374            self.view_store
2375                .restore_view(def)
2376                .map_err(|e| GraphError::Corrupt {
2377                    detail: format!("v8: view restore: {e}"),
2378                })?;
2379        }
2380        // Load the last-change map from section 11 (small section; load eagerly).
2381        // Pre-Task-3 snapshots lack this section; `last_change_bytes` returns &[]
2382        // in that case and `decode_last_change_bytes` returns an empty map.
2383        let last_change_raw = mapped
2384            .last_change_bytes()
2385            .map_err(|e| GraphError::Corrupt {
2386                detail: format!("v8: last_change section: {e:?}"),
2387            })?;
2388        self.last_change = decode_last_change_bytes(last_change_raw);
2389
2390        // Validate that all deferred sections (provenance, HNSW, IVF) fit within
2391        // the file.  Pure bounds check — no bytes read, no page faults triggered.
2392        // Catches truncated snapshots at open time before the lazy deferred reads.
2393        mapped.validate_section_bounds().map_err(|e| match e {
2394            GraphError::Corrupt { detail } => GraphError::Corrupt {
2395                detail: format!("v8: section bounds: {detail}"),
2396            },
2397            other => other,
2398        })?;
2399        Ok(())
2400    }
2401
2402    /// Read provenance, HNSW, and IVF sections from the mmap base into the
2403    /// engine's retained fields on first call.  Subsequent calls are a no-op
2404    /// (AtomicBool fast-path).
2405    ///
2406    /// Must be called before any code path that reads or mutates engine
2407    /// provenance, HNSW, or IVF state:
2408    /// - WAL replay (before `consume_retained_state_eager`)
2409    /// - First mutation (`log_then_apply_with`)
2410    /// - Read-only paths (`stats`, `explain`, `node_edges`)
2411    /// - Snapshot (`snapshot_with`)
2412    ///
2413    /// No-op for fresh stores and V5-V7 opens (`self.base` is `None`).
2414    fn ensure_v8_base_sections_loaded(&self) {
2415        use std::sync::atomic::Ordering;
2416        if self.v8_sections_loaded.load(Ordering::Acquire) {
2417            return;
2418        }
2419        let _guard = self
2420            .v8_sections_mutex
2421            .lock()
2422            .expect("v8 sections mutex poisoned");
2423        if self.v8_sections_loaded.load(Ordering::Acquire) {
2424            return; // another caller populated while we waited
2425        }
2426        let _t = std::time::Instant::now();
2427        if let Some(base) = &self.base {
2428            // Provenance: raw rkyv bytes; CRC validated inside section_bytes.
2429            // Bounds are already validated at open time (restore_v8_base →
2430            // validate_section_bounds) — unreachable post-validate_section_bounds;
2431            // unwrap_or_default is a safety belt against impossible errors.
2432            let prov_bytes = base
2433                .provenance_raw_bytes()
2434                .map(|b| b.to_vec())
2435                .unwrap_or_default();
2436            self.engine.store_provenance_bytes(prov_bytes);
2437            // HNSW: decode rkyv blobs into owned map.
2438            let hnsw_state = base
2439                .hnsw_section()
2440                .map(archived_hnsw_to_owned)
2441                .unwrap_or_default();
2442            // IVF: raw bincode bytes; deserialized on first mutation/query.
2443            let ivf_bytes = base.ivf_bytes().map(|b| b.to_vec()).unwrap_or_default();
2444            self.engine.store_snapshot_state(hnsw_state, ivf_bytes);
2445        }
2446        self.v8_sections_loaded.store(true, Ordering::Release);
2447        if std::env::var("MUSHROOMDB_TRACE_OPEN").is_ok() {
2448            eprintln!(
2449                "[MUSHROOMDB_TRACE_OPEN] ensure_v8_base_sections_loaded: {:>9.3?}",
2450                _t.elapsed()
2451            );
2452        }
2453    }
2454
2455    /// Return a `TopologyView` that merges the mmap'd base (when present) with
2456    /// the in-memory WAL overlay.  Used by all read paths in db.rs that need
2457    /// the full merged topology without going through `self.view()`.
2458    fn topo_view(&self) -> TopologyView<'_> {
2459        match self.base {
2460            None => TopologyView::owned(&self.topo),
2461            Some(ref base) => {
2462                // SAFETY: base lives as long as self; section bounds validated at open.
2463                // topology() uses access_unchecked; all field reads are bounds-checked in seam.rs.
2464                let archived = base
2465                    .topology()
2466                    .expect("base topology section bounds validated at open");
2467                TopologyView::with_base(&self.topo, archived)
2468            }
2469        }
2470    }
2471
2472    /// Return a `ColumnsView` that merges the mmap'd base columns (when a V8
2473    /// snapshot is open) with the in-memory WAL overlay.  Reads consult the
2474    /// overlay first, then fall through to the archived base section zero-copy.
2475    fn props_view(&self) -> core_storage::v8::seam::ColumnsView<'_> {
2476        match self.base {
2477            None => core_storage::v8::seam::ColumnsView::owned(&self.props),
2478            Some(ref base) => {
2479                // columns() uses access_unchecked; field reads are bounds-checked in seam.rs.
2480                let archived = base
2481                    .columns()
2482                    .expect("base columns section bounds validated at open");
2483                core_storage::v8::seam::ColumnsView::with_base_cached(
2484                    &self.props,
2485                    archived,
2486                    base.mixed_cache(),
2487                )
2488            }
2489        }
2490    }
2491
2492    /// Return an `EdgePropsView` that merges the mmap'd base edge-props section
2493    /// (when a V8 snapshot is open) with the in-memory WAL overlay.
2494    ///
2495    /// Reads consult the overlay first (for post-snapshot mutations), then fall
2496    /// through to the archived base section zero-copy.  Tombstones in the
2497    /// overlay mask deleted-from-base entries.
2498    fn edge_props_view(&self) -> EdgePropsView<'_> {
2499        match self.base {
2500            None => EdgePropsView::owned(&self.edge_props),
2501            Some(ref base) => {
2502                // edge_props_section() uses access_unchecked; field reads bounds-checked in seam.rs.
2503                let archived = base
2504                    .edge_props_section()
2505                    .expect("base edge_props section bounds validated at open");
2506                EdgePropsView::with_base(&self.edge_props, archived)
2507            }
2508        }
2509    }
2510
2511    fn open_at_with(fs: F, commit: u64) -> Result<Self> {
2512        // An as-of view never writes and is pinned to one commit: it takes no
2513        // cross-process lock and does not follow later commits.
2514        let mut db = Self::new_empty(
2515            fs,
2516            OpenOptions {
2517                repair_wal: false,
2518                auto_migrate: false,
2519                read_only: true,
2520            },
2521        );
2522        db.pinned = true; // read_only is set after replay, but pinning is immediate
2523        db.wal_horizon_floor = db.fs.read_horizon_floor()?;
2524        db.archive_genesis_chain = db.fs.has_genesis_marker();
2525        // Same orphaned-archive cleanup as open_with: floor was written first
2526        // during pruning, so a crash may have left stale archives below floor.
2527        db.cleanup_orphaned_archives()?;
2528        // Collect archive frames (oldest-first) and live WAL frames.
2529        // Archives represent pre-snapshot history; the snapshot captures the
2530        // cumulative state at the time of archiving.  Crash-window guarantee:
2531        //   A: crash before rename → WAL intact, no archive. Reopen: normal.
2532        //   B: crash after rename, before new WAL → archive present, WAL
2533        //      absent. Reopen: snapshot loaded (full state), no WAL replay.
2534        //   C: crash after new baseline WAL written → normal post-archive.
2535        let archive_ns = db.fs.list_archives()?;
2536        let mut archive_frames_all: Vec<WalRecord> = Vec::new();
2537        for n in &archive_ns {
2538            let arc_bytes = db.fs.read_archive(*n)?;
2539            let (arc_frames, _) = decode_all(&arc_bytes);
2540            archive_frames_all.extend(arc_frames);
2541        }
2542        let total_archive_frames = archive_frames_all.len() as u64;
2543
2544        let live_bytes = db.fs.read(FileId::Wal)?;
2545        let (live_records, _valid_len) = decode_all(&live_bytes);
2546        let total_surviving = total_archive_frames + live_records.len() as u64;
2547        // Global total including any pruned history below the horizon floor.
2548        let total = db.wal_horizon_floor + total_surviving;
2549
2550        // Horizon and range check.
2551        if commit < db.wal_horizon_floor {
2552            return Err(GraphError::CommitOutOfRange { commit, total });
2553        }
2554        if commit >= total {
2555            return Err(GraphError::CommitOutOfRange { commit, total });
2556        }
2557
2558        // Local index into surviving frames (0 = first frame of oldest archive).
2559        let local = commit - db.wal_horizon_floor;
2560
2561        if local < total_archive_frames {
2562            // Target commit is in an archive.  Correct replay from empty state
2563            // is only possible when the archive chain is an uninterrupted
2564            // genesis chain (first archive taken from a fresh store, no prior
2565            // WAL truncation) and no archives have been pruned (floor == 0).
2566            //
2567            // If either condition is violated the prefix needed to reconstruct
2568            // the requested state is gone; refuse rather than return wrong data.
2569            if db.wal_horizon_floor > 0 || !db.archive_genesis_chain {
2570                return Err(GraphError::CommitOutOfRange { commit, total });
2571            }
2572            // Replay all archive frames up to and including the target commit
2573            // from an empty database state.  Archives must be replayed in order
2574            // so that dense-id intern tables are built up correctly.
2575            for rec in archive_frames_all.into_iter().take((local + 1) as usize) {
2576                db.apply(&rec)?;
2577                let _ = db.engine.drain_deltas();
2578            }
2579        } else {
2580            // Target commit is in the live WAL: load snapshot as base, then
2581            // replay the needed live WAL prefix.
2582            //
2583            // Base state: a truncating snapshot (wal_truncated=true) compacts
2584            // all pre-truncation / pre-archive commits.  Dense-id records in
2585            // the live WAL reference ids/interns that the snapshot provides.
2586            // Peek 6 bytes (same pattern as open_with).
2587            let snap_header = db.fs.read_prefix(FileId::Snapshot, 6)?;
2588            let is_v8 = snap_header.len() >= 6
2589                && &snap_header[0..4] == b"GDB1"
2590                && u16::from_le_bytes([snap_header[4], snap_header[5]])
2591                    == core_storage::snapshot::VERSION_8;
2592            if is_v8 {
2593                let state = if let Some(snap_path) = db.fs.snapshot_path() {
2594                    let mapped = core_storage::v8::MappedBase::map(&snap_path).map_err(|e| {
2595                        GraphError::Corrupt {
2596                            detail: format!("v8: open_at mmap: {e:?}"),
2597                        }
2598                    })?;
2599                    core_storage::snapshot::decode_v8_from_mapped(&mapped)?
2600                } else {
2601                    let snap_bytes = db.fs.read(FileId::Snapshot)?;
2602                    core_storage::snapshot::decode(&snap_bytes)?
2603                };
2604                if let Some(state) = state {
2605                    if state.wal_truncated {
2606                        db.restore_snapshot_state(state)?;
2607                    }
2608                }
2609            } else if !snap_header.is_empty() {
2610                let snap_bytes = db.fs.read(FileId::Snapshot)?;
2611                if let Some(state) = core_storage::snapshot::decode(&snap_bytes)? {
2612                    if state.wal_truncated {
2613                        db.restore_snapshot_state(state)?;
2614                    }
2615                }
2616            }
2617            // else: snap_header empty = no snapshot file.
2618            let live_local = local - total_archive_frames;
2619            for rec in live_records.into_iter().take((live_local + 1) as usize) {
2620                db.apply(&rec)?;
2621                let _ = db.engine.drain_deltas();
2622            }
2623        }
2624        // Pin: pending_delta_count must be 0 after as-of replay, mirroring T1's
2625        // post-loop assert in open_with.
2626        debug_assert_eq!(
2627            db.engine.pending_delta_count(),
2628            0,
2629            "pending_deltas non-empty after open_at replay — \
2630             per-frame drain must run inside the loop to keep memory O(1)"
2631        );
2632        let _ = db.engine.drain_deltas(); // belt-and-braces no-op
2633                                          // Rebuild view values after WAL replay so derived-edge-driven views
2634                                          // reflect the as-of state.  open_at always uses the legacy path (no V8
2635                                          // base), so topo_view is always owned.
2636        {
2637            let topo_view = TopologyView::owned(&db.topo);
2638            db.view_store
2639                .rebuild_all(&mut db.props, &topo_view, &db.ids, &db.syms, &db.labels);
2640        }
2641        // Rebuild full-text index for as-of view (mirrors open_with pattern).
2642        db.fulltext.rebuild_all(
2643            &db.ids,
2644            &db.labels,
2645            &db.syms,
2646            build_props_view(&db.props, &db.base),
2647        );
2648        db.prop_index.rebuild_all(
2649            &db.ids,
2650            &db.labels,
2651            &db.syms,
2652            build_props_view(&db.props, &db.base),
2653        );
2654        // Load roles sidecar (current roles, not point-in-time).
2655        db.roles = Self::load_roles_from_fs(&db.fs)?;
2656        db.read_only = true;
2657        db.total_wal_commits = total;
2658        // Capture initial fold so reader() is immediately usable.
2659        db.fold_now();
2660        Ok(db)
2661    }
2662
2663    /// Whether this instance is a read-only as-of view.
2664    pub fn is_read_only(&self) -> bool {
2665        self.read_only
2666    }
2667
2668    // ── MVCC epoch reader ─────────────────────────────────────────────────────
2669
2670    /// Clone the current overlay state into a new `FrozenOverlay` and reset
2671    /// the delta tail. Called automatically every `FOLD_EVERY_K` commits and at
2672    /// the end of `open_with` / `open_at_with` to prime the reader.
2673    fn fold_now(&mut self) {
2674        let frozen = crate::reader::FrozenOverlay {
2675            ids: self.ids.clone(),
2676            syms: self.syms.clone(),
2677            topo: self.topo.clone(),
2678            props: self.props.clone(),
2679            labels: self.labels.clone(),
2680            edge_props: self.edge_props.clone(),
2681            roles: self.roles.clone(),
2682            fulltext: self.fulltext.clone(),
2683        };
2684        self.fold_overlay = Some(Arc::new(frozen));
2685        self.delta_tail.clear();
2686        self.commits_since_fold = 0;
2687    }
2688
2689    /// Capture a lock-free reader snapshot of the current db state.
2690    ///
2691    /// The read lock is held only for the duration of this call (to clone a
2692    /// handful of `Arc` handles). Subsequent query operations run without any
2693    /// lock.
2694    pub fn reader(&self) -> crate::reader::ReaderSnapshot {
2695        crate::reader::ReaderSnapshot::new(
2696            self.fold_overlay
2697                .clone()
2698                .expect("fold_overlay is always Some after open_with; call reader() after open"),
2699            self.base.clone(),
2700            self.delta_tail.clone(),
2701        )
2702    }
2703
2704    /// Total number of WAL commits at the time [`open_at`] was called.
2705    /// Returns 0 for normal (non-as-of) instances.
2706    pub fn total_wal_commits(&self) -> u64 {
2707        self.total_wal_commits
2708    }
2709
2710    /// Apply a record to in-memory state. Used by both live writes and replay,
2711    /// so replay is definitionally identical to the original execution.
2712    fn apply(&mut self, rec: &WalRecord) -> Result<()> {
2713        match rec {
2714            WalRecord::InsertNode { label, key, props } => {
2715                let id = self.ids.try_insert(key)?;
2716                let sym = self.syms.intern(label);
2717                if self.labels.len() <= id as usize {
2718                    // gap slots are sentinels, never valid label symbols
2719                    self.labels.resize(id as usize + 1, u32::MAX);
2720                }
2721                self.labels[id as usize] = sym;
2722                for (field, value) in props {
2723                    self.props.set(id, field, value.clone());
2724                }
2725                // Initialize view values for the new node before the engine runs so
2726                // delta-based increments start from a known zero baseline.
2727                self.view_store
2728                    .init_node_views(id, &mut self.props, &self.syms, &self.labels);
2729                // Fire rules for the newly inserted node.
2730                let cursor = self.engine.pending_delta_count();
2731                let mut eng = std::mem::take(&mut self.engine);
2732                {
2733                    let mut gm = make_graph_mut(
2734                        &self.ids,
2735                        &mut self.syms,
2736                        &self.labels,
2737                        build_props_view(&self.props, &self.base),
2738                        &mut self.topo,
2739                        &self.base,
2740                        &mut self.edge_props,
2741                    );
2742                    eng.on_node_changed(id, None, &mut gm);
2743                }
2744                self.engine = eng;
2745                // Process derived-edge deltas for view maintenance.
2746                // Fast path: skip the O(delta_count) allocation when no views exist.
2747                if !self.view_store.is_empty() {
2748                    #[cfg(test)]
2749                    DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
2750                    let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
2751                    for d in &new_deltas {
2752                        self.view_store.on_edge_changed(
2753                            d.etype_sym,
2754                            d.src_id,
2755                            d.dst_id,
2756                            d.fired,
2757                            &mut self.props,
2758                            &build_topo_view(&self.topo, &self.base),
2759                            &self.ids,
2760                            &self.syms,
2761                            &self.labels,
2762                            self.base.as_ref().map(|b| {
2763                                b.columns()
2764                                    .expect("base columns section bounds validated at open")
2765                            }),
2766                        );
2767                    }
2768                }
2769                // Full-text index maintenance: index enabled fields for this label.
2770                if self.fulltext.has_label(label) {
2771                    for (field, value) in props {
2772                        if self.fulltext.is_enabled(label, field) {
2773                            self.fulltext.add_tokens(id, field, value);
2774                        }
2775                    }
2776                }
2777                // Property (equality) index maintenance.
2778                if self.prop_index.has_label(label) {
2779                    for (field, value) in props {
2780                        self.prop_index.set(label, field, id, value);
2781                    }
2782                }
2783            }
2784            WalRecord::InsertEdge {
2785                edge_type,
2786                src_key,
2787                dst_key,
2788            } => {
2789                let src = self.ids.get(src_key).ok_or_else(|| GraphError::Corrupt {
2790                    detail: format!("wal replay references unknown key {src_key}"),
2791                })?;
2792                let dst = self.ids.get(dst_key).ok_or_else(|| GraphError::Corrupt {
2793                    detail: format!("wal replay references unknown key {dst_key}"),
2794                })?;
2795                let etype = self.syms.intern(edge_type);
2796                // Skip if the edge is already visible in the merged base+overlay
2797                // view.  This keeps WAL replay idempotent when the WAL contains
2798                // pre-snapshot records that are already encoded in a V8 base
2799                // (keep_wal=true opens and crash-before-truncation scenarios).
2800                if self.base.is_some()
2801                    && self
2802                        .topo_view()
2803                        .neighbors(etype, Direction::Out, src)
2804                        .contains(&dst)
2805                {
2806                    return Ok(());
2807                }
2808                self.topo.add_edge(etype, src, dst);
2809                // View maintenance for manual edge insert.
2810                self.view_store.on_edge_changed(
2811                    etype,
2812                    src,
2813                    dst,
2814                    true,
2815                    &mut self.props,
2816                    &build_topo_view(&self.topo, &self.base),
2817                    &self.ids,
2818                    &self.syms,
2819                    &self.labels,
2820                    self.base.as_ref().map(|b| {
2821                        b.columns()
2822                            .expect("base columns section bounds validated at open")
2823                    }),
2824                );
2825                // Rule engine: via-hop rules must update when user edges change.
2826                let cursor = self.engine.pending_delta_count();
2827                let mut eng = std::mem::take(&mut self.engine);
2828                {
2829                    let mut gm = make_graph_mut(
2830                        &self.ids,
2831                        &mut self.syms,
2832                        &self.labels,
2833                        build_props_view(&self.props, &self.base),
2834                        &mut self.topo,
2835                        &self.base,
2836                        &mut self.edge_props,
2837                    );
2838                    eng.on_edge_changed(edge_type, src, dst, &mut gm);
2839                }
2840                self.engine = eng;
2841                if !self.view_store.is_empty() {
2842                    let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
2843                    for d in &new_deltas {
2844                        self.view_store.on_edge_changed(
2845                            d.etype_sym,
2846                            d.src_id,
2847                            d.dst_id,
2848                            d.fired,
2849                            &mut self.props,
2850                            &build_topo_view(&self.topo, &self.base),
2851                            &self.ids,
2852                            &self.syms,
2853                            &self.labels,
2854                            self.base.as_ref().map(|b| {
2855                                b.columns()
2856                                    .expect("base columns section bounds validated at open")
2857                            }),
2858                        );
2859                    }
2860                }
2861            }
2862            WalRecord::SetProp { key, field, value } => {
2863                let id = self.ids.get(key).ok_or_else(|| GraphError::Corrupt {
2864                    detail: format!("wal replay references unknown key {key}"),
2865                })?;
2866                let old_value = build_props_view(&self.props, &self.base)
2867                    .get(id, field)
2868                    .map(|vr| vr.into_value());
2869                self.props.set(id, field, value.clone());
2870                // Fire rules for the changed field.
2871                let cursor = self.engine.pending_delta_count();
2872                let mut eng = std::mem::take(&mut self.engine);
2873                {
2874                    let mut gm = make_graph_mut(
2875                        &self.ids,
2876                        &mut self.syms,
2877                        &self.labels,
2878                        build_props_view(&self.props, &self.base),
2879                        &mut self.topo,
2880                        &self.base,
2881                        &mut self.edge_props,
2882                    );
2883                    eng.on_node_changed(id, Some((field, old_value)), &mut gm);
2884                }
2885                self.engine = eng;
2886                // Derived-edge deltas → view updates.
2887                if !self.view_store.is_empty() {
2888                    #[cfg(test)]
2889                    DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
2890                    let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
2891                    for d in &new_deltas {
2892                        self.view_store.on_edge_changed(
2893                            d.etype_sym,
2894                            d.src_id,
2895                            d.dst_id,
2896                            d.fired,
2897                            &mut self.props,
2898                            &build_topo_view(&self.topo, &self.base),
2899                            &self.ids,
2900                            &self.syms,
2901                            &self.labels,
2902                            self.base.as_ref().map(|b| {
2903                                b.columns()
2904                                    .expect("base columns section bounds validated at open")
2905                            }),
2906                        );
2907                    }
2908                }
2909                // Neighbor-aggregate views that read `field` must also update.
2910                self.view_store.on_prop_changed(
2911                    id,
2912                    field,
2913                    &mut self.props,
2914                    &build_topo_view(&self.topo, &self.base),
2915                    &self.ids,
2916                    &self.syms,
2917                    &self.labels,
2918                    self.base.as_ref().map(|b| {
2919                        b.columns()
2920                            .expect("base columns section bounds validated at open")
2921                    }),
2922                );
2923                // Full-text index maintenance: update tokens for this field if indexed.
2924                if self.fulltext.field_indexed(field) {
2925                    let label_opt = self.labels.get(id as usize).and_then(|&sym| {
2926                        if sym == u32::MAX {
2927                            None
2928                        } else {
2929                            self.syms.resolve(sym)
2930                        }
2931                    });
2932                    if let Some(label) = label_opt {
2933                        if self.fulltext.is_enabled(label, field) {
2934                            self.fulltext.remove_node_field(id, field);
2935                            self.fulltext.add_tokens(id, field, value);
2936                        }
2937                    }
2938                }
2939                // Property (equality) index maintenance: re-key this node's value.
2940                if self.prop_index.field_indexed(field) {
2941                    let label_opt = self.labels.get(id as usize).and_then(|&sym| {
2942                        if sym == u32::MAX {
2943                            None
2944                        } else {
2945                            self.syms.resolve(sym)
2946                        }
2947                    });
2948                    if let Some(label) = label_opt {
2949                        self.prop_index.set(label, field, id, value);
2950                    }
2951                }
2952            }
2953            WalRecord::Intern { id, text } => {
2954                if let Some(existing) = self.syms.get(text) {
2955                    if existing != *id {
2956                        return Err(GraphError::Corrupt {
2957                            detail: format!(
2958                                "wal intern mismatch for {text:?}: have {existing}, record {id}"
2959                            ),
2960                        });
2961                    }
2962                } else {
2963                    let got = self.syms.intern(text);
2964                    if got != *id {
2965                        return Err(GraphError::Corrupt {
2966                            detail: format!(
2967                                "wal intern assigned {got} for {text:?}, record wanted {id}"
2968                            ),
2969                        });
2970                    }
2971                }
2972            }
2973            WalRecord::InsertNodeId { label, key, props } => {
2974                let id = self.ids.try_insert(key)?;
2975                if self.labels.len() <= id as usize {
2976                    self.labels.resize(id as usize + 1, u32::MAX);
2977                }
2978                self.labels[id as usize] = *label;
2979                let label_str = self
2980                    .syms
2981                    .resolve(*label)
2982                    .ok_or_else(|| GraphError::Corrupt {
2983                        detail: format!("wal InsertNodeId unknown label intern {label}"),
2984                    })?
2985                    .to_string();
2986                for (field_sym, value) in props {
2987                    let field =
2988                        self.syms
2989                            .resolve(*field_sym)
2990                            .ok_or_else(|| GraphError::Corrupt {
2991                                detail: format!(
2992                                    "wal InsertNodeId unknown field intern {field_sym}"
2993                                ),
2994                            })?;
2995                    self.props.set(id, field, value.clone());
2996                }
2997                self.view_store
2998                    .init_node_views(id, &mut self.props, &self.syms, &self.labels);
2999                let cursor = self.engine.pending_delta_count();
3000                let mut eng = std::mem::take(&mut self.engine);
3001                {
3002                    let mut gm = make_graph_mut(
3003                        &self.ids,
3004                        &mut self.syms,
3005                        &self.labels,
3006                        build_props_view(&self.props, &self.base),
3007                        &mut self.topo,
3008                        &self.base,
3009                        &mut self.edge_props,
3010                    );
3011                    eng.on_node_changed(id, None, &mut gm);
3012                }
3013                self.engine = eng;
3014                if !self.view_store.is_empty() {
3015                    #[cfg(test)]
3016                    DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
3017                    let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
3018                    for d in &new_deltas {
3019                        self.view_store.on_edge_changed(
3020                            d.etype_sym,
3021                            d.src_id,
3022                            d.dst_id,
3023                            d.fired,
3024                            &mut self.props,
3025                            &build_topo_view(&self.topo, &self.base),
3026                            &self.ids,
3027                            &self.syms,
3028                            &self.labels,
3029                            self.base.as_ref().map(|b| {
3030                                b.columns()
3031                                    .expect("base columns section bounds validated at open")
3032                            }),
3033                        );
3034                    }
3035                }
3036                if self.fulltext.has_label(&label_str) {
3037                    for (field_sym, value) in props {
3038                        let Some(field) = self.syms.resolve(*field_sym) else {
3039                            continue;
3040                        };
3041                        if self.fulltext.is_enabled(&label_str, field) {
3042                            self.fulltext.add_tokens(id, field, value);
3043                        }
3044                    }
3045                }
3046                if self.prop_index.has_label(&label_str) {
3047                    for (field_sym, value) in props {
3048                        let Some(field) = self.syms.resolve(*field_sym) else {
3049                            continue;
3050                        };
3051                        self.prop_index.set(&label_str, field, id, value);
3052                    }
3053                }
3054            }
3055            WalRecord::InsertEdgeId { etype, src, dst } => {
3056                // Replay-over-snapshot: dense ids in the pre-snapshot WAL may
3057                // already be tombstoned. Skip rather than attaching edges to
3058                // dead ids (DeleteNode keys the live re-insert, not the old id).
3059                if self.ids.is_tombstoned(*src)
3060                    || self.ids.is_tombstoned(*dst)
3061                    || self.ids.key_of(*src).is_none()
3062                    || self.ids.key_of(*dst).is_none()
3063                {
3064                    return Ok(());
3065                }
3066                // Skip if already visible in the merged view (same idempotency
3067                // guard as InsertEdge above: prevents double-counting when
3068                // pre-snapshot WAL records are replayed over a V8 base).
3069                if self.base.is_some()
3070                    && self
3071                        .topo_view()
3072                        .neighbors(*etype, Direction::Out, *src)
3073                        .contains(dst)
3074                {
3075                    return Ok(());
3076                }
3077                self.topo.add_edge(*etype, *src, *dst);
3078                self.view_store.on_edge_changed(
3079                    *etype,
3080                    *src,
3081                    *dst,
3082                    true,
3083                    &mut self.props,
3084                    &build_topo_view(&self.topo, &self.base),
3085                    &self.ids,
3086                    &self.syms,
3087                    &self.labels,
3088                    self.base.as_ref().map(|b| {
3089                        b.columns()
3090                            .expect("base columns section bounds validated at open")
3091                    }),
3092                );
3093                // Rule engine: via-hop rules fire when user via-edges are inserted.
3094                // Resolve etype back to string so on_edge_changed can match rules by name.
3095                if let Some(etype_str) = self.syms.resolve(*etype).map(|s| s.to_string()) {
3096                    let cursor = self.engine.pending_delta_count();
3097                    let mut eng = std::mem::take(&mut self.engine);
3098                    {
3099                        let mut gm = make_graph_mut(
3100                            &self.ids,
3101                            &mut self.syms,
3102                            &self.labels,
3103                            build_props_view(&self.props, &self.base),
3104                            &mut self.topo,
3105                            &self.base,
3106                            &mut self.edge_props,
3107                        );
3108                        eng.on_edge_changed(&etype_str, *src, *dst, &mut gm);
3109                    }
3110                    self.engine = eng;
3111                    if !self.view_store.is_empty() {
3112                        let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
3113                        for d in &new_deltas {
3114                            self.view_store.on_edge_changed(
3115                                d.etype_sym,
3116                                d.src_id,
3117                                d.dst_id,
3118                                d.fired,
3119                                &mut self.props,
3120                                &build_topo_view(&self.topo, &self.base),
3121                                &self.ids,
3122                                &self.syms,
3123                                &self.labels,
3124                                self.base.as_ref().map(|b| {
3125                                    b.columns()
3126                                        .expect("base columns section bounds validated at open")
3127                                }),
3128                            );
3129                        }
3130                    }
3131                }
3132            }
3133            WalRecord::SetPropId { id, field, value } => {
3134                if self.ids.is_tombstoned(*id) || self.ids.key_of(*id).is_none() {
3135                    return Ok(());
3136                }
3137                let field_str = self
3138                    .syms
3139                    .resolve(*field)
3140                    .ok_or_else(|| GraphError::Corrupt {
3141                        detail: format!("wal SetPropId unknown field intern {field}"),
3142                    })?
3143                    .to_string();
3144                let old_value = build_props_view(&self.props, &self.base)
3145                    .get(*id, &field_str)
3146                    .map(|vr| vr.into_value());
3147                self.props.set(*id, &field_str, value.clone());
3148                let cursor = self.engine.pending_delta_count();
3149                let mut eng = std::mem::take(&mut self.engine);
3150                {
3151                    let mut gm = make_graph_mut(
3152                        &self.ids,
3153                        &mut self.syms,
3154                        &self.labels,
3155                        build_props_view(&self.props, &self.base),
3156                        &mut self.topo,
3157                        &self.base,
3158                        &mut self.edge_props,
3159                    );
3160                    eng.on_node_changed(*id, Some((field_str.as_str(), old_value)), &mut gm);
3161                }
3162                self.engine = eng;
3163                if !self.view_store.is_empty() {
3164                    #[cfg(test)]
3165                    DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
3166                    let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
3167                    for d in &new_deltas {
3168                        self.view_store.on_edge_changed(
3169                            d.etype_sym,
3170                            d.src_id,
3171                            d.dst_id,
3172                            d.fired,
3173                            &mut self.props,
3174                            &build_topo_view(&self.topo, &self.base),
3175                            &self.ids,
3176                            &self.syms,
3177                            &self.labels,
3178                            self.base.as_ref().map(|b| {
3179                                b.columns()
3180                                    .expect("base columns section bounds validated at open")
3181                            }),
3182                        );
3183                    }
3184                }
3185                self.view_store.on_prop_changed(
3186                    *id,
3187                    &field_str,
3188                    &mut self.props,
3189                    &build_topo_view(&self.topo, &self.base),
3190                    &self.ids,
3191                    &self.syms,
3192                    &self.labels,
3193                    self.base.as_ref().map(|b| {
3194                        b.columns()
3195                            .expect("base columns section bounds validated at open")
3196                    }),
3197                );
3198                if self.fulltext.field_indexed(&field_str) {
3199                    let label_opt = self.labels.get(*id as usize).and_then(|&sym| {
3200                        if sym == u32::MAX {
3201                            None
3202                        } else {
3203                            self.syms.resolve(sym)
3204                        }
3205                    });
3206                    if let Some(label) = label_opt {
3207                        if self.fulltext.is_enabled(label, &field_str) {
3208                            self.fulltext.remove_node_field(*id, &field_str);
3209                            self.fulltext.add_tokens(*id, &field_str, value);
3210                        }
3211                    }
3212                }
3213                if self.prop_index.field_indexed(&field_str) {
3214                    let label_opt = self.labels.get(*id as usize).and_then(|&sym| {
3215                        if sym == u32::MAX {
3216                            None
3217                        } else {
3218                            self.syms.resolve(sym)
3219                        }
3220                    });
3221                    if let Some(label) = label_opt {
3222                        self.prop_index.set(label, &field_str, *id, value);
3223                    }
3224                }
3225            }
3226            WalRecord::CreateRule { def_bytes } => {
3227                let def: RuleDef = decode_rule_def(def_bytes).map_err(|e| GraphError::Corrupt {
3228                    detail: format!("CreateRule def_bytes deserialize failed: {e}"),
3229                })?;
3230                // Replay-over-snapshot idempotency: the rule was captured in the snapshot
3231                // so the engine already has it; silently skip to avoid a spurious
3232                // RuleInvalid error in the crash window between snapshot write and WAL
3233                // truncation.
3234                if self.engine.rules().any(|r| r.name == def.name) {
3235                    return Ok(());
3236                }
3237                let cursor = self.engine.pending_delta_count();
3238                let mut eng = std::mem::take(&mut self.engine);
3239                let result = {
3240                    let mut gm = make_graph_mut(
3241                        &self.ids,
3242                        &mut self.syms,
3243                        &self.labels,
3244                        build_props_view(&self.props, &self.base),
3245                        &mut self.topo,
3246                        &self.base,
3247                        &mut self.edge_props,
3248                    );
3249                    eng.create_rule(def, &mut gm)
3250                };
3251                self.engine = eng;
3252                result.map_err(|e| GraphError::RuleInvalid { detail: e })?;
3253                // Derived-edge fires from backfill → view updates.
3254                // Fast path: skip O(edge_count) allocation when no views exist.
3255                if !self.view_store.is_empty() {
3256                    #[cfg(test)]
3257                    DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
3258                    let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
3259                    for d in &new_deltas {
3260                        self.view_store.on_edge_changed(
3261                            d.etype_sym,
3262                            d.src_id,
3263                            d.dst_id,
3264                            d.fired,
3265                            &mut self.props,
3266                            &build_topo_view(&self.topo, &self.base),
3267                            &self.ids,
3268                            &self.syms,
3269                            &self.labels,
3270                            self.base.as_ref().map(|b| {
3271                                b.columns()
3272                                    .expect("base columns section bounds validated at open")
3273                            }),
3274                        );
3275                    }
3276                }
3277            }
3278            WalRecord::DeleteRule { name } => {
3279                // Replay-over-snapshot idempotency: the snapshot already captured the
3280                // post-delete state so the rule is absent; silently skip to avoid a
3281                // spurious RuleNotFound error in the crash window between snapshot write
3282                // and WAL truncation.
3283                if !self.engine.rules().any(|r| r.name == *name) {
3284                    return Ok(());
3285                }
3286                let cursor = self.engine.pending_delta_count();
3287                let mut eng = std::mem::take(&mut self.engine);
3288                let result = {
3289                    let mut gm = make_graph_mut(
3290                        &self.ids,
3291                        &mut self.syms,
3292                        &self.labels,
3293                        build_props_view(&self.props, &self.base),
3294                        &mut self.topo,
3295                        &self.base,
3296                        &mut self.edge_props,
3297                    );
3298                    eng.delete_rule(name, &mut gm)
3299                };
3300                self.engine = eng;
3301                result.map_err(|_| GraphError::RuleNotFound { name: name.clone() })?;
3302                // Derived-edge retractions → view updates.
3303                if !self.view_store.is_empty() {
3304                    #[cfg(test)]
3305                    DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
3306                    let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
3307                    for d in &new_deltas {
3308                        self.view_store.on_edge_changed(
3309                            d.etype_sym,
3310                            d.src_id,
3311                            d.dst_id,
3312                            d.fired,
3313                            &mut self.props,
3314                            &build_topo_view(&self.topo, &self.base),
3315                            &self.ids,
3316                            &self.syms,
3317                            &self.labels,
3318                            self.base.as_ref().map(|b| {
3319                                b.columns()
3320                                    .expect("base columns section bounds validated at open")
3321                            }),
3322                        );
3323                    }
3324                }
3325            }
3326            WalRecord::RemoveProp { key, field } => {
3327                // Recovery-safe: unknown key or already-absent field is a
3328                // clean no-op. Crash-window replay over a snapshot that
3329                // already applied this record must not Err.
3330                let Some(id) = self.ids.get(key) else {
3331                    return Ok(());
3332                };
3333                // Read old value through the seam for rule retraction.
3334                let old = build_props_view(&self.props, &self.base)
3335                    .get(id, field)
3336                    .map(|vr| vr.into_value());
3337                self.props.remove(id, field);
3338                // If the base still supplies the value after the overlay removal,
3339                // record a tombstone so ColumnsView::get does not resurrect it.
3340                // This covers both the base-only case AND the both-resident case:
3341                //   base-only (in_overlay=false): old prop was only in base, remove
3342                //     is a no-op on overlay, base still visible → tombstone needed.
3343                //   both-resident (in_overlay=true): overlay had v2, base has v1;
3344                //     removing overlay uncovers v1 → tombstone needed.
3345                // Idempotent on double-replay: second pass sees the tombstone →
3346                // get() returns None → condition is false → no duplicate tombstone.
3347                if build_props_view(&self.props, &self.base)
3348                    .get(id, field)
3349                    .is_some()
3350                {
3351                    self.props.record_prop_tombstone(id, field);
3352                }
3353                let cursor = self.engine.pending_delta_count();
3354                let mut eng = std::mem::take(&mut self.engine);
3355                {
3356                    let mut gm = make_graph_mut(
3357                        &self.ids,
3358                        &mut self.syms,
3359                        &self.labels,
3360                        build_props_view(&self.props, &self.base),
3361                        &mut self.topo,
3362                        &self.base,
3363                        &mut self.edge_props,
3364                    );
3365                    eng.on_node_changed(id, Some((field, old)), &mut gm);
3366                }
3367                self.engine = eng;
3368                // Derived-edge deltas → view updates.
3369                if !self.view_store.is_empty() {
3370                    #[cfg(test)]
3371                    DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
3372                    let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
3373                    for d in &new_deltas {
3374                        self.view_store.on_edge_changed(
3375                            d.etype_sym,
3376                            d.src_id,
3377                            d.dst_id,
3378                            d.fired,
3379                            &mut self.props,
3380                            &build_topo_view(&self.topo, &self.base),
3381                            &self.ids,
3382                            &self.syms,
3383                            &self.labels,
3384                            self.base.as_ref().map(|b| {
3385                                b.columns()
3386                                    .expect("base columns section bounds validated at open")
3387                            }),
3388                        );
3389                    }
3390                }
3391                // Neighbor-aggregate views that read `field` must also update.
3392                self.view_store.on_prop_changed(
3393                    id,
3394                    field,
3395                    &mut self.props,
3396                    &build_topo_view(&self.topo, &self.base),
3397                    &self.ids,
3398                    &self.syms,
3399                    &self.labels,
3400                    self.base.as_ref().map(|b| {
3401                        b.columns()
3402                            .expect("base columns section bounds validated at open")
3403                    }),
3404                );
3405                // Full-text index maintenance: remove tokens for this field.
3406                if self.fulltext.field_indexed(field) {
3407                    self.fulltext.remove_node_field(id, field);
3408                }
3409                // Property (equality) index maintenance: drop this node's entry.
3410                if self.prop_index.field_indexed(field) {
3411                    if let Some(label) = self.labels.get(id as usize).and_then(|&sym| {
3412                        (sym != u32::MAX).then(|| self.syms.resolve(sym)).flatten()
3413                    }) {
3414                        self.prop_index.remove_node(label, field, id);
3415                    }
3416                }
3417            }
3418            WalRecord::DeleteEdge {
3419                edge_type,
3420                src_key,
3421                dst_key,
3422            } => {
3423                // Recovery-safe: unknown keys, unknown etype, or already-
3424                // absent edge is a clean no-op (remove_edge returns false).
3425                let Some(src) = self.ids.get(src_key) else {
3426                    return Ok(());
3427                };
3428                let Some(dst) = self.ids.get(dst_key) else {
3429                    return Ok(());
3430                };
3431                let Some(etype) = self.syms.get(edge_type) else {
3432                    return Ok(());
3433                };
3434                // I3: phantom-tombstone guard.  When a V8 base is present, a
3435                // DeleteEdge WAL record for an edge that was already absorbed into
3436                // the new base (i.e. neither in overlay nor in base) must be skipped.
3437                // Without this guard, remove_edge records a tombstone for an edge
3438                // that no longer exists, incorrectly understating edge_count.
3439                if self.base.is_some()
3440                    && !self
3441                        .topo_view()
3442                        .neighbors(etype, core_storage::topology::Direction::Out, src)
3443                        .contains(&dst)
3444                {
3445                    return Ok(());
3446                }
3447                self.topo.remove_edge(etype, src, dst);
3448                self.edge_props.remove_edge(etype, src, dst);
3449                // View maintenance for manual edge delete (topo already updated above).
3450                self.view_store.on_edge_changed(
3451                    etype,
3452                    src,
3453                    dst,
3454                    false,
3455                    &mut self.props,
3456                    &build_topo_view(&self.topo, &self.base),
3457                    &self.ids,
3458                    &self.syms,
3459                    &self.labels,
3460                    self.base.as_ref().map(|b| {
3461                        b.columns()
3462                            .expect("base columns section bounds validated at open")
3463                    }),
3464                );
3465                // Rule engine: via-hop rules must retract when user via-edges are deleted.
3466                let cursor = self.engine.pending_delta_count();
3467                let mut eng = std::mem::take(&mut self.engine);
3468                {
3469                    let mut gm = make_graph_mut(
3470                        &self.ids,
3471                        &mut self.syms,
3472                        &self.labels,
3473                        build_props_view(&self.props, &self.base),
3474                        &mut self.topo,
3475                        &self.base,
3476                        &mut self.edge_props,
3477                    );
3478                    eng.on_edge_changed(edge_type, src, dst, &mut gm);
3479                }
3480                self.engine = eng;
3481                if !self.view_store.is_empty() {
3482                    let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
3483                    for d in &new_deltas {
3484                        self.view_store.on_edge_changed(
3485                            d.etype_sym,
3486                            d.src_id,
3487                            d.dst_id,
3488                            d.fired,
3489                            &mut self.props,
3490                            &build_topo_view(&self.topo, &self.base),
3491                            &self.ids,
3492                            &self.syms,
3493                            &self.labels,
3494                            self.base.as_ref().map(|b| {
3495                                b.columns()
3496                                    .expect("base columns section bounds validated at open")
3497                            }),
3498                        );
3499                    }
3500                }
3501            }
3502            WalRecord::DeleteNode { key } => {
3503                // Recovery-safe: already-tombstoned / unknown key is a clean
3504                // no-op. Crash-window replay over a snapshot that already
3505                // applied this record cannot recover the retired id from the
3506                // key (`IdMap::get` is None), so every subsequent step is
3507                // skipped. Each step is independently idempotent if invoked
3508                // twice on a still-live id: retraction is a no-op on empty
3509                // provenance, `remove_edge` returns false, `remove_all` is a
3510                // no-op, `ids.delete` returns None, label sentinel is sticky.
3511                let Some(n) = self.ids.get(key) else {
3512                    return Ok(());
3513                };
3514
3515                // (1) Retract derived edges + de-index while props/labels live.
3516                let cursor = self.engine.pending_delta_count();
3517                let mut eng = std::mem::take(&mut self.engine);
3518                {
3519                    let mut gm = make_graph_mut(
3520                        &self.ids,
3521                        &mut self.syms,
3522                        &self.labels,
3523                        build_props_view(&self.props, &self.base),
3524                        &mut self.topo,
3525                        &self.base,
3526                        &mut self.edge_props,
3527                    );
3528                    eng.on_node_removed(n, &mut gm);
3529                }
3530                self.engine = eng;
3531                // Derived-edge retractions → view updates for neighbors.
3532                if !self.view_store.is_empty() {
3533                    #[cfg(test)]
3534                    DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
3535                    let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
3536                    for d in &new_deltas {
3537                        self.view_store.on_edge_changed(
3538                            d.etype_sym,
3539                            d.src_id,
3540                            d.dst_id,
3541                            d.fired,
3542                            &mut self.props,
3543                            &build_topo_view(&self.topo, &self.base),
3544                            &self.ids,
3545                            &self.syms,
3546                            &self.labels,
3547                            self.base.as_ref().map(|b| {
3548                                b.columns()
3549                                    .expect("base columns section bounds validated at open")
3550                            }),
3551                        );
3552                    }
3553                }
3554
3555                // (2) Sweep ALL remaining edges incident to n, both directions,
3556                // every etype. This cascade is intentionally mask-independent:
3557                // topology integrity requires removing every edge touching the
3558                // deleted node regardless of the caller's visibility scope.
3559                // (The mask limits which nodes a role's read phase can return;
3560                // the WAL delete always executes with full storage authority.)
3561                // Collect then remove so neighbor slices stay valid during
3562                // iteration. Remove from topo first, then call view maintenance
3563                // so Avg/Min/Max recompute sees the correct (reduced) neighbor set.
3564                let etypes: Vec<u32> = self.topo.etypes().collect();
3565                let mut doomed = Vec::new();
3566                for et in &etypes {
3567                    for &dst in self.topo.neighbors(*et, Direction::Out, n).as_ref() {
3568                        doomed.push((*et, n, dst));
3569                    }
3570                    for &src in self.topo.neighbors(*et, Direction::In, n).as_ref() {
3571                        doomed.push((*et, src, n));
3572                    }
3573                }
3574                for (et, s, d) in doomed {
3575                    self.topo.remove_edge(et, s, d);
3576                    self.edge_props.remove_edge(et, s, d);
3577                    // View maintenance: n's own view values will be cleared by
3578                    // remove_all below; only update surviving neighbors.
3579                    self.view_store.on_edge_changed(
3580                        et,
3581                        s,
3582                        d,
3583                        false,
3584                        &mut self.props,
3585                        &build_topo_view(&self.topo, &self.base),
3586                        &self.ids,
3587                        &self.syms,
3588                        &self.labels,
3589                        self.base.as_ref().map(|b| {
3590                            b.columns()
3591                                .expect("base columns section bounds validated at open")
3592                        }),
3593                    );
3594                }
3595
3596                // (3) Drop every remaining prop (`ColumnStore::remove_all`).
3597                self.props.remove_all(n);
3598                // Full-text index maintenance: remove all tokens for this node.
3599                self.fulltext.remove_node(n);
3600                // Property (equality) index maintenance: drop all entries for n.
3601                self.prop_index.remove_node_all(n);
3602
3603                // (4) Retire the dense id and stamp the label sentinel.
3604                self.ids.delete(key);
3605                if let Some(slot) = self.labels.get_mut(n as usize) {
3606                    *slot = u32::MAX;
3607                }
3608            }
3609            WalRecord::Batch(inner) => {
3610                // Apply each inner record in order through the same apply path.
3611                // Inner records are validated free of nested Batch by encode_record.
3612                for rec in inner {
3613                    self.apply(rec)?;
3614                }
3615            }
3616            WalRecord::RebuildRule { name } => {
3617                // Replay-over-snapshot idempotency: the snapshot may already
3618                // reflect a later delete_rule, so the rule is absent; skip.
3619                if !self.engine.rules().any(|r| r.name == *name) {
3620                    return Ok(());
3621                }
3622                let cursor = self.engine.pending_delta_count();
3623                let mut eng = std::mem::take(&mut self.engine);
3624                let result = {
3625                    let mut gm = make_graph_mut(
3626                        &self.ids,
3627                        &mut self.syms,
3628                        &self.labels,
3629                        build_props_view(&self.props, &self.base),
3630                        &mut self.topo,
3631                        &self.base,
3632                        &mut self.edge_props,
3633                    );
3634                    eng.rebuild(name, &mut gm)
3635                };
3636                self.engine = eng;
3637                result.map_err(|_| GraphError::RuleNotFound { name: name.clone() })?;
3638                // Derived-edge delta changes → view updates.
3639                if !self.view_store.is_empty() {
3640                    #[cfg(test)]
3641                    DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
3642                    let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
3643                    for d in &new_deltas {
3644                        self.view_store.on_edge_changed(
3645                            d.etype_sym,
3646                            d.src_id,
3647                            d.dst_id,
3648                            d.fired,
3649                            &mut self.props,
3650                            &build_topo_view(&self.topo, &self.base),
3651                            &self.ids,
3652                            &self.syms,
3653                            &self.labels,
3654                            self.base.as_ref().map(|b| {
3655                                b.columns()
3656                                    .expect("base columns section bounds validated at open")
3657                            }),
3658                        );
3659                    }
3660                }
3661            }
3662            WalRecord::CreateView { def_bytes } => {
3663                let def: ViewDef =
3664                    bincode::deserialize(def_bytes).map_err(|e| GraphError::Corrupt {
3665                        detail: format!("CreateView def_bytes deserialize failed: {e}"),
3666                    })?;
3667                // Replay-over-snapshot idempotency: view already present → skip.
3668                if self.view_store.has_view(&def.name) {
3669                    return Ok(());
3670                }
3671                self.view_store
3672                    .create_view(
3673                        def,
3674                        &mut self.props,
3675                        &build_topo_view(&self.topo, &self.base),
3676                        &self.ids,
3677                        &self.syms,
3678                        &self.labels,
3679                    )
3680                    .map_err(|e| GraphError::RuleInvalid { detail: e })?;
3681            }
3682            WalRecord::DeleteView { name } => {
3683                // Replay-over-snapshot idempotency: view already absent → skip.
3684                if !self.view_store.has_view(name) {
3685                    return Ok(());
3686                }
3687                self.view_store
3688                    .delete_view(name, &mut self.props, &self.ids, &self.labels, &self.syms)
3689                    .map_err(|_| GraphError::RuleNotFound { name: name.clone() })?;
3690            }
3691            WalRecord::EnableFulltext { label, field } => {
3692                // Replay-over-snapshot idempotency: already enabled → skip.
3693                if self.fulltext.is_enabled(label, field) {
3694                    return Ok(());
3695                }
3696                self.fulltext.enable(label, field);
3697                // Backfill: index all live nodes of this label that have the field.
3698                let n = self.ids.len() as u32;
3699                for id in 0..n {
3700                    let Some(&sym) = self.labels.get(id as usize) else {
3701                        continue;
3702                    };
3703                    if sym == u32::MAX {
3704                        continue; // tombstoned
3705                    }
3706                    let Some(lbl) = self.syms.resolve(sym) else {
3707                        continue;
3708                    };
3709                    if lbl != label {
3710                        continue;
3711                    }
3712                    if let Some(value) = build_props_view(&self.props, &self.base)
3713                        .get(id, field)
3714                        .map(|vr| vr.into_value())
3715                    {
3716                        self.fulltext.add_tokens(id, field, &value);
3717                    }
3718                }
3719            }
3720            WalRecord::DisableFulltext { label, field } => {
3721                // Replay-over-snapshot idempotency: already disabled → skip.
3722                if !self.fulltext.is_enabled(label, field) {
3723                    return Ok(());
3724                }
3725                // If another label still indexes this field, the postings column
3726                // is kept — but it must not contain node_ids from the now-disabled
3727                // label.  Remove them before calling disable() so the field_indexed
3728                // guard inside disable() sees the correct post-removal state.
3729                if self.fulltext.field_indexed_by_other(label, field) {
3730                    if let Some(label_sym) = self.syms.get(label) {
3731                        for (node_id, &lsym) in self.labels.iter().enumerate() {
3732                            if lsym == label_sym {
3733                                self.fulltext.remove_node_field(node_id as u32, field);
3734                            }
3735                        }
3736                    }
3737                }
3738                self.fulltext.disable(label, field);
3739            }
3740            WalRecord::EnableIndex { label, field } => {
3741                // Replay-over-snapshot idempotency: already enabled → skip.
3742                if self.prop_index.is_enabled(label, field) {
3743                    return Ok(());
3744                }
3745                self.prop_index.enable(label, field);
3746                // Backfill: index all live nodes of this label that have the field.
3747                let n = self.ids.len() as u32;
3748                for id in 0..n {
3749                    let Some(&sym) = self.labels.get(id as usize) else {
3750                        continue;
3751                    };
3752                    if sym == u32::MAX {
3753                        continue; // tombstoned
3754                    }
3755                    let Some(lbl) = self.syms.resolve(sym) else {
3756                        continue;
3757                    };
3758                    if lbl != label {
3759                        continue;
3760                    }
3761                    if let Some(value) = build_props_view(&self.props, &self.base)
3762                        .get(id, field)
3763                        .map(|vr| vr.into_value())
3764                    {
3765                        self.prop_index.set(label, field, id, &value);
3766                    }
3767                }
3768            }
3769            WalRecord::DisableIndex { label, field } => {
3770                self.prop_index.disable(label, field);
3771            }
3772            // History markers carry no replay state — rules re-derive edges
3773            // deterministically on open/replay. Skip unconditionally.
3774            WalRecord::DerivedEdgeAdded { .. } | WalRecord::DerivedEdgeRetracted { .. } => {}
3775            // ── rename_node ──────────────────────────────────────────────────
3776            WalRecord::RenameNode { old_key, new_key } => {
3777                // Recovery-safe: if old_key is already gone (key was renamed
3778                // by a snapshot or a prior replay frame), skip cleanly.
3779                if self.ids.get(old_key).is_none() {
3780                    return Ok(());
3781                }
3782                // The rename only updates the key-table; the dense id, all
3783                // topo edges, props, labels, and rule state are id-indexed and
3784                // require no change.
3785                self.ids
3786                    .rename(old_key, new_key)
3787                    .map_err(|e| GraphError::Corrupt {
3788                        detail: format!("wal replay RenameNode {old_key}→{new_key}: {e}"),
3789                    })?;
3790            }
3791        }
3792        Ok(())
3793    }
3794
3795    /// Intern `s` in `syms` and emit a WAL `Intern` record so `*Id` records
3796    /// replay on WAL-only `open_at` (no snapshot intern table). Apply is
3797    /// idempotent when the string is already bound. Always emit: after
3798    /// `snapshot()` the WAL is truncated and live intern is not on disk.
3799    fn intern_wal(&mut self, s: &str) -> (u32, WalRecord) {
3800        let id = if let Some(id) = self.syms.get(s) {
3801            id
3802        } else {
3803            self.syms.intern(s)
3804        };
3805        (
3806            id,
3807            WalRecord::Intern {
3808                id,
3809                text: s.to_string(),
3810            },
3811        )
3812    }
3813
3814    /// Rewrite user-facing records into dense-id records. On `Err`, no live
3815    /// state is left mutated: speculative interns made while building the
3816    /// output are rolled back, so a later successful mutation cannot log an
3817    /// `Intern` record whose id replay would never reproduce.
3818    fn rewrite_wal_dense(&mut self, recs: Vec<WalRecord>) -> Result<Vec<WalRecord>> {
3819        let syms_checkpoint = self.syms.len();
3820        let result = self.rewrite_wal_dense_inner(recs);
3821        if result.is_err() {
3822            self.syms.truncate(syms_checkpoint);
3823        }
3824        result
3825    }
3826
3827    fn rewrite_wal_dense_inner(&mut self, recs: Vec<WalRecord>) -> Result<Vec<WalRecord>> {
3828        let mut out = Vec::with_capacity(recs.len());
3829        // Node ids allocated by later apply(InsertNodeId) in this same batch.
3830        let mut pending: std::collections::HashMap<String, u32> = std::collections::HashMap::new();
3831        let mut interned = std::collections::HashSet::<u32>::new();
3832        let mut next = u32::try_from(self.ids.len()).map_err(|_| GraphError::Corrupt {
3833            detail: "id space exhausted".into(),
3834        })?;
3835        let lookup = |ids: &IdMap,
3836                      pending: &std::collections::HashMap<String, u32>,
3837                      key: &str|
3838         -> Option<u32> { ids.get(key).or_else(|| pending.get(key).copied()) };
3839        for rec in recs {
3840            match rec {
3841                WalRecord::InsertNode { label, key, props } => {
3842                    let (label_id, intern) = self.intern_wal(&label);
3843                    if interned.insert(label_id) {
3844                        out.push(intern);
3845                    }
3846                    let mut props_id = Vec::with_capacity(props.len());
3847                    for (field, value) in props {
3848                        let (field_id, intern) = self.intern_wal(&field);
3849                        if interned.insert(field_id) {
3850                            out.push(intern);
3851                        }
3852                        props_id.push((field_id, value));
3853                    }
3854                    if lookup(&self.ids, &pending, &key).is_none() {
3855                        pending.insert(key.clone(), next);
3856                        next = next.checked_add(1).ok_or_else(|| GraphError::Corrupt {
3857                            detail: "id space exhausted".into(),
3858                        })?;
3859                    }
3860                    out.push(WalRecord::InsertNodeId {
3861                        label: label_id,
3862                        key,
3863                        props: props_id,
3864                    });
3865                }
3866                WalRecord::SetProp { key, field, value } => {
3867                    let id =
3868                        lookup(&self.ids, &pending, &key).ok_or_else(|| GraphError::Corrupt {
3869                            detail: format!("dense WAL rewrite missing key {key}"),
3870                        })?;
3871                    let (field_id, intern) = self.intern_wal(&field);
3872                    if interned.insert(field_id) {
3873                        out.push(intern);
3874                    }
3875                    out.push(WalRecord::SetPropId {
3876                        id,
3877                        field: field_id,
3878                        value,
3879                    });
3880                }
3881                WalRecord::InsertEdge {
3882                    edge_type,
3883                    src_key,
3884                    dst_key,
3885                } => {
3886                    let (etype, intern) = self.intern_wal(&edge_type);
3887                    if interned.insert(etype) {
3888                        out.push(intern);
3889                    }
3890                    let src = lookup(&self.ids, &pending, &src_key).ok_or_else(|| {
3891                        GraphError::Corrupt {
3892                            detail: format!("dense WAL rewrite missing src {src_key}"),
3893                        }
3894                    })?;
3895                    let dst = lookup(&self.ids, &pending, &dst_key).ok_or_else(|| {
3896                        GraphError::Corrupt {
3897                            detail: format!("dense WAL rewrite missing dst {dst_key}"),
3898                        }
3899                    })?;
3900                    out.push(WalRecord::InsertEdgeId { etype, src, dst });
3901                }
3902                WalRecord::RenameNode {
3903                    ref old_key,
3904                    ref new_key,
3905                } => {
3906                    // Track the rename in `pending` so subsequent InsertEdge /
3907                    // SetProp records in this batch can resolve the new key.
3908                    let id = lookup(&self.ids, &pending, old_key).ok_or_else(|| {
3909                        GraphError::Corrupt {
3910                            detail: format!(
3911                                "dense WAL rewrite: RenameNode old key {old_key} not found"
3912                            ),
3913                        }
3914                    })?;
3915                    pending.remove(old_key.as_str());
3916                    pending.insert(new_key.clone(), id);
3917                    out.push(rec);
3918                }
3919                // # Symbol-order invariant (load-bearing)
3920                //
3921                // Write-time and replay-time symbol assignment must agree: every
3922                // symbol in a `Batch` frame has to receive the same dense id when
3923                // the frame's records are replayed in order as it received when
3924                // the frame was written.
3925                //
3926                // A rule's backfill interns its `edge_type` lazily
3927                // (`core_rules::engine`, every `g.syms.intern(&def.edge_type)`
3928                // site), and that backfill runs from `apply` — during the
3929                // `CreateRule` record itself, and again from any later
3930                // `InsertNodeId` in the same frame that makes the rule fire. At
3931                // write time the whole batch is rewritten before any of it is
3932                // applied, so a later `InsertEdge` in the same batch would win the
3933                // lower id for its edge type; on replay the rule's lazy intern
3934                // gets there first and steals it, and the `Intern` record fails at
3935                // the `wal intern assigned …` check in `apply`.
3936                //
3937                // Pre-interning the rule's `edge_type` here, and emitting its
3938                // `Intern` record ahead of the `CreateRule` record, makes both
3939                // orders identical. `weight_prop` needs no pre-intern:
3940                // `EdgeProps::set` keys props by `String`, never through the
3941                // interner. `via_edge` needs none either: via-hop rules resolve it
3942                // with `syms.get` and skip when it is absent.
3943                //
3944                // `RebuildRule` and `DeleteRule` need no such handling here:
3945                // `RebuildRule` has no `BatchOp` variant, so it never appears
3946                // inside a `Batch` today — it is only ever issued as its own
3947                // standalone commit (`rebuild_rule`, or the auto-rebuild path
3948                // that logs it as a second commit after the triggering op).
3949                // `DeleteRule` does have a `BatchOp` variant and can appear
3950                // inside a `Batch`, but it carries only a rule `name` — no
3951                // `edge_type` or other symbol that needs pre-interning — so
3952                // only `CreateRule` needs this arm.
3953                WalRecord::CreateRule { ref def_bytes } => {
3954                    let def = decode_rule_def(def_bytes).map_err(|e| GraphError::Corrupt {
3955                        detail: format!("CreateRule def_bytes deserialize failed: {e}"),
3956                    })?;
3957                    let (etype, intern) = self.intern_wal(&def.edge_type);
3958                    if interned.insert(etype) {
3959                        out.push(intern);
3960                    }
3961                    out.push(rec);
3962                }
3963                other => out.push(other),
3964            }
3965        }
3966        Ok(out)
3967    }
3968
3969    fn log_dense(&mut self, recs: Vec<WalRecord>) -> Result<()> {
3970        let recs = self.rewrite_wal_dense(recs)?;
3971        match recs.len() {
3972            0 => Ok(()),
3973            1 => self.log_then_apply(recs.into_iter().next().unwrap()),
3974            _ => self.log_then_apply(WalRecord::Batch(recs)),
3975        }
3976    }
3977
3978    /// Durable write, then notify the event sink. Replay (`apply` during
3979    /// `open`) never enters this function, so it is the replay-silent seam.
3980    fn log_then_apply(&mut self, rec: WalRecord) -> Result<()> {
3981        self.log_then_apply_with(rec, None, self.fsync)
3982    }
3983
3984    /// Whether this frame must fsync under `policy`.
3985    ///
3986    /// Batched contract: user-visible batches (>1 mutation) fsync; single
3987    /// mutations do not. The dense rewrite wraps a single mutation in a
3988    /// `Batch([Intern.., <one *Id record>])`, so `Intern` records are excluded
3989    /// from the count — removing that filter would make every single-op write
3990    /// fsync under Batched (or, if the threshold were raised instead, skip a
3991    /// needed fsync for real two-op batches).
3992    fn wal_needs_sync(policy: FsyncPolicy, rec: &WalRecord) -> bool {
3993        match policy {
3994            FsyncPolicy::Relaxed => false,
3995            FsyncPolicy::Strict => true,
3996            FsyncPolicy::Batched => match rec {
3997                // Intern + one mutation is the single-op rewrite, not a user batch.
3998                WalRecord::Batch(inner) => {
3999                    inner
4000                        .iter()
4001                        .filter(|r| !matches!(r, WalRecord::Intern { .. }))
4002                        .count()
4003                        > 1
4004                }
4005                _ => false,
4006            },
4007        }
4008    }
4009
4010    /// # Apply-infallibility invariant (load-bearing)
4011    ///
4012    /// The ordering is: WAL append → fsync → apply. If `apply` returned `Err`
4013    /// for a `Batch` frame after a successful WAL write, the WAL would contain
4014    /// the full frame while in-memory state would reflect only the ops before
4015    /// the failure. On reopen, WAL replay would then apply the entire batch —
4016    /// diverging permanently from what the pre-crash process had in memory.
4017    ///
4018    /// For `Batch` frames this situation cannot arise because:
4019    /// - All validation runs via `commit_logged_batch`/`MutPreview` **before**
4020    ///   the WAL write. `MutPreview` uses the same `&mut self` that apply will
4021    ///   use, with no concurrent mutation between validation exit and apply entry.
4022    /// - Every `apply` arm for a validated op is either infallible by construction
4023    ///   (`InsertNode`, `RemoveProp`, `DeleteEdge`, `DeleteNode`), has idempotency
4024    ///   guards that return `Ok(())` (`CreateRule`, `DeleteRule`), or is
4025    ///   guaranteed-present by validation (`InsertEdge`/`SetProp` key lookups).
4026    /// - `on_node_changed` and `on_node_removed` return `()` — never `Err`.
4027    ///
4028    /// A `debug_assert!` below fires in debug builds if `apply` ever returns
4029    /// `Err` for a `Batch` frame, making any future regression immediately visible
4030    /// in tests rather than silently diverging crash-recovery behaviour.
4031    fn log_then_apply_with(
4032        &mut self,
4033        rec: WalRecord,
4034        ingest: Option<(String, usize)>,
4035        policy: FsyncPolicy,
4036    ) -> Result<()> {
4037        // Read-only guard: as-of instances must never write the WAL.
4038        if self.read_only {
4039            return Err(GraphError::ReadOnly);
4040        }
4041        // Degraded guard: fsync failure left WAL truncated, or a refresh failed
4042        // partway; in-memory state is ahead of (or out of step with) the
4043        // on-disk WAL, so further mutations would deepen the divergence.
4044        // Reopen the database to recover.  Checked before the lock guard: this
4045        // is the more serious condition and the more useful error.
4046        if self.degraded {
4047            return Err(GraphError::Io(std::io::Error::other(
4048                "database degraded after group-commit fsync failure; reopen required",
4049            )));
4050        }
4051        // Cross-process guard: this write scope asked for the store's write
4052        // lock and did not get it. Writing anyway would append frames on top of
4053        // a WAL another process is extending, so refuse instead.
4054        if self.lock_denied {
4055            return Err(GraphError::Busy { holder: None });
4056        }
4057        // Ensure retained provenance bytes are decoded into the live mutable
4058        // fields before any mutation touches self.engine.provenance.  This is a
4059        // no-op if provenance was never stored (fresh store) or has already been
4060        // consumed (subsequent mutations).  WAL replay calls apply() directly
4061        // and is covered by consume_retained_state_eager before replay.
4062        self.ensure_v8_base_sections_loaded();
4063        self.engine.ensure_provenance_loaded_mut();
4064        // Invariant (I-1): no stale deltas may enter from a previous apply.
4065        // If any engine method ever accumulates deltas before erroring, they would
4066        // contaminate the *next* commit's event stream. This assert fires in debug
4067        // builds, making any future regression visible at the earliest point.
4068        debug_assert_eq!(
4069            self.engine.pending_delta_count(),
4070            0,
4071            "stale engine deltas at log_then_apply_with entry — \
4072             a previous apply arm may have accumulated deltas before erroring; \
4073             the caller must drain_deltas() on any error path before returning"
4074        );
4075        let frame = encode_record(&rec);
4076        self.fs.append(FileId::Wal, &frame)?;
4077        // The cursor advances by exactly the bytes appended: these frames are
4078        // ours and already applied, so a later refresh must not replay them.
4079        self.wal_consumed += frame.len() as u64;
4080        if Self::wal_needs_sync(policy, &rec) {
4081            self.fs.sync(FileId::Wal)?;
4082        }
4083        // Marker writing always needs the engine deltas, but the engine only
4084        // accumulates them when emit_deltas is true (normally gated on subscribers
4085        // or views being present).  Enable emission for this apply if it is
4086        // currently off, then restore the original state unconditionally via an
4087        // RAII guard — this prevents a panic in apply() from leaking the flag.
4088        // The same guard resets the engine's transient chaining state. A panic
4089        // unwinding out of a rule hook would otherwise leave `chain_depth`
4090        // non-zero, which makes every later `begin_chain` decide chaining is
4091        // already running and silently switch it off for good.
4092        struct RestoreEmitDeltas(*mut RuleEngine, bool);
4093        impl Drop for RestoreEmitDeltas {
4094            fn drop(&mut self) {
4095                // SAFETY: pointer into self (GraphDb); guard is dropped within
4096                // this frame before log_then_apply_with returns.
4097                unsafe {
4098                    (*self.0).set_emit_deltas(self.1);
4099                    (*self.0).reset_chain_state();
4100                }
4101            }
4102        }
4103        let original_emit = self.engine.emit_deltas();
4104        if !original_emit {
4105            self.engine.set_emit_deltas(true);
4106        }
4107        // SAFETY: raw pointer into self; guard dropped within this frame.
4108        let _emit_guard = RestoreEmitDeltas(&mut self.engine as *mut _, original_emit);
4109
4110        let apply_result = self.apply(&rec);
4111        // For Batch frames, post-validation apply must be infallible (see above).
4112        // A debug_assert here catches any future change that makes apply fallible
4113        // before the caller notices via silent WAL/memory divergence.
4114        if matches!(&rec, WalRecord::Batch(_)) {
4115            debug_assert!(
4116                apply_result.is_ok(),
4117                "Batch apply returned Err after successful WAL write — \
4118                 the validate-then-apply invariant has been violated; \
4119                 see log_then_apply_with invariant doc"
4120            );
4121        }
4122        if apply_result.is_err() {
4123            // Discard any partial deltas accumulated by the failed apply.
4124            // They must not ride the next commit's event stream (I-1).
4125            // _emit_guard restores emit_deltas on drop automatically.
4126            let _ = self.engine.drain_deltas();
4127            let _ = self.engine.take_rebuild_needed();
4128            apply_result?;
4129        }
4130        self.commit_seq += 1;
4131        let seq = self.commit_seq;
4132        // Update per-node last-change map for the committed record.
4133        // Must happen after commit_seq is incremented so the seq is correct.
4134        self.update_last_change_from_rec(&rec, seq);
4135        // Drain engine deltas and distribute to subscribers before the existing
4136        // MutationEvent sink fires — both happen post-fsync, post-apply.
4137        // _emit_guard restores emit_deltas after this line when it drops.
4138        let engine_deltas = self.engine.drain_deltas();
4139
4140        // Append history-marker WAL records for any derived-edge changes so
4141        // that `edge_history` and `was_linked` can surface rule-attributed
4142        // events. Markers are STATE NO-OPS during replay; they are written
4143        // without an additional fsync (the triggering commit's sync already
4144        // happened; the next commit's sync covers these lazily).
4145        if !engine_deltas.is_empty() {
4146            let markers: Vec<WalRecord> = engine_deltas
4147                .iter()
4148                .map(|d| {
4149                    if d.fired {
4150                        WalRecord::DerivedEdgeAdded {
4151                            rule: d.rule.clone(),
4152                            edge_type: d.edge_type.clone(),
4153                            src_key: d.src_key.clone(),
4154                            dst_key: d.dst_key.clone(),
4155                        }
4156                    } else {
4157                        WalRecord::DerivedEdgeRetracted {
4158                            rule: d.rule.clone(),
4159                            edge_type: d.edge_type.clone(),
4160                            src_key: d.src_key.clone(),
4161                            dst_key: d.dst_key.clone(),
4162                        }
4163                    }
4164                })
4165                .collect();
4166            let marker_frame = if markers.len() == 1 {
4167                markers.into_iter().next().unwrap()
4168            } else {
4169                WalRecord::Batch(markers)
4170            };
4171            // Ignore append errors: markers are best-effort history
4172            // annotations. Losing them does not affect state correctness.
4173            // The cursor only advances when the bytes actually landed.
4174            let marker_bytes = encode_record(&marker_frame);
4175            if self.fs.append(FileId::Wal, &marker_bytes).is_ok() {
4176                self.wal_consumed += marker_bytes.len() as u64;
4177            }
4178        }
4179
4180        // Record MVCC CommitDelta for the epoch reader.  The WAL record is
4181        // stored as-is (including any nested Batch / Intern records); the
4182        // ReaderSnapshot's apply_one function handles all variants.
4183        {
4184            let derived_inserts = engine_deltas
4185                .iter()
4186                .filter(|d| d.fired)
4187                .map(|d| (d.etype_sym, d.src_id, d.dst_id))
4188                .collect();
4189            let derived_deletes = engine_deltas
4190                .iter()
4191                .filter(|d| !d.fired)
4192                .map(|d| (d.etype_sym, d.src_id, d.dst_id))
4193                .collect();
4194            let delta = Arc::new(crate::reader::CommitDelta {
4195                records: vec![rec.clone()],
4196                derived_inserts,
4197                derived_deletes,
4198            });
4199            self.delta_tail.push(delta);
4200            self.commits_since_fold += 1;
4201            if self.commits_since_fold >= crate::reader::FOLD_EVERY_K {
4202                self.fold_now();
4203            }
4204        }
4205
4206        if self.defer_events {
4207            // Group-commit drain thread: hold events until after the group
4208            // fsync so subscribers only observe durable data (R2).
4209            self.deferred_events.push(DeferredEvent {
4210                rec: rec.clone(),
4211                engine_deltas,
4212                seq,
4213                ingest,
4214            });
4215        } else {
4216            self.distribute_events(&rec, &engine_deltas, seq);
4217            self.emit_committed(&rec, ingest);
4218        }
4219        // Drift is only known after apply, so auto-rebuild cannot join the
4220        // triggering op's WAL frame. Issue RebuildRule as a second commit.
4221        // Skip when `rec` is itself RebuildRule: rebuild resets drift, so a
4222        // retrigger loop is impossible if the fit succeeded, but we still
4223        // drain the flag so a leftover cannot re-enter.
4224        let rebuilds = self.engine.take_rebuild_needed();
4225        if !matches!(&rec, WalRecord::RebuildRule { .. }) {
4226            let mut failed = Vec::new();
4227            for name in rebuilds {
4228                if self.engine.rules().any(|r| r.name == name) {
4229                    // User op is already durable. A failed second commit must
4230                    // not surface as the caller's error.
4231                    if let Err(e) =
4232                        self.log_then_apply(WalRecord::RebuildRule { name: name.clone() })
4233                    {
4234                        eprintln!(
4235                            "auto-rebuild of rule {name:?} failed after durable user commit: {e}"
4236                        );
4237                        failed.push(name);
4238                    }
4239                }
4240            }
4241            for name in failed {
4242                self.engine.queue_rebuild_needed(name);
4243            }
4244        }
4245        Ok(())
4246    }
4247
4248    /// Install a post-commit hook. Replaces any previous sink.
4249    ///
4250    /// The sink runs inside `log_then_apply` after a successful
4251    /// durable commit, while the caller still holds `&mut self`. When this
4252    /// database is behind a [`crate::SharedDb`], that means the **write
4253    /// guard is held**. The sink must never call `read` / `write` (or any
4254    /// other method) on the same `SharedDb` — the `RwLock` is not
4255    /// re-entrant and doing so deadlocks. The sink is `Send + Sync`;
4256    /// `std::sync::mpsc::Sender` is not `Sync` and will not type-check.
4257    /// Intended examples: `std::sync::mpsc::SyncSender`,
4258    /// `tokio::sync::mpsc::Sender`, `tokio::sync::broadcast::Sender`
4259    /// (non-blocking `send`), or `Arc<Mutex<Vec<MutationEvent>>>`.
4260    pub fn set_event_sink(&mut self, sink: Box<dyn Fn(MutationEvent) + Send + Sync>) {
4261        self.event_sink = Some(sink);
4262    }
4263
4264    /// Whether a post-commit event sink is currently installed.
4265    pub fn has_event_sink(&self) -> bool {
4266        self.event_sink.is_some()
4267    }
4268
4269    /// Set WAL fsync cadence. Default [`FsyncPolicy::Strict`].
4270    pub fn set_fsync_policy(&mut self, p: FsyncPolicy) {
4271        self.fsync = p;
4272    }
4273
4274    /// Return the current WAL fsync cadence.
4275    pub fn fsync_policy(&self) -> FsyncPolicy {
4276        self.fsync
4277    }
4278
4279    // ── Group-commit event deferral ───────────────────────────────────────────
4280
4281    /// Enable or disable deferred event mode.
4282    ///
4283    /// When `true`, event notifications (subscription `DbEvent`s and legacy
4284    /// `MutationEvent` sink calls) are buffered rather than fired immediately.
4285    /// Call [`flush_deferred_events`] after the group fsync to deliver them,
4286    /// or [`discard_deferred_events`] if the fsync failed and the group must
4287    /// be treated as lost.
4288    pub fn set_deferred_events_mode(&mut self, defer: bool) {
4289        self.defer_events = defer;
4290    }
4291
4292    /// Fire all buffered events accumulated since [`set_deferred_events_mode`]
4293    /// was set to true.  Clears the buffer.
4294    ///
4295    /// Called by the drain thread AFTER a successful group fsync, so
4296    /// subscribers observe only data that is durably on disk.
4297    pub fn flush_deferred_events(&mut self) {
4298        let events = std::mem::take(&mut self.deferred_events);
4299        for de in events {
4300            self.distribute_events(&de.rec, &de.engine_deltas, de.seq);
4301            self.emit_committed(&de.rec, de.ingest);
4302        }
4303    }
4304
4305    /// Discard all buffered events without firing them.
4306    ///
4307    /// Called by the drain thread when a group fsync fails: the WAL has been
4308    /// truncated back to the pre-group offset, so the committed-but-unsynced
4309    /// ops must not be observable to subscribers.
4310    pub fn discard_deferred_events(&mut self) {
4311        self.deferred_events.clear();
4312    }
4313
4314    // ── Degraded state ────────────────────────────────────────────────────────
4315
4316    /// Mark this database as degraded.
4317    ///
4318    /// Called by the group-commit drain thread after a group fsync failure and
4319    /// WAL truncation: the in-memory state is now ahead of the on-disk WAL, so
4320    /// further mutations would deepen the divergence.  All subsequent calls to
4321    /// [`log_then_apply_with`] return `Err` until the database is reopened.
4322    pub fn set_degraded(&mut self) {
4323        self.degraded = true;
4324    }
4325
4326    fn emit(&self, ev: MutationEvent) {
4327        if let Some(sink) = &self.event_sink {
4328            sink(ev);
4329        }
4330    }
4331
4332    fn emit_committed(&self, rec: &WalRecord, ingest: Option<(String, usize)>) {
4333        match rec {
4334            WalRecord::Batch(inner) => {
4335                for r in inner {
4336                    if let Some(ev) = event_from_record(r, &self.syms, &self.ids) {
4337                        self.emit(ev);
4338                    }
4339                }
4340                match ingest {
4341                    Some((label, inserted)) => {
4342                        self.emit(MutationEvent::Ingested { label, inserted })
4343                    }
4344                    None => {
4345                        let ops = inner
4346                            .iter()
4347                            .filter(|r| !matches!(r, WalRecord::Intern { .. }))
4348                            .count();
4349                        if ops > 1 {
4350                            self.emit(MutationEvent::BatchApplied { ops });
4351                        }
4352                    }
4353                }
4354            }
4355            other => {
4356                if let Some(ev) = event_from_record(other, &self.syms, &self.ids) {
4357                    self.emit(ev);
4358                }
4359            }
4360        }
4361    }
4362
4363    // -----------------------------------------------------------------------
4364    // Subscription API
4365    // -----------------------------------------------------------------------
4366
4367    /// Distribute post-commit events to all live subscribers.
4368    ///
4369    /// Build a row-key → row-data map from a [`ResultSet`].
4370    ///
4371    /// Each row is serialized to JSON to form its key; a debug fallback is used
4372    /// if serialization fails. Used by both the initial-seed path in
4373    /// [`Self::subscribe_query`] and the per-commit diff path in
4374    /// [`Self::distribute_events`] to keep the two in sync.
4375    fn result_to_row_map(
4376        result: &core_query::ResultSet,
4377    ) -> std::collections::HashMap<String, Vec<Option<Value>>> {
4378        (0..result.len())
4379            .map(|i| {
4380                let row = result.row(i).to_vec();
4381                let key = serde_json::to_string(&row).unwrap_or_else(|_| format!("{row:?}"));
4382                (key, row)
4383            })
4384            .collect()
4385    }
4386
4387    /// Collect the set of label syms touched by a WAL record.
4388    ///
4389    /// Returns `Some(set)` when every record in this commit can be attributed to
4390    /// a known label sym. Returns `None` when the commit must not be skipped:
4391    /// edge records, unresolvable key→label lookups, or any record type not in
4392    /// the explicit handled set.
4393    ///
4394    /// Handled record types and their actions:
4395    /// - `InsertNode`   → look up label in interner (fails → None)
4396    /// - `InsertNodeId` → label sym is carried directly
4397    /// - `SetProp`      → resolve key→id→label (fails → None)
4398    /// - `DeleteNode`   → resolve key→id→label (fails → None)
4399    /// - `Batch`        → recurse into every inner record
4400    /// - `InsertEdge`, `DeleteEdge`, `InsertEdgeId` → always None (edge records)
4401    /// - everything else → None (conservative)
4402    fn commit_touched_labels(
4403        rec: &WalRecord,
4404        syms: &Interner,
4405        ids: &IdMap,
4406        labels: &[u32],
4407    ) -> Option<BTreeSet<u32>> {
4408        let mut out = BTreeSet::new();
4409        if Self::collect_touched_labels(rec, syms, ids, labels, &mut out) {
4410            Some(out)
4411        } else {
4412            None
4413        }
4414    }
4415
4416    fn collect_touched_labels(
4417        rec: &WalRecord,
4418        syms: &Interner,
4419        ids: &IdMap,
4420        labels: &[u32],
4421        out: &mut BTreeSet<u32>,
4422    ) -> bool {
4423        match rec {
4424            // String-key insert: the dense rewrite converts this to
4425            // [Intern, InsertNodeId], so this arm fires only for legacy WAL
4426            // records written before the dense path was added.
4427            WalRecord::InsertNode { label, .. } => {
4428                if let Some(sym) = syms.get(label) {
4429                    out.insert(sym);
4430                    true
4431                } else {
4432                    false
4433                }
4434            }
4435            // Dense-id insert (produced by rewrite_wal_dense for every
4436            // insert_node call in the current codebase).
4437            WalRecord::InsertNodeId { label, .. } => {
4438                out.insert(*label);
4439                true
4440            }
4441            // String-key prop set: dense path converts to [Intern, SetPropId].
4442            WalRecord::SetProp { key, .. } => {
4443                if let Some(sym) = Self::resolve_key_label_sym(key, ids, labels) {
4444                    out.insert(sym);
4445                    true
4446                } else {
4447                    false
4448                }
4449            }
4450            // Dense-id prop set (produced by rewrite_wal_dense for set_prop).
4451            WalRecord::SetPropId { id, .. } => {
4452                if let Some(sym) = labels.get(*id as usize).copied().filter(|&s| s != u32::MAX) {
4453                    out.insert(sym);
4454                    true
4455                } else {
4456                    false
4457                }
4458            }
4459            WalRecord::DeleteNode { key } => {
4460                if let Some(sym) = Self::resolve_key_label_sym(key, ids, labels) {
4461                    out.insert(sym);
4462                    true
4463                } else {
4464                    false
4465                }
4466            }
4467            WalRecord::Batch(inner) => inner
4468                .iter()
4469                .all(|r| Self::collect_touched_labels(r, syms, ids, labels, out)),
4470            // Intern is a pure metadata record — it does not touch any node's
4471            // label and is safe to skip for the label-skip predicate.
4472            WalRecord::Intern { .. } => true,
4473            // Edge records: always re-execute (edges can change join results).
4474            WalRecord::InsertEdge { .. }
4475            | WalRecord::DeleteEdge { .. }
4476            | WalRecord::InsertEdgeId { .. } => false,
4477            _ => false,
4478        }
4479    }
4480
4481    /// Resolve a node key to its label sym via the dense id table.
4482    /// Returns `None` if the key is unknown or the label is a tombstone sentinel.
4483    fn resolve_key_label_sym(key: &str, ids: &IdMap, labels: &[u32]) -> Option<u32> {
4484        let id = ids.get(key)?;
4485        let sym = labels.get(id as usize).copied()?;
4486        (sym != u32::MAX).then_some(sym)
4487    }
4488
4489    /// Distribute post-commit events to all live subscribers.
4490    ///
4491    /// Called from `log_then_apply_with` after apply + fsync, before the
4492    /// legacy MutationEvent sink. Prunes dead `Weak` entries in-place.
4493    ///
4494    /// Query subscriptions (subscribe_query) re-execute their plan on every
4495    /// call and diff the result against the previous run. Zero overhead when
4496    /// no query subscriptions are active.
4497    fn distribute_events(&mut self, rec: &WalRecord, engine_deltas: &[EngineEdgeDelta], seq: u64) {
4498        if self.subscriptions.is_empty() && self.query_subscriptions.is_empty() {
4499            return;
4500        }
4501
4502        if !self.subscriptions.is_empty() {
4503            // Build write events from the WAL record.
4504            let write_events: Vec<DbEvent> =
4505                Self::write_events_from_record(rec, seq, &self.syms, &self.ids);
4506
4507            // Build edge events from engine deltas.  Weight is looked up from
4508            // edge_props at distribution time (after apply), so it's always fresh.
4509            let edge_events: Vec<DbEvent> = engine_deltas
4510                .iter()
4511                .map(|d| {
4512                    if d.fired {
4513                        // The score lives under the rule's declared weight_prop,
4514                        // which is not always the literal "weight".
4515                        let prop = self
4516                            .engine
4517                            .rules()
4518                            .find(|r| r.name == d.rule)
4519                            .and_then(|r| r.weight_prop.as_deref());
4520                        let weight = prop.and_then(|p| {
4521                            self.edge_props
4522                                .get(d.etype_sym, d.src_id, d.dst_id, p)
4523                                .and_then(|v| {
4524                                    if let core_storage::Value::Float(f) = v {
4525                                        Some(*f)
4526                                    } else {
4527                                        None
4528                                    }
4529                                })
4530                        });
4531                        DbEvent::EdgeFired {
4532                            rule: d.rule.clone(),
4533                            src_key: d.src_key.clone(),
4534                            dst_key: d.dst_key.clone(),
4535                            edge_type: d.edge_type.clone(),
4536                            weight,
4537                            commit_seq: seq,
4538                        }
4539                    } else {
4540                        DbEvent::EdgeRetracted {
4541                            rule: d.rule.clone(),
4542                            src_key: d.src_key.clone(),
4543                            dst_key: d.dst_key.clone(),
4544                            edge_type: d.edge_type.clone(),
4545                            commit_seq: seq,
4546                        }
4547                    }
4548                })
4549                .collect();
4550
4551            // Prune dead entries; push matching events to live ones.
4552            self.subscriptions.retain(|entry| {
4553                let Some(inner) = entry.inner.upgrade() else {
4554                    return false;
4555                };
4556                for ev in &write_events {
4557                    if event_matches(ev, &entry.filter) {
4558                        inner.push(ev.clone());
4559                    }
4560                }
4561                for ev in &edge_events {
4562                    if event_matches(ev, &entry.filter) {
4563                        inner.push(ev.clone());
4564                    }
4565                }
4566                true
4567            });
4568
4569            // Turn off delta accumulation if all subscribers dropped and no views remain.
4570            if self.subscriptions.is_empty() && self.view_store.is_empty() {
4571                self.engine.set_emit_deltas(false);
4572            }
4573        }
4574
4575        // Query subscriptions: full re-run per commit, then diff rows.
4576        // IMPORTANT: full re-execution on every commit — use LIMIT to bound cost.
4577        // Differential evaluation is roadmap / Phase 5.
4578        if !self.query_subscriptions.is_empty() {
4579            // Take the list out so we can call self.view() without borrow conflict.
4580            let mut query_subs = std::mem::take(&mut self.query_subscriptions);
4581            let empty_params = BTreeMap::new();
4582            query_subs.retain_mut(|entry| {
4583                let Some(inner) = entry.inner.upgrade() else {
4584                    return false; // subscriber dropped — prune
4585                };
4586                // Label-skip: if the plan has a known scan label and this commit
4587                // can be proven to touch only different labels (and no rule-derived
4588                // edge deltas fired), the result set cannot have changed — skip.
4589                if let Some(scan_sym) = entry.scan_label {
4590                    if engine_deltas.is_empty() {
4591                        let touched =
4592                            Self::commit_touched_labels(rec, &self.syms, &self.ids, &self.labels);
4593                        if touched.map(|t| !t.contains(&scan_sym)).unwrap_or(false) {
4594                            return true; // safe to skip — result set unchanged
4595                        }
4596                    }
4597                }
4598                QUERY_SUB_EXECS_TL.with(|c| c.set(c.get() + 1));
4599                let result = match execute(&self.view(), &entry.ops, &Params(&empty_params)) {
4600                    Ok(r) => r,
4601                    Err(e) => {
4602                        // Keep the subscription alive; skip the diff for this commit.
4603                        // Re-run errors are transient (e.g., planner change) and
4604                        // self-heal when the next commit succeeds.
4605                        eprintln!("[mushroomdb] subscribe_query re-run failed: {e}");
4606                        return true;
4607                    }
4608                };
4609                // Build new row map: serialized-key → row data.
4610                let new_row_map = Self::result_to_row_map(&result);
4611                // Removed rows: in prev but not in new.
4612                for (key, row) in &entry.prev_row_map {
4613                    if !new_row_map.contains_key(key) {
4614                        inner.push(DbEvent::QueryRowRemoved {
4615                            columns: entry.columns.clone(),
4616                            row: row.clone(),
4617                        });
4618                    }
4619                }
4620                // Added rows: in new but not in prev.
4621                for (key, row) in &new_row_map {
4622                    if !entry.prev_row_map.contains_key(key) {
4623                        inner.push(DbEvent::QueryRowAdded {
4624                            columns: entry.columns.clone(),
4625                            row: row.clone(),
4626                        });
4627                    }
4628                }
4629                entry.prev_row_map = new_row_map;
4630                true
4631            });
4632            self.query_subscriptions = query_subs;
4633        }
4634    }
4635
4636    /// Returns `true` if any live subscriber or view definition requires delta
4637    /// accumulation. Used to set `engine.emit_deltas` on subscribe/view DDL.
4638    fn needs_emit_deltas(&self) -> bool {
4639        !self.view_store.is_empty()
4640            || self
4641                .subscriptions
4642                .iter()
4643                .any(|e| e.inner.upgrade().is_some())
4644    }
4645
4646    /// Convert a WAL record into `DbEvent` write events with the given seq.
4647    fn write_events_from_record(
4648        rec: &WalRecord,
4649        seq: u64,
4650        intern: &Interner,
4651        ids: &IdMap,
4652    ) -> Vec<DbEvent> {
4653        match rec {
4654            WalRecord::InsertNode { label, key, .. } => vec![DbEvent::NodeInserted {
4655                label: label.clone(),
4656                key: key.clone(),
4657                commit_seq: seq,
4658            }],
4659            // *Id arms run after a successful apply, so resolution can only
4660            // fail on a programming error. Skip the event rather than emit a
4661            // fabricated "" that clients can't tell from a real empty value
4662            // (mirrors event_from_record returning None).
4663            WalRecord::InsertNodeId { label, key, .. } => intern
4664                .resolve(*label)
4665                .map(|label| DbEvent::NodeInserted {
4666                    label: label.to_string(),
4667                    key: key.clone(),
4668                    commit_seq: seq,
4669                })
4670                .into_iter()
4671                .collect(),
4672            WalRecord::SetProp { key, field, .. } => vec![DbEvent::PropSet {
4673                key: key.clone(),
4674                field: field.clone(),
4675                commit_seq: seq,
4676            }],
4677            WalRecord::SetPropId { id, field, .. } => ids
4678                .key_of(*id)
4679                .zip(intern.resolve(*field))
4680                .map(|(key, field)| DbEvent::PropSet {
4681                    key: key.to_string(),
4682                    field: field.to_string(),
4683                    commit_seq: seq,
4684                })
4685                .into_iter()
4686                .collect(),
4687            WalRecord::RemoveProp { key, field } => vec![DbEvent::PropRemoved {
4688                key: key.clone(),
4689                field: field.clone(),
4690                commit_seq: seq,
4691            }],
4692            WalRecord::InsertEdge {
4693                edge_type,
4694                src_key,
4695                dst_key,
4696            } => vec![DbEvent::EdgeInserted {
4697                edge_type: edge_type.clone(),
4698                src: src_key.clone(),
4699                dst: dst_key.clone(),
4700                commit_seq: seq,
4701            }],
4702            WalRecord::InsertEdgeId { etype, src, dst } => (|| {
4703                Some(DbEvent::EdgeInserted {
4704                    edge_type: intern.resolve(*etype)?.to_string(),
4705                    src: ids.key_of(*src)?.to_string(),
4706                    dst: ids.key_of(*dst)?.to_string(),
4707                    commit_seq: seq,
4708                })
4709            })()
4710            .into_iter()
4711            .collect(),
4712            WalRecord::DeleteEdge {
4713                edge_type,
4714                src_key,
4715                dst_key,
4716            } => vec![DbEvent::EdgeDeleted {
4717                edge_type: edge_type.clone(),
4718                src: src_key.clone(),
4719                dst: dst_key.clone(),
4720                commit_seq: seq,
4721            }],
4722            WalRecord::DeleteNode { key } => vec![DbEvent::NodeDeleted {
4723                key: key.clone(),
4724                commit_seq: seq,
4725            }],
4726            WalRecord::Batch(inner) => inner
4727                .iter()
4728                .flat_map(|r| Self::write_events_from_record(r, seq, intern, ids))
4729                .collect(),
4730            WalRecord::CreateRule { .. }
4731            | WalRecord::DeleteRule { .. }
4732            | WalRecord::RebuildRule { .. }
4733            | WalRecord::CreateView { .. }
4734            | WalRecord::DeleteView { .. }
4735            | WalRecord::EnableFulltext { .. }
4736            | WalRecord::DisableFulltext { .. }
4737            | WalRecord::EnableIndex { .. }
4738            | WalRecord::DisableIndex { .. }
4739            | WalRecord::Intern { .. }
4740            // History markers produce no DbEvent — the engine delta already
4741            // fired the EdgeFired/EdgeRetracted subscription events.
4742            | WalRecord::DerivedEdgeAdded { .. }
4743            | WalRecord::DerivedEdgeRetracted { .. }
4744            | WalRecord::RenameNode { .. } => vec![],
4745        }
4746    }
4747
4748    /// Subscribe to edge-fire and edge-retract events for one named rule.
4749    ///
4750    /// Returns `Err(GraphError::RuleNotFound)` if `rule_name` is not
4751    /// currently registered. Dropping the returned [`Subscription`] handle
4752    /// unregisters the subscriber — no further events are queued, no
4753    /// resources leak.
4754    pub fn subscribe_rule(&mut self, rule_name: &str) -> core_storage::Result<Subscription> {
4755        if self.read_only {
4756            return Err(core_storage::GraphError::ReadOnly);
4757        }
4758        if !self.engine.rules().any(|r| r.name == rule_name) {
4759            return Err(core_storage::GraphError::RuleNotFound {
4760                name: rule_name.to_string(),
4761            });
4762        }
4763        let inner = SubInner::new(self.sub_capacity());
4764        self.subscriptions.push(SubEntry {
4765            filter: SubFilter::Rule(rule_name.to_string()),
4766            inner: std::sync::Arc::downgrade(&inner),
4767        });
4768        self.engine.set_emit_deltas(true);
4769        Ok(Subscription(inner))
4770    }
4771
4772    /// Subscribe to edge-fire and edge-retract events for **all** rules.
4773    ///
4774    /// Returns `Err(GraphError::ReadOnly)` if called on an as-of instance —
4775    /// as-of instances never commit, so `distribute_events` never runs and the
4776    /// subscription would never deliver events.
4777    pub fn subscribe_all_rules(&mut self) -> core_storage::Result<Subscription> {
4778        if self.read_only {
4779            return Err(core_storage::GraphError::ReadOnly);
4780        }
4781        let inner = SubInner::new(self.sub_capacity());
4782        self.subscriptions.push(SubEntry {
4783            filter: SubFilter::AllRules,
4784            inner: std::sync::Arc::downgrade(&inner),
4785        });
4786        self.engine.set_emit_deltas(true);
4787        Ok(Subscription(inner))
4788    }
4789
4790    /// Subscribe to write events: node insert/delete, prop set/remove.
4791    ///
4792    /// Does not include edge-fire / edge-retract (rule-derived edge events).
4793    ///
4794    /// Returns `Err(GraphError::ReadOnly)` if called on an as-of instance —
4795    /// as-of instances never commit, so `distribute_events` never runs and the
4796    /// subscription would never deliver events.
4797    pub fn subscribe_writes(&mut self) -> core_storage::Result<Subscription> {
4798        if self.read_only {
4799            return Err(core_storage::GraphError::ReadOnly);
4800        }
4801        let inner = SubInner::new(self.sub_capacity());
4802        self.subscriptions.push(SubEntry {
4803            filter: SubFilter::Writes,
4804            inner: std::sync::Arc::downgrade(&inner),
4805        });
4806        self.engine.set_emit_deltas(true);
4807        Ok(Subscription(inner))
4808    }
4809
4810    /// Subscribe to incremental Cypher query results.
4811    ///
4812    /// Parses and plans `cypher`; rejects the query if the plan is not in the
4813    /// allowlisted subset (see [`core_query::cypher::is_subscribable`]):
4814    ///   - `MATCH (n:Label) WHERE … RETURN … [LIMIT n]`
4815    ///   - `MATCH (a)-[r:TYPE]->(b) RETURN … [LIMIT n]`  (exactly one hop)
4816    ///
4817    /// SKIP is not supported — it shifts the result window on every commit,
4818    /// causing spurious Added/Removed churn for rows whose data never changed.
4819    /// Multi-hop Expand chains are not supported; each additional MATCH clause
4820    /// widens scope beyond the documented single-scan / single-hop subset.
4821    ///
4822    /// After each successful commit, the plan is **fully re-executed** and the
4823    /// result is diffed against the previous run. Added rows produce
4824    /// [`DbEvent::QueryRowAdded`]; removed rows produce
4825    /// [`DbEvent::QueryRowRemoved`].
4826    ///
4827    /// **Full re-run per commit; use LIMIT to bound execution cost.**
4828    /// The existing 1 M intermediate-row cap applies. Differential evaluation
4829    /// is roadmap / Phase 5.
4830    ///
4831    /// Returns `Err(GraphError::ReadOnly)` if called on an as-of instance —
4832    /// as-of instances never commit, so `distribute_events` never runs and the
4833    /// subscription would never deliver events.
4834    ///
4835    /// Returns `Err(GraphError::QueryError)` if the query fails to parse, plan,
4836    /// or if the plan shape is not in the allowlist.
4837    pub fn subscribe_query(&mut self, cypher: &str) -> Result<Subscription> {
4838        if self.read_only {
4839            return Err(GraphError::ReadOnly);
4840        }
4841        let tokens = lex(cypher).map_err(|e| GraphError::QueryError {
4842            detail: format!("lex: {e}"),
4843        })?;
4844        let ast = parse(&tokens).map_err(|e| GraphError::QueryError {
4845            detail: format!("parse: {e}"),
4846        })?;
4847        let ops = plan(&ast).map_err(|e| GraphError::QueryError {
4848            detail: format!("plan: {e}"),
4849        })?;
4850        if !is_subscribable(&ops) {
4851            return Err(GraphError::QueryError {
4852                detail: "subscribe_query only supports allowlisted plan shapes: \
4853                         MATCH (n:Label) WHERE … RETURN … [LIMIT n] or \
4854                         MATCH (a)-[r:TYPE]->(b) RETURN … [LIMIT n] (exactly one hop). \
4855                         Not supported: multi-hop Expand chains, SKIP (creates \
4856                         unstable offset windows), ORDER BY, DISTINCT, aggregates, \
4857                         variable-length paths, OPTIONAL MATCH, WITH, UNWIND. \
4858                         Use LIMIT to bound re-execution cost."
4859                    .to_string(),
4860            });
4861        }
4862        // Execute once to capture initial state (initial rows are not emitted as
4863        // events — the subscriber learns the baseline via the first query call).
4864        let empty_params = BTreeMap::new();
4865        let initial = execute(&self.view(), &ops, &Params(&empty_params)).map_err(|e| {
4866            GraphError::QueryError {
4867                detail: format!("execute: {e}"),
4868            }
4869        })?;
4870        let columns = initial.columns().to_vec();
4871        let prev_row_map = Self::result_to_row_map(&initial);
4872        let inner = SubInner::new(self.sub_capacity());
4873        // Derive the scan-label sym for the commit-skip fast-path.  Any Expand op
4874        // or unrecognized leading scan → None (always re-execute).
4875        let scan_label = extract_scan_label(&ops, &mut self.syms);
4876        self.query_subscriptions.push(QuerySubEntry {
4877            ops,
4878            columns,
4879            prev_row_map,
4880            inner: std::sync::Arc::downgrade(&inner),
4881            scan_label,
4882        });
4883        Ok(Subscription(inner))
4884    }
4885
4886    /// Queue capacity used for new subscriptions.
4887    fn sub_capacity(&self) -> usize {
4888        self.sub_capacity
4889    }
4890
4891    /// Override per-subscriber queue capacity for subsequently created
4892    /// subscriptions on this db instance.
4893    ///
4894    /// Default is [`DEFAULT_SUB_CAPACITY`] (65,536 events). Use a smaller
4895    /// value in tests to exercise the [`DbEvent::Lagged`] path without
4896    /// generating tens of thousands of events.
4897    ///
4898    /// This is a test-support escape hatch. Calling it in production reduces
4899    /// subscriber reliability (more Lagged events). It is hidden from rustdoc
4900    /// to discourage accidental production use.
4901    #[doc(hidden)]
4902    pub fn set_sub_capacity(&mut self, capacity: usize) {
4903        self.sub_capacity = capacity;
4904    }
4905
4906    // -----------------------------------------------------------------------
4907
4908    /// Start an atomic batch.
4909    ///
4910    /// The returned [`BatchBuilder`] borrows `self` mutably until
4911    /// [`BatchBuilder::commit`]. Builder methods queue ops only — no
4912    /// validation, no WAL I/O. `commit` validates every queued op against
4913    /// live state plus preceding ops in this batch (duplicate key inside
4914    /// the batch is `Err`; an edge between two nodes created earlier in
4915    /// the batch is valid; `delete_node` then insert of the same key is a
4916    /// fresh identity). Validation never mutates the database. Any failure
4917    /// leaves WAL bytes and in-memory state identical to before `commit`.
4918    /// On success, one `WalRecord::Batch` frame is appended (one fsync)
4919    /// and each inner record is applied in order so rules fire per record.
4920    /// An empty batch, or a batch of only no-ops, writes zero WAL bytes.
4921    ///
4922    /// **Rule-window limitation:** batch validation cannot see edges that a
4923    /// rule created earlier in the *same* batch will derive at apply time, so
4924    /// a `delete_edge` / `insert_edge` in that window is silently no-oped
4925    /// where sequential calls would return `Err(RuleOwned)`. State integrity
4926    /// is unaffected (idempotent apply, provenance intact). Create rules in
4927    /// their own batch, or sequentially, when later ops may touch derived
4928    /// edges.
4929    pub fn batch(&mut self) -> BatchBuilder<'_, F> {
4930        BatchBuilder {
4931            db: self,
4932            ops: Vec::new(),
4933        }
4934    }
4935
4936    /// Closure-style atomic write batch.
4937    ///
4938    /// Equivalent to calling [`GraphDb::batch`], invoking `build` to queue ops,
4939    /// then committing. All ops queued inside `build` are validated in order and
4940    /// committed as a single `WalRecord::Batch` frame (one fsync). Rules fire
4941    /// once per inner record, in order, after commit — semantically identical to
4942    /// sequential single-op writes.
4943    ///
4944    /// **Error semantics — validate-then-apply.** `build` queues ops without
4945    /// touching the database. [`BatchBuilder::commit`] validates every op against
4946    /// live state plus earlier ops in this batch before writing anything. If op N
4947    /// fails validation (duplicate key, unknown key, rule-owned edge, …) the
4948    /// entire batch is rejected: no WAL bytes are written and no in-memory state
4949    /// changes. The database is identical to its state before `write_batch` was
4950    /// called.
4951    ///
4952    /// **Atomicity is crash-level, NOT isolation-level.** On replay after a crash,
4953    /// a partial (torn) `Batch` frame applies NONE of its ops — the frame is
4954    /// either fully applied or not at all. However, while applying a committed
4955    /// batch, concurrent readers may observe intermediate states as ops are applied
4956    /// sequentially in memory. There is no interactive transaction isolation in v1.
4957    /// This is documented as "crash-atomic write batches; no interactive
4958    /// transactions or read isolation."
4959    ///
4960    /// **Returns** `(nodes_inserted, edges_inserted)`. An empty or all-noop batch
4961    /// writes zero WAL bytes and returns `(0, 0)`.
4962    ///
4963    /// # Example
4964    ///
4965    /// ```rust,ignore
4966    /// let (nodes, edges) = db.write_batch(|b| {
4967    ///     b.insert_node("Person", "alice", vec![("age".into(), Value::Int(30))]);
4968    ///     b.insert_node("Person", "bob", vec![]);
4969    ///     b.insert_edge("KNOWS", "alice", "bob");
4970    ///     b.set_prop("alice", "role", Value::Str("admin".into()));
4971    ///     b.delete_node("old_key");
4972    /// })?;
4973    /// // One fsync; on crash replay: all five ops land or none do.
4974    /// ```
4975    pub fn write_batch<C>(&mut self, build: C) -> Result<(usize, usize)>
4976    where
4977        C: FnOnce(&mut BatchBuilder<'_, F>),
4978    {
4979        let mut b = self.batch();
4980        build(&mut b);
4981        b.commit()
4982    }
4983
4984    /// Insert `rows` as nodes of `label`. One call is one atomic batch:
4985    /// auto-declared KeyMatch rules (if any) first, then the accepted node
4986    /// inserts, so incremental fire sees the new rules. Per-row key problems
4987    /// are collected in [`IngestReport::row_errors`] and skipped; a commit
4988    /// `Err` means nothing was applied.
4989    ///
4990    /// Auto-FK rule names are `auto_fk_<src_label_lowercase>_<field>` so
4991    /// distinct source labels sharing an FK field each get their own rule.
4992    pub fn ingest(
4993        &mut self,
4994        label: &str,
4995        rows: Vec<BTreeMap<String, Value>>,
4996        opts: &IngestOptions,
4997    ) -> Result<IngestReport> {
4998        self.ingest_with_edges(label, rows, opts, &[])
4999    }
5000
5001    /// [`ingest`] plus user edges in the **same** previewed WAL batch.
5002    /// A failing edge rejects the whole request; nothing is applied.
5003    pub fn ingest_with_edges(
5004        &mut self,
5005        label: &str,
5006        rows: Vec<BTreeMap<String, Value>>,
5007        opts: &IngestOptions,
5008        edges: &[(String, String, String)],
5009    ) -> Result<IngestReport> {
5010        crate::ingest::run(self, label, rows, opts, edges)
5011    }
5012
5013    /// Parse `json` as an array of objects and ingest via [`GraphDb::ingest`].
5014    ///
5015    /// JSON `null` fields are silently omitted (not stored, not a row error).
5016    /// Nested objects and arrays-of-objects are a per-row error (row skipped).
5017    /// Parse failures and a top-level value that is not an array of objects
5018    /// return [`GraphError::IngestError`].
5019    pub fn ingest_json(
5020        &mut self,
5021        label: &str,
5022        json: &str,
5023        opts: &IngestOptions,
5024    ) -> Result<IngestReport> {
5025        crate::ingest::run_json(self, label, json, opts)
5026    }
5027
5028    fn commit_logged_batch(
5029        &mut self,
5030        ops: Vec<BatchOp>,
5031        ingest: Option<(String, usize)>,
5032        // Two-source rule: write_batch_authz threads authz here directly (never
5033        // touches pending_write_authz); query_write_authz sets the field instead
5034        // and passes None.  Only one source is non-None per call.
5035        param_authz: Option<WriteAuthz>,
5036    ) -> Result<(usize, usize)> {
5037        // Read-only guard: catches empty-batch calls before the early-return
5038        // that skips log_then_apply_with, ensuring all mutation entry points fail.
5039        if self.read_only {
5040            return Err(GraphError::ReadOnly);
5041        }
5042        // Ensure provenance is decoded before MutPreview accesses it
5043        // (note_delete_rule / is_rule_owned may call engine.provenance()).
5044        self.engine.ensure_provenance_loaded_mut();
5045
5046        // ── Authz pre-check ──────────────────────────────────────────────────
5047        // Evaluate the decision table per-op BEFORE MutPreview so that a denial
5048        // produces no WAL frame (all-or-nothing at the authz boundary extends
5049        // the existing validate-then-apply contract to role-scope checks).
5050        //
5051        // `batch_created` tracks key→label for nodes created by earlier ops in
5052        // THIS batch, so InsertEdgeUpsert can count same-batch placeholder nodes
5053        // as visible without needing to call `self.ids.get` on not-yet-committed
5054        // keys (they won't be there yet).
5055        //
5056        // Two-source rule: param_authz (write_batch_authz path) takes precedence;
5057        // fall back to self.pending_write_authz (query_write_authz/Cypher path).
5058        // Cloning the field copy avoids a simultaneous borrow of self.ids below.
5059        let authz_opt = param_authz.or_else(|| self.pending_write_authz.clone());
5060        if let Some(ref authz) = authz_opt {
5061            let mut batch_created: BTreeMap<String, String> = BTreeMap::new();
5062            for op in &ops {
5063                self.check_single_op_authz(authz, op, &batch_created)?;
5064                // Update batch_created after a passing authz check so that
5065                // subsequent ops in this batch see the nodes as "about to exist".
5066                match op {
5067                    BatchOp::InsertNode { label, key, .. } => {
5068                        // Only track genuinely new nodes (absent from the
5069                        // snapshot at authz-check time). A pre-existing visible
5070                        // key would be a DuplicateKey — not a real creation —
5071                        // so MutPreview handles it. Letting it into batch_created
5072                        // would allow a later SetProp to bypass update_labels
5073                        // via the "batch-created → always updatable" ruling
5074                        // (delete+recreate exploit, fix for I1 review round 2).
5075                        //
5076                        // Accepted edge: for a delete+recreate-with-different-
5077                        // label batch, node_status resolves the pre-delete
5078                        // (store) label for any subsequent update checks. This
5079                        // grants no net-new capability — a role that can delete+
5080                        // create can already place arbitrary props via
5081                        // InsertNode's own props field.
5082                        if self.ids.get(key.as_str()).is_none() {
5083                            batch_created.insert(key.clone(), label.clone());
5084                        }
5085                    }
5086                    BatchOp::InsertEdgeUpsert {
5087                        placeholder_label,
5088                        src_key,
5089                        dst_key,
5090                        ..
5091                    } => {
5092                        // Both endpoints will be created if not already in store.
5093                        for ep_key in [src_key, dst_key] {
5094                            if self.ids.get(ep_key.as_str()).is_none()
5095                                && !batch_created.contains_key(ep_key.as_str())
5096                            {
5097                                batch_created.insert(ep_key.clone(), placeholder_label.clone());
5098                            }
5099                        }
5100                    }
5101                    _ => {}
5102                }
5103            }
5104        }
5105
5106        let recs = {
5107            let mut preview = MutPreview::new(self);
5108            let mut recs = Vec::with_capacity(ops.len());
5109            for op in ops {
5110                match op {
5111                    BatchOp::InsertNode { label, key, props } => {
5112                        preview.check_insert_node(&key)?;
5113                        preview.note_insert_node(&key, &props);
5114                        recs.push(WalRecord::InsertNode { label, key, props });
5115                    }
5116                    BatchOp::InsertEdge {
5117                        edge_type,
5118                        src_key,
5119                        dst_key,
5120                    } => {
5121                        if preview.prepare_insert_edge(&edge_type, &src_key, &dst_key)? {
5122                            preview.note_insert_edge(&edge_type, &src_key, &dst_key);
5123                            recs.push(WalRecord::InsertEdge {
5124                                edge_type,
5125                                src_key,
5126                                dst_key,
5127                            });
5128                        }
5129                    }
5130                    BatchOp::SetProp { key, field, value } => {
5131                        preview.check_live_key(&key)?;
5132                        preview.note_set_prop(&key, &field, &value);
5133                        recs.push(WalRecord::SetProp { key, field, value });
5134                    }
5135                    BatchOp::RemoveProp { key, field } => {
5136                        if preview.prepare_remove_prop(&key, &field)? {
5137                            preview.note_remove_prop(&key, &field);
5138                            recs.push(WalRecord::RemoveProp { key, field });
5139                        }
5140                    }
5141                    BatchOp::DeleteEdge {
5142                        edge_type,
5143                        src_key,
5144                        dst_key,
5145                    } => {
5146                        if preview.prepare_delete_edge(&edge_type, &src_key, &dst_key)? {
5147                            preview.note_delete_edge(&edge_type, &src_key, &dst_key);
5148                            recs.push(WalRecord::DeleteEdge {
5149                                edge_type,
5150                                src_key,
5151                                dst_key,
5152                            });
5153                        }
5154                    }
5155                    BatchOp::DeleteNode { key } => {
5156                        preview.check_live_key(&key)?;
5157                        preview.note_delete_node(&key);
5158                        recs.push(WalRecord::DeleteNode { key });
5159                    }
5160                    BatchOp::CreateRule(def) => {
5161                        preview.check_create_rule(&def)?;
5162                        let def_bytes =
5163                            bincode::serialize(&def).map_err(|e| GraphError::Corrupt {
5164                                detail: format!("serialize rule: {e}"),
5165                            })?;
5166                        preview.note_create_rule(&def);
5167                        recs.push(WalRecord::CreateRule { def_bytes });
5168                    }
5169                    BatchOp::DeleteRule { name } => {
5170                        preview.check_delete_rule(&name)?;
5171                        preview.note_delete_rule(&name);
5172                        recs.push(WalRecord::DeleteRule { name });
5173                    }
5174                    BatchOp::RenameNode { old_key, new_key } => {
5175                        preview.check_rename_node(&old_key, &new_key)?;
5176                        preview.note_rename_node(&old_key, &new_key);
5177                        recs.push(WalRecord::RenameNode { old_key, new_key });
5178                    }
5179                    BatchOp::InsertEdgeUpsert {
5180                        edge_type,
5181                        src_key,
5182                        dst_key,
5183                        placeholder_label,
5184                    } => {
5185                        // Auto-create any missing endpoints as plain InsertNode ops.
5186                        // Rules fire and last-change is updated for each created node.
5187                        for key in [&src_key, &dst_key] {
5188                            if !preview.has_key(key) {
5189                                preview.check_insert_node(key)?;
5190                                preview.note_insert_node(key, &[]);
5191                                recs.push(WalRecord::InsertNode {
5192                                    label: placeholder_label.clone(),
5193                                    key: key.clone(),
5194                                    props: vec![],
5195                                });
5196                            }
5197                        }
5198                        if preview.prepare_insert_edge(&edge_type, &src_key, &dst_key)? {
5199                            preview.note_insert_edge(&edge_type, &src_key, &dst_key);
5200                            recs.push(WalRecord::InsertEdge {
5201                                edge_type,
5202                                src_key,
5203                                dst_key,
5204                            });
5205                        }
5206                    }
5207                }
5208            }
5209            recs
5210        };
5211        if recs.is_empty() {
5212            return Ok((0, 0));
5213        }
5214        // rewrite_wal_dense converts every InsertNode/InsertEdge into its
5215        // *Id form, so only the dense variants can appear in `recs` here.
5216        let recs = self.rewrite_wal_dense(recs)?;
5217        let nodes_inserted = recs
5218            .iter()
5219            .filter(|r| matches!(r, WalRecord::InsertNodeId { .. }))
5220            .count();
5221        let edges_inserted = recs
5222            .iter()
5223            .filter(|r| matches!(r, WalRecord::InsertEdgeId { .. }))
5224            .count();
5225        // Ingest / write_batch / query_write: one Batch frame, one fsync per call
5226        // under Strict.  Pass self.fsync directly so Strict stays Strict —
5227        // wal_needs_sync(Strict, _) always returns true regardless of op count.
5228        // Mapping Strict → Batched (the prior bug) caused wal_needs_sync to
5229        // short-circuit on single-op batches and silently skip the fsync.
5230        // Batched fsyncs only for multi-op batches; Relaxed always skips.
5231        self.log_then_apply_with(WalRecord::Batch(recs), ingest, self.fsync)?;
5232        Ok((nodes_inserted, edges_inserted))
5233    }
5234
5235    fn commit_batch(&mut self, ops: Vec<BatchOp>) -> Result<(usize, usize)> {
5236        self.commit_logged_batch(ops, None, None)
5237    }
5238
5239    /// Commit one submission WITHOUT an fsync — for use inside `commit_group`
5240    /// and the group-commit drain thread, which do a single group fsync later.
5241    fn commit_batch_nosync(&mut self, ops: Vec<BatchOp>) -> Result<(usize, usize)> {
5242        // Restore fsync policy even on panic via a raw-pointer drop guard.
5243        // A panic here would poison the RwLock anyway, but the correct policy
5244        // must be in place if the guard is ever unwrapped.
5245        struct RestoreFsync(*mut FsyncPolicy, FsyncPolicy);
5246        impl Drop for RestoreFsync {
5247            fn drop(&mut self) {
5248                // SAFETY: the pointer is valid for the full duration of
5249                // commit_batch_nosync; the guard is dropped before the frame
5250                // returns, and GraphDb outlives this frame.
5251                unsafe {
5252                    *self.0 = self.1;
5253                }
5254            }
5255        }
5256        let saved = self.fsync;
5257        // SAFETY: raw pointer into self; guard dropped within this frame.
5258        let _g = RestoreFsync(&mut self.fsync as *mut FsyncPolicy, saved);
5259        self.fsync = FsyncPolicy::Relaxed;
5260        self.commit_logged_batch(ops, None, None)
5261    }
5262
5263    /// Commit multiple op-batches as a **group**: each submission gets its own
5264    /// WAL `Batch` frame, but there is exactly **one** `Fs::sync` for the whole
5265    /// group (under `Strict` / `Batched` policy; `Relaxed` skips all syncs).
5266    ///
5267    /// # Durability semantics
5268    ///
5269    /// A crash before the group fsync may lose **all** submissions in the group.
5270    /// A crash after the group fsync preserves all of them.  No submission is
5271    /// ever torn: each WAL frame is either fully applied on replay or dropped
5272    /// in its entirety (CRC-protected frame boundaries).
5273    ///
5274    /// Events and subscription notifications fire per-submission immediately
5275    /// after apply, which may be before the group fsync.  From a subscriber's
5276    /// perspective this is equivalent to the `Relaxed` durability window.
5277    /// Submitters using [`SharedDb::submit_batch`] only unblock after the group
5278    /// fsync, so from their perspective durability is fully guaranteed.
5279    ///
5280    /// # MVCC interplay
5281    ///
5282    /// Each submission records its own `CommitDelta`; the fold-every-K counter
5283    /// increments per submission (not per group), preserving existing reader
5284    /// snapshot semantics.
5285    ///
5286    /// # Returns
5287    ///
5288    /// One `Result<(nodes_inserted, edges_inserted)>` per input group element,
5289    /// in order.  Failures are per-submission (validation errors); the group
5290    /// fsync error (if any) is returned as the second tuple element.
5291    pub fn commit_group(
5292        &mut self,
5293        groups: Vec<Vec<BatchOp>>,
5294    ) -> (Vec<Result<(usize, usize)>>, Option<GraphError>) {
5295        let mut results = Vec::with_capacity(groups.len());
5296        for ops in groups {
5297            results.push(self.commit_batch_nosync(ops));
5298        }
5299        let any_ok = results.iter().any(|r| r.is_ok());
5300        let sync_err = if self.fsync != FsyncPolicy::Relaxed && any_ok {
5301            self.fs
5302                .sync(core_storage::fs::FileId::Wal)
5303                .map_err(GraphError::Io)
5304                .err()
5305        } else {
5306            None
5307        };
5308        (results, sync_err)
5309    }
5310
5311    /// Like [`commit_group`] but skips the group fsync entirely.
5312    ///
5313    /// Used by the drain thread to apply submissions under the write lock and
5314    /// then perform the single fsync OUTSIDE the lock (via
5315    /// `core_storage::sync_wal_at`), reducing the write-lock hold time visible
5316    /// to concurrent readers.
5317    pub fn commit_group_nosync(
5318        &mut self,
5319        groups: Vec<Vec<BatchOp>>,
5320    ) -> Vec<Result<(usize, usize)>> {
5321        let mut results = Vec::with_capacity(groups.len());
5322        for ops in groups {
5323            results.push(self.commit_batch_nosync(ops));
5324        }
5325        results
5326    }
5327
5328    pub fn insert_node(
5329        &mut self,
5330        label: &str,
5331        key: &str,
5332        props: Vec<(String, Value)>,
5333    ) -> Result<()> {
5334        if self.read_only {
5335            return Err(GraphError::ReadOnly);
5336        }
5337        MutPreview::new(self).check_insert_node(key)?;
5338        self.log_dense(vec![WalRecord::InsertNode {
5339            label: label.into(),
5340            key: key.into(),
5341            props,
5342        }])
5343    }
5344
5345    pub fn insert_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) -> Result<bool> {
5346        if self.read_only {
5347            return Err(GraphError::ReadOnly);
5348        }
5349        if !MutPreview::new(self).prepare_insert_edge(edge_type, src_key, dst_key)? {
5350            return Ok(false);
5351        }
5352        self.log_dense(vec![WalRecord::InsertEdge {
5353            edge_type: edge_type.into(),
5354            src_key: src_key.into(),
5355            dst_key: dst_key.into(),
5356        }])?;
5357        Ok(true)
5358    }
5359
5360    pub fn set_prop(&mut self, key: &str, field: &str, value: Value) -> Result<()> {
5361        if self.read_only {
5362            return Err(GraphError::ReadOnly);
5363        }
5364        if let Some(view_name) = self.view_store.view_for_prop(field) {
5365            return Err(GraphError::ViewPropReadOnly {
5366                view_name: view_name.to_string(),
5367            });
5368        }
5369        MutPreview::new(self).check_live_key(key)?;
5370        self.log_dense(vec![WalRecord::SetProp {
5371            key: key.into(),
5372            field: field.into(),
5373            value,
5374        }])
5375    }
5376
5377    /// Remove a property. Returns `Ok(false)` (and does not log) if the field
5378    /// is already absent. Unknown or tombstoned keys are `Err(KeyNotFound)`.
5379    pub fn remove_prop(&mut self, key: &str, field: &str) -> Result<bool> {
5380        if self.read_only {
5381            return Err(GraphError::ReadOnly);
5382        }
5383        if let Some(view_name) = self.view_store.view_for_prop(field) {
5384            return Err(GraphError::ViewPropReadOnly {
5385                view_name: view_name.to_string(),
5386            });
5387        }
5388        if !MutPreview::new(self).prepare_remove_prop(key, field)? {
5389            return Ok(false);
5390        }
5391        self.log_then_apply(WalRecord::RemoveProp {
5392            key: key.into(),
5393            field: field.into(),
5394        })?;
5395        Ok(true)
5396    }
5397
5398    /// Delete a user edge. Returns `Ok(false)` (and does not log) if the edge
5399    /// is absent. Unknown keys are `Err(KeyNotFound)`. Rule-owned edges — in
5400    /// provenance, or a pair a live rule would derive — are `Err(RuleOwned)`
5401    /// (the rule would just put the edge back; delete or change the rule).
5402    pub fn delete_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) -> Result<bool> {
5403        if self.read_only {
5404            return Err(GraphError::ReadOnly);
5405        }
5406        if !MutPreview::new(self).prepare_delete_edge(edge_type, src_key, dst_key)? {
5407            return Ok(false);
5408        }
5409        self.log_then_apply(WalRecord::DeleteEdge {
5410            edge_type: edge_type.into(),
5411            src_key: src_key.into(),
5412            dst_key: dst_key.into(),
5413        })?;
5414        Ok(true)
5415    }
5416
5417    /// Delete a live node. Unknown or already-tombstoned keys are
5418    /// `Err(KeyNotFound)` and are not logged. Validation runs before the WAL
5419    /// write; `apply` of a logged `DeleteNode` for an already-tombstoned key
5420    /// (crash window) is a clean no-op.
5421    ///
5422    /// Returns a [`DeleteReport`] with counts of manual and derived edges
5423    /// removed (computed from live state before the deletion is applied).
5424    pub fn delete_node(&mut self, key: &str) -> Result<DeleteReport> {
5425        if self.read_only {
5426            return Err(GraphError::ReadOnly);
5427        }
5428        // Provenance must be loaded before we query provenance_touching.
5429        self.engine.ensure_provenance_loaded_mut();
5430        let id = self
5431            .ids
5432            .get(key)
5433            .ok_or_else(|| GraphError::KeyNotFound { key: key.into() })?;
5434
5435        // Count edges before the delete is applied so we can report counts.
5436        let derived_set: BTreeSet<(u32, u32, u32)> = self
5437            .engine
5438            .provenance_touching(id)
5439            .map(|(_, etype, src, dst)| (etype, src, dst))
5440            .collect();
5441        let derived_edges = derived_set.len() as u64;
5442
5443        let mut total_topo = 0u64;
5444        let tv = self.topo_view();
5445        for et in tv.etypes() {
5446            total_topo += tv.neighbors(et, Direction::Out, id).len() as u64
5447                + tv.neighbors(et, Direction::In, id).len() as u64;
5448        }
5449        // For symmetric rules (e.g. Overlap), a→b and b→a are two separate directed
5450        // triples in both the topo scan (Out and In from id) and in provenance_touching.
5451        // The subtraction remains correct because both counts include both directions.
5452        let manual_edges = total_topo.saturating_sub(derived_edges);
5453
5454        self.log_then_apply(WalRecord::DeleteNode { key: key.into() })?;
5455        Ok(DeleteReport {
5456            manual_edges,
5457            derived_edges,
5458        })
5459    }
5460
5461    /// Rename a live node's key.  The dense id (and therefore all edges,
5462    /// props, history, and last-change tracking) is unaffected.
5463    ///
5464    /// Returns `Err(KeyNotFound)` if `old` is not a live key.
5465    /// Returns `Err(DuplicateKey)` if `new` is already live.
5466    pub fn rename_node(&mut self, old: &str, new: &str) -> Result<()> {
5467        if self.read_only {
5468            return Err(GraphError::ReadOnly);
5469        }
5470        MutPreview::new(self).check_rename_node(old, new)?;
5471        self.log_then_apply(WalRecord::RenameNode {
5472            old_key: old.into(),
5473            new_key: new.into(),
5474        })
5475    }
5476
5477    /// Return the IVF drift counter for the dst-side candidate index of `rule`.
5478    /// `None` if the rule does not exist or is not approximate.
5479    ///
5480    /// The drift counter increments on IVF insert/remove after the last fit.
5481    /// When dst-side drift exceeds [`core_rules::IVF_DRIFT_REBUILD`], apply
5482    /// WAL-logs `RebuildRule` as a second commit (rebuild resets the counter).
5483    pub fn ivf_dst_drift(&self, rule: &str) -> Option<u64> {
5484        // SideIvfExport = (centroids, node→cluster, drift)
5485        self.engine
5486            .export_ivf_state()
5487            .remove(rule)
5488            .map(|(_src, dst)| dst.2)
5489    }
5490
5491    /// Validate and WAL-log a new rule, then backfill derived edges inside apply.
5492    /// Validation and duplicate-name check run before logging so invalid rules
5493    /// never enter the WAL.
5494    pub fn create_rule(&mut self, def: RuleDef) -> Result<()> {
5495        if self.read_only {
5496            return Err(GraphError::ReadOnly);
5497        }
5498        MutPreview::new(self).check_create_rule(&def)?;
5499        let def_bytes = bincode::serialize(&def).map_err(|e| GraphError::Corrupt {
5500            detail: format!("serialize rule: {e}"),
5501        })?;
5502        self.log_then_apply(WalRecord::CreateRule { def_bytes })
5503    }
5504
5505    /// WAL-log rule deletion. Returns RuleNotFound if the rule does not exist.
5506    pub fn delete_rule(&mut self, name: &str) -> Result<()> {
5507        if self.read_only {
5508            return Err(GraphError::ReadOnly);
5509        }
5510        MutPreview::new(self).check_delete_rule(name)?;
5511        self.log_then_apply(WalRecord::DeleteRule { name: name.into() })
5512    }
5513
5514    /// Return a snapshot of all registered rules.
5515    pub fn rules(&self) -> Vec<RuleDef> {
5516        self.engine.rules().cloned().collect()
5517    }
5518
5519    // -----------------------------------------------------------------------
5520    // Rule suggestion API
5521    // -----------------------------------------------------------------------
5522
5523    /// Profile the database and suggest linking rules with previewed edge counts.
5524    ///
5525    /// Uses the default seed ([`core_rules::SUGGEST_DEFAULT_SEED`]) for deterministic
5526    /// sampling. Suggestions are sorted by estimated edge count (descending).
5527    /// **NO auto-accept** — call [`GraphDb::create_rule`] explicitly to apply.
5528    pub fn suggest_rules(&self) -> Vec<core_rules::RuleSuggestion> {
5529        self.suggest_rules_seeded(core_rules::SUGGEST_DEFAULT_SEED)
5530    }
5531
5532    /// Like [`suggest_rules`] but with a caller-supplied RNG seed for
5533    /// reproducibility. Same seed + same data = identical output.
5534    pub fn suggest_rules_seeded(&self, seed: u64) -> Vec<core_rules::RuleSuggestion> {
5535        self.suggest_rules_with_config(&core_rules::suggest::SuggestConfig::default(), seed)
5536            .suggestions
5537    }
5538
5539    /// [`suggest_rules_seeded`] with a fully custom [`SuggestConfig`].
5540    ///
5541    /// Returns a [`core_rules::SuggestReport`] that includes both the candidate list
5542    /// and a `truncated` flag indicating whether the global budget fired before all
5543    /// candidates were evaluated.
5544    pub fn suggest_rules_with_config(
5545        &self,
5546        config: &core_rules::suggest::SuggestConfig,
5547        seed: u64,
5548    ) -> core_rules::SuggestReport {
5549        use std::collections::BTreeMap;
5550
5551        // Collect (node_id, key) pairs per label, skipping tombstoned nodes.
5552        let mut label_nodes: BTreeMap<String, Vec<(u32, String)>> = BTreeMap::new();
5553        for id in 0..self.ids.len() as u32 {
5554            let Some(key) = self.ids.key_of(id) else {
5555                continue;
5556            };
5557            let Some(&sym) = self.labels.get(id as usize) else {
5558                continue;
5559            };
5560            if sym == u32::MAX {
5561                continue; // tombstoned
5562            }
5563            let Some(label) = self.syms.resolve(sym) else {
5564                continue;
5565            };
5566            label_nodes
5567                .entry(label.to_string())
5568                .or_default()
5569                .push((id, key.to_string()));
5570        }
5571
5572        let existing = self.rules();
5573        let pv = build_props_view(&self.props, &self.base);
5574        let all_fields: Vec<String> = pv.field_names();
5575
5576        core_rules::suggest::suggest_rules(
5577            &label_nodes,
5578            &|id, field| pv.get(id, field).map(|vr| vr.into_value()),
5579            &all_fields,
5580            &existing,
5581            config,
5582            seed,
5583        )
5584    }
5585
5586    /// Recompute a rule's derived edges from scratch. WAL-logged so un-trip
5587    /// plus later mutations replay identically (rebuild is a pure function
5588    /// of state).
5589    ///
5590    /// Only exit from the tripped latch: if the full desired set fits the
5591    /// budget, it is applied completely and `tripped` clears; if it still
5592    /// exceeds the budget, provenance is left untouched and `tripped` stays
5593    /// true. Counts as a fire evaluation (see [`RuleStats::fires`]).
5594    /// Unknown rule → `RuleNotFound`, nothing logged.
5595    pub fn rebuild_rule(&mut self, name: &str) -> Result<()> {
5596        if self.read_only {
5597            return Err(GraphError::ReadOnly);
5598        }
5599        if !self.engine.rules().any(|r| r.name == name) {
5600            return Err(GraphError::RuleNotFound { name: name.into() });
5601        }
5602        self.log_then_apply(WalRecord::RebuildRule { name: name.into() })
5603    }
5604
5605    // -----------------------------------------------------------------------
5606    // Materialized view API
5607    // -----------------------------------------------------------------------
5608
5609    /// Register a new materialized property view, backfill its values for all
5610    /// existing nodes, and WAL-log the definition.
5611    ///
5612    /// # Errors
5613    /// - `ReadOnly`: called on an as-of instance.
5614    /// - `RuleInvalid`: name collision, view_prop collision, or invalid def.
5615    pub fn create_view(&mut self, def: ViewDef) -> Result<()> {
5616        if self.read_only {
5617            return Err(GraphError::ReadOnly);
5618        }
5619        // Pre-validate before WAL write.
5620        def.validate()
5621            .map_err(|e| GraphError::RuleInvalid { detail: e })?;
5622        if self.view_store.has_view(&def.name) {
5623            return Err(GraphError::RuleInvalid {
5624                detail: format!("view {:?} already exists", def.name),
5625            });
5626        }
5627        if let Some(existing) = self.view_store.view_for_prop(&def.view_prop) {
5628            return Err(GraphError::RuleInvalid {
5629                detail: format!(
5630                    "view_prop {:?} is already used by view {:?}",
5631                    def.view_prop, existing
5632                ),
5633            });
5634        }
5635        let def_bytes = bincode::serialize(&def).map_err(|e| GraphError::Corrupt {
5636            detail: format!("serialize view: {e}"),
5637        })?;
5638        // Enable delta accumulation before the view is registered so subsequent
5639        // incremental edge events reach view maintenance from this point onward.
5640        // (The backfill inside create_view reads topo directly; it does not rely
5641        // on pending deltas.)
5642        self.engine.set_emit_deltas(true);
5643        self.log_then_apply(WalRecord::CreateView { def_bytes })
5644    }
5645
5646    /// Remove a named view and delete its values from every node.
5647    ///
5648    /// # Errors
5649    /// - `ReadOnly`: called on an as-of instance.
5650    /// - `RuleNotFound`: view does not exist.
5651    pub fn delete_view(&mut self, name: &str) -> Result<()> {
5652        if self.read_only {
5653            return Err(GraphError::ReadOnly);
5654        }
5655        if !self.view_store.has_view(name) {
5656            return Err(GraphError::RuleNotFound { name: name.into() });
5657        }
5658        let result = self.log_then_apply(WalRecord::DeleteView { name: name.into() });
5659        // After deletion, disable accumulation if no listeners remain.
5660        if !self.needs_emit_deltas() {
5661            self.engine.set_emit_deltas(false);
5662        }
5663        result
5664    }
5665
5666    /// Snapshot of all registered view definitions.
5667    pub fn views(&self) -> Vec<ViewDef> {
5668        self.view_store.views().cloned().collect()
5669    }
5670
5671    // -----------------------------------------------------------------------
5672    // Full-text-lite API
5673    // -----------------------------------------------------------------------
5674
5675    /// Enable full-text indexing for all nodes of `label` on property `field`.
5676    ///
5677    /// After this call, every subsequent write to `(label, field)` is reflected
5678    /// in the index incrementally.  Existing nodes are backfilled immediately.
5679    /// The declaration is persisted as a WAL record; the index itself is rebuilt
5680    /// from scratch on re-open (no snapshot format changes).
5681    ///
5682    /// # Errors
5683    /// - [`GraphError::ReadOnly`]: called on an as-of instance.
5684    /// - [`GraphError::RuleInvalid`]: `(label, field)` is already indexed.
5685    pub fn enable_fulltext(&mut self, label: &str, field: &str) -> Result<()> {
5686        if self.read_only {
5687            return Err(GraphError::ReadOnly);
5688        }
5689        if self.fulltext.is_enabled(label, field) {
5690            return Err(GraphError::RuleInvalid {
5691                detail: format!("full-text index for ({label:?}, {field:?}) already enabled"),
5692            });
5693        }
5694        self.log_then_apply(WalRecord::EnableFulltext {
5695            label: label.into(),
5696            field: field.into(),
5697        })
5698    }
5699
5700    /// Disable full-text indexing for `(label, field)` and drop its postings.
5701    ///
5702    /// # Errors
5703    /// - [`GraphError::ReadOnly`]: called on an as-of instance.
5704    /// - [`GraphError::RuleNotFound`]: `(label, field)` is not currently indexed.
5705    pub fn disable_fulltext(&mut self, label: &str, field: &str) -> Result<()> {
5706        if self.read_only {
5707            return Err(GraphError::ReadOnly);
5708        }
5709        if !self.fulltext.is_enabled(label, field) {
5710            return Err(GraphError::RuleNotFound {
5711                name: format!("fulltext({label},{field})"),
5712            });
5713        }
5714        self.log_then_apply(WalRecord::DisableFulltext {
5715            label: label.into(),
5716            field: field.into(),
5717        })
5718    }
5719
5720    /// Whether `(label, field)` is currently indexed for full-text search.
5721    pub fn is_fulltext_enabled(&self, label: &str, field: &str) -> bool {
5722        self.fulltext.is_enabled(label, field)
5723    }
5724
5725    /// Every `(label, field)` pair with a live full-text index, sorted.
5726    ///
5727    /// Note that [`GraphDb::search`] is keyed by field alone — a pair only
5728    /// declares which nodes are *indexed*, so callers that want to search
5729    /// everything indexed should query each distinct field once.
5730    pub fn fulltext_pairs(&self) -> Vec<(String, String)> {
5731        let mut v: Vec<(String, String)> = self.fulltext.enabled_pairs().cloned().collect();
5732        v.sort();
5733        v
5734    }
5735
5736    /// Enable an equality index for all nodes of `label` on scalar property
5737    /// `field`. Subsequent `WHERE n.field = value` lookups become O(matches)
5738    /// instead of an O(N_label) scan. Existing nodes are backfilled; the
5739    /// declaration persists via WAL and the postings rebuild on re-open.
5740    ///
5741    /// # Errors
5742    /// - [`GraphError::ReadOnly`]: called on an as-of instance.
5743    /// - [`GraphError::RuleInvalid`]: `(label, field)` is already indexed.
5744    pub fn enable_index(&mut self, label: &str, field: &str) -> Result<()> {
5745        if self.read_only {
5746            return Err(GraphError::ReadOnly);
5747        }
5748        if self.prop_index.is_enabled(label, field) {
5749            return Err(GraphError::RuleInvalid {
5750                detail: format!("property index for ({label:?}, {field:?}) already enabled"),
5751            });
5752        }
5753        self.log_then_apply(WalRecord::EnableIndex {
5754            label: label.into(),
5755            field: field.into(),
5756        })
5757    }
5758
5759    /// Disable the equality index for `(label, field)` and drop its postings.
5760    ///
5761    /// # Errors
5762    /// - [`GraphError::ReadOnly`]: called on an as-of instance.
5763    /// - [`GraphError::RuleNotFound`]: `(label, field)` is not currently indexed.
5764    pub fn disable_index(&mut self, label: &str, field: &str) -> Result<()> {
5765        if self.read_only {
5766            return Err(GraphError::ReadOnly);
5767        }
5768        if !self.prop_index.is_enabled(label, field) {
5769            return Err(GraphError::RuleNotFound {
5770                name: format!("index({label},{field})"),
5771            });
5772        }
5773        self.log_then_apply(WalRecord::DisableIndex {
5774            label: label.into(),
5775            field: field.into(),
5776        })
5777    }
5778
5779    /// Whether `(label, field)` currently has an equality index.
5780    pub fn is_index_enabled(&self, label: &str, field: &str) -> bool {
5781        self.prop_index.is_enabled(label, field)
5782    }
5783
5784    /// Search a full-text-indexed field.
5785    ///
5786    /// Returns `(node_key, match_count)` pairs sorted by match_count descending,
5787    /// ties broken by key (lexicographic).  Tombstoned nodes are excluded.
5788    ///
5789    /// **Query syntax:**
5790    /// - Space-separated terms are AND'd: `"foo bar"` requires both.
5791    /// - `OR` between terms forms disjunction: `"foo OR bar"` matches either.
5792    /// - Trailing `*` on a term is a prefix match: `"rust*"` matches `rustlang`, `rusty`.
5793    /// - `AND` keyword is accepted explicitly and is the default.
5794    /// - Tokenization is unicode-alphanumeric (same as index time); case-insensitive.
5795    ///
5796    /// **Unindexed field:** returns `Ok(vec![])` if `field` is not indexed.
5797    /// Pin: this is the documented, tested, stable behavior for v1.
5798    ///
5799    /// **Memory / performance:** O(postings) lookup; no scan.  The index is
5800    /// in-memory and proportional to total indexed text across all enabled fields.
5801    ///
5802    /// **v2 grammar:** supports `"phrase"`, `-negation`, `prefix*`, `OR`, `AND`.
5803    /// Results are BM25-scored (k1=1.2, b=0.75) and sorted by score descending,
5804    /// key ascending for deterministic tiebreaking.
5805    pub fn search(&self, field: &str, query: &str) -> Vec<(String, f64)> {
5806        // Resolve node_ids to keys (excluding tombstones) then re-sort by
5807        // (score DESC, key ASC) to give a deterministic, key-lexicographic
5808        // tiebreak.  FulltextIndex::search sorts by (score DESC, node_id ASC)
5809        // which diverges from key order when nodes were not inserted in key-lex order.
5810        let mut results: Vec<(String, f64)> = self
5811            .fulltext
5812            .search(field, query, 0)
5813            .into_iter()
5814            .filter_map(|(id, score)| self.ids.key_of(id).map(|key| (key.to_string(), score)))
5815            .collect();
5816        results.sort_by(|a, b| {
5817            b.1.partial_cmp(&a.1)
5818                .unwrap_or(std::cmp::Ordering::Equal)
5819                .then(a.0.cmp(&b.0))
5820        });
5821        results
5822    }
5823
5824    /// [`search`](Self::search), stopping at the `k` best hits.
5825    ///
5826    /// Same ranking and the same deterministic tiebreak, but the index drops
5827    /// everything past `k` before any key is resolved, so a caller that wants
5828    /// the top few out of a field that matched thousands does not pay to
5829    /// materialise and re-sort the tail. `k == 0` means no limit, exactly as
5830    /// [`search`](Self::search) behaves.
5831    ///
5832    /// The BM25 scoring itself is not bounded by `k` — every candidate is
5833    /// scored either way — so this trims the resolve and the sort, not the
5834    /// search.
5835    pub fn search_top(&self, field: &str, query: &str, k: usize) -> Vec<(String, f64)> {
5836        // A tombstoned id resolves to nothing, so asking the index for exactly
5837        // `k` could return fewer. Over-fetching a little and truncating after
5838        // the filter keeps the count right without unbounding the call.
5839        let want = if k == 0 { 0 } else { k.saturating_mul(2) };
5840        let mut results: Vec<(String, f64)> = self
5841            .fulltext
5842            .search(field, query, want)
5843            .into_iter()
5844            .filter_map(|(id, score)| self.ids.key_of(id).map(|key| (key.to_string(), score)))
5845            .collect();
5846        results.sort_by(|a, b| {
5847            b.1.partial_cmp(&a.1)
5848                .unwrap_or(std::cmp::Ordering::Equal)
5849                .then(a.0.cmp(&b.0))
5850        });
5851        if k > 0 {
5852            results.truncate(k);
5853        }
5854        results
5855    }
5856
5857    /// Hybrid search: Reciprocal Rank Fusion (RRF) over fulltext + vector results.
5858    ///
5859    /// Takes up to `4*k` fulltext hits for `(text_field, query_text)` and up to
5860    /// `4*k` vector hits for `(vector_field, query_vec, min=0.0)`, then fuses
5861    /// them with RRF using a fixed constant of 60.
5862    ///
5863    /// ```text
5864    /// score(d) = Σ  1 / (60 + rank_i(d))    (rank 1-based per list)
5865    /// ```
5866    ///
5867    /// Returns the top `k` nodes by fused score, ties broken by node key
5868    /// ascending (deterministic).
5869    ///
5870    /// # Vector leg fallback
5871    ///
5872    /// When `query_vec` is empty the vector leg is skipped entirely and
5873    /// results are ranked by the text list alone through the same RRF path
5874    /// (each text result scores `1/(60 + rank)` from that single list).
5875    ///
5876    /// When `label` is `None`, the vector leg **always** returns empty results.
5877    /// Internally `label` is mapped to `""`, which does not match any rule-created
5878    /// HNSW index (all such indexes are keyed to a specific non-empty label), and
5879    /// the brute-force fallback finds no nodes with an empty label.  The fused
5880    /// ranking is therefore text-only in this case.
5881    pub fn search_hybrid(
5882        &self,
5883        text_field: &str,
5884        query_text: &str,
5885        vector_field: &str,
5886        query_vec: &[f64],
5887        label: Option<&str>,
5888        k: usize,
5889    ) -> Vec<(String, f64)> {
5890        use std::collections::HashMap;
5891
5892        const RRF_K: f64 = 60.0;
5893        let pool = 4 * k;
5894
5895        // Accumulate per-node RRF scores.
5896        let mut scores: HashMap<String, f64> = HashMap::new();
5897
5898        // Text leg.
5899        let text_hits = self.search(text_field, query_text);
5900        for (rank0, (key, _count)) in text_hits.into_iter().take(pool).enumerate() {
5901            let rank = (rank0 + 1) as f64;
5902            *scores.entry(key).or_insert(0.0) += 1.0 / (RRF_K + rank);
5903        }
5904
5905        // Vector leg (skipped when query_vec is empty).
5906        if !query_vec.is_empty() {
5907            let vec_hits = self.find_similar_vector(vector_field, label, query_vec, pool, 0.0);
5908            for (rank0, (key, _sim)) in vec_hits.into_iter().enumerate() {
5909                let rank = (rank0 + 1) as f64;
5910                *scores.entry(key).or_insert(0.0) += 1.0 / (RRF_K + rank);
5911            }
5912        }
5913
5914        // Sort: score DESC, then key ASC for deterministic tie-breaking.
5915        let mut ranked: Vec<(String, f64)> = scores.into_iter().collect();
5916        ranked.sort_by(|a, b| {
5917            b.1.partial_cmp(&a.1)
5918                .unwrap_or(std::cmp::Ordering::Equal)
5919                .then(a.0.cmp(&b.0))
5920        });
5921        ranked.truncate(k);
5922        ranked
5923    }
5924
5925    /// For DST/testing: scratch BM25 search over live nodes without the index.
5926    /// Walks every live node, re-stems field tokens, computes corpus stats, and
5927    /// returns BM25-ranked results.
5928    ///
5929    /// The oracle: the ordered key list of `search(field, q)` must equal that of
5930    /// `scratch_search(field, q)` at every quiescent state.
5931    #[doc(hidden)]
5932    pub fn scratch_search(&self, field: &str, query: &str) -> Vec<(String, f64)> {
5933        use core_storage::fulltext::{parse_query, value_tokens_stemmed_with_positions};
5934        use std::collections::BTreeMap;
5935
5936        let groups = parse_query(query);
5937        if groups.is_empty() {
5938            return vec![];
5939        }
5940
5941        // --- Pass 1: collect all live indexed nodes with stemmed token data ---
5942        struct NodeData {
5943            key: String,
5944            /// stemmed_token → positions (sorted)
5945            tokens: BTreeMap<String, Vec<u32>>,
5946            dl: u32,
5947        }
5948
5949        let mut nodes: Vec<NodeData> = Vec::new();
5950        for id in 0..self.ids.len() as u32 {
5951            let Some(key) = self.ids.key_of(id) else {
5952                continue;
5953            };
5954            let Some(&sym) = self.labels.get(id as usize) else {
5955                continue;
5956            };
5957            if sym == u32::MAX {
5958                continue;
5959            }
5960            let label = match self.syms.resolve(sym) {
5961                Some(l) => l,
5962                None => continue,
5963            };
5964            if !self.fulltext.is_enabled(label, field) {
5965                continue;
5966            }
5967            let Some(value) = self.props_view().get(id, field).map(|vr| vr.into_value()) else {
5968                continue;
5969            };
5970            // Use value_tokens_stemmed_with_positions so list elements are
5971            // separated by POSITION_GAP — identical to the index path, which
5972            // prevents phrase queries from matching across element boundaries.
5973            let stemmed_with_pos = match &value {
5974                Value::Str(_) | Value::List(_) => value_tokens_stemmed_with_positions(&value),
5975                _ => continue,
5976            };
5977            let dl = stemmed_with_pos.len() as u32;
5978            let mut tok_map: BTreeMap<String, Vec<u32>> = BTreeMap::new();
5979            for (tok, pos) in stemmed_with_pos {
5980                tok_map.entry(tok).or_default().push(pos);
5981            }
5982            nodes.push(NodeData {
5983                key: key.to_string(),
5984                tokens: tok_map,
5985                dl,
5986            });
5987        }
5988
5989        if nodes.is_empty() {
5990            return vec![];
5991        }
5992
5993        // --- BM25 corpus stats ---
5994        let n = nodes.len() as f64;
5995        let avg_dl: f64 = nodes.iter().map(|nd| nd.dl as f64).sum::<f64>() / n;
5996        // df per stemmed token across all live indexed nodes.
5997        let mut df_map: BTreeMap<&str, f64> = BTreeMap::new();
5998        for nd in &nodes {
5999            for tok in nd.tokens.keys() {
6000                *df_map.entry(tok.as_str()).or_insert(0.0) += 1.0;
6001            }
6002        }
6003
6004        const K1: f64 = 1.2;
6005        const B: f64 = 0.75;
6006
6007        // --- Pass 2: score each node against each OR-group ---
6008        let mut results: Vec<(String, f64)> = Vec::new();
6009        for nd in &nodes {
6010            let dl = nd.dl as f64;
6011            let mut total_score = 0.0f64;
6012
6013            'group: for group in &groups {
6014                let mut group_score = 0.0f64;
6015
6016                for term in group {
6017                    if term.negated {
6018                        // Negated: if doc has this stemmed token → group fails.
6019                        let present = if term.prefix {
6020                            nd.tokens.keys().any(|t| t.starts_with(term.token.as_str()))
6021                        } else {
6022                            nd.tokens.contains_key(term.token.as_str())
6023                        };
6024                        if present {
6025                            continue 'group;
6026                        }
6027                        continue;
6028                    }
6029                    if term.prefix {
6030                        // Prefix: sum BM25 for all matching stemmed tokens.
6031                        let mut prefix_matched = false;
6032                        for (tok, positions) in &nd.tokens {
6033                            if tok.starts_with(term.token.as_str()) {
6034                                let tf = positions.len() as f64;
6035                                let df = df_map.get(tok.as_str()).copied().unwrap_or(1.0);
6036                                let idf = ((n - df + 0.5) / (df + 0.5) + 1.0).ln();
6037                                let tf_norm =
6038                                    tf * (K1 + 1.0) / (tf + K1 * (1.0 - B + B * dl / avg_dl));
6039                                group_score += idf * tf_norm;
6040                                prefix_matched = true;
6041                            }
6042                        }
6043                        if !prefix_matched {
6044                            continue 'group;
6045                        }
6046                    } else {
6047                        // term.token is already stemmed by parse_query; use directly.
6048                        match nd.tokens.get(term.token.as_str()) {
6049                            None => continue 'group,
6050                            Some(positions) => {
6051                                let tf = positions.len() as f64;
6052                                let df = df_map.get(term.token.as_str()).copied().unwrap_or(1.0);
6053                                let idf = ((n - df + 0.5) / (df + 0.5) + 1.0).ln();
6054                                let tf_norm =
6055                                    tf * (K1 + 1.0) / (tf + K1 * (1.0 - B + B * dl / avg_dl));
6056                                group_score += idf * tf_norm;
6057                            }
6058                        }
6059                    }
6060                }
6061
6062                if group_score > 0.0 {
6063                    total_score += group_score;
6064                }
6065            }
6066
6067            if total_score > 0.0 {
6068                results.push((nd.key.clone(), total_score));
6069            }
6070        }
6071
6072        results.sort_by(|a, b| {
6073            b.1.partial_cmp(&a.1)
6074                .unwrap_or(std::cmp::Ordering::Equal)
6075                .then(a.0.cmp(&b.0))
6076        });
6077        results
6078    }
6079
6080    /// Return the current view-maintained value of `view_prop` for node `key`.
6081    /// Equivalent to `get_prop` but documents that it reads a view-managed column.
6082    pub fn get_view_prop(&self, key: &str, view_prop: &str) -> Option<Value> {
6083        let id = self.ids.get(key)?;
6084        self.props_view()
6085            .get(id, view_prop)
6086            .map(|vr| vr.into_value())
6087    }
6088
6089    /// For testing / DST oracle: scratch recompute of a view value for one node.
6090    ///
6091    /// Returns `None` if the node does not exist, the view does not exist, or
6092    /// the view has no result for the node (e.g. Avg with no qualifying neighbors).
6093    #[doc(hidden)]
6094    pub fn scratch_view_value(&self, key: &str, view_name: &str) -> Option<Value> {
6095        let node = self.ids.get(key)?;
6096        let def = self.view_store.views().find(|v| v.name == view_name)?;
6097        // Use TopologyView so that NeighborAgg sees base + overlay edges
6098        // without materialising a temporary Topology (I1).
6099        let topo_view = self.topo_view();
6100        core_rules::views::compute_view_value(
6101            def,
6102            node,
6103            self.props_view(),
6104            &topo_view,
6105            &self.ids,
6106            &self.syms,
6107            &self.labels,
6108        )
6109    }
6110
6111    // -----------------------------------------------------------------------
6112    // Graph algorithm API
6113    // -----------------------------------------------------------------------
6114
6115    /// Run PageRank over the unified topology (manual + derived edges).
6116    ///
6117    /// Returns a [`PageRankReport`] with scores sorted descending (ties: key
6118    /// ascending).  Set `config.edge_type` to restrict to one edge type.
6119    /// `config.converged` is `true` only when the power iteration converged
6120    /// within `config.max_iters` and within any time budget.
6121    pub fn pagerank(&self, config: &crate::algo::PageRankConfig) -> crate::algo::PageRankReport {
6122        let topo = build_topo_view(&self.topo, &self.base);
6123        let edge_props = self.edge_props_view();
6124        crate::algo::pagerank(
6125            &topo,
6126            &self.ids,
6127            &self.syms,
6128            &self.labels,
6129            &edge_props,
6130            config,
6131        )
6132    }
6133
6134    /// Weakly-connected components over the unified topology (treated as
6135    /// undirected regardless of how edges were inserted).
6136    ///
6137    /// Component IDs are the key of the smallest member in the component
6138    /// (deterministic).  Result sorted by (component_id, key).
6139    pub fn connected_components(&self, config: &crate::algo::WccConfig) -> crate::algo::WccReport {
6140        let topo = build_topo_view(&self.topo, &self.base);
6141        let edge_props = self.edge_props_view();
6142        crate::algo::wcc(
6143            &topo,
6144            &self.ids,
6145            &self.syms,
6146            &self.labels,
6147            &edge_props,
6148            config,
6149        )
6150    }
6151
6152    /// Degree centrality for every live node.
6153    ///
6154    /// `direction`: `AlgoDir::Out` = out-degree, `AlgoDir::In` = in-degree,
6155    /// `AlgoDir::Both` = out + in (total directed degree).
6156    ///
6157    /// For one-shot ranking use this; for a live property updated on every
6158    /// write, create a Degree materialized view instead (see `docs/site/algorithms.md`).
6159    pub fn degree_centrality(
6160        &self,
6161        config: &crate::algo::DegreeConfig,
6162    ) -> crate::algo::DegreeReport {
6163        let topo = build_topo_view(&self.topo, &self.base);
6164        let edge_props = self.edge_props_view();
6165        crate::algo::degree_centrality(
6166            &topo,
6167            &self.ids,
6168            &self.syms,
6169            &self.labels,
6170            &edge_props,
6171            config,
6172        )
6173    }
6174
6175    /// Louvain community detection over the unified topology (undirected).
6176    ///
6177    /// See [`crate::algo::LouvainConfig`] for edge-type/weight/label
6178    /// restriction and [`crate::algo::CommunityReport`] for the shape of the
6179    /// result (communities sorted size-desc, then smallest member key asc).
6180    pub fn communities(&self, config: &crate::algo::LouvainConfig) -> crate::algo::CommunityReport {
6181        let topo = build_topo_view(&self.topo, &self.base);
6182        let edge_props = self.edge_props_view();
6183        crate::algo::louvain(
6184            &topo,
6185            &self.ids,
6186            &self.syms,
6187            &self.labels,
6188            &edge_props,
6189            config,
6190        )
6191    }
6192
6193    /// Write a vector of `(node_key, score)` pairs as `prop_name` on each node,
6194    /// atomically via a single write-batch (one WAL frame, one fsync).
6195    ///
6196    /// # Errors
6197    /// - [`GraphError::ReadOnly`]: called on an as-of instance.
6198    /// - [`GraphError::RuleInvalid`]: `prop_name` is managed by an existing view
6199    ///   (collision check mirrors `create_view`).
6200    /// - [`GraphError::KeyNotFound`]: a key in `scores` does not exist as a live node.
6201    pub fn write_scores(&mut self, prop_name: &str, scores: &[(String, f64)]) -> Result<()> {
6202        if self.read_only {
6203            return Err(GraphError::ReadOnly);
6204        }
6205        // Collision check: refuse if prop_name is view-managed.
6206        if let Some(view_name) = self.view_store.view_for_prop(prop_name) {
6207            return Err(GraphError::RuleInvalid {
6208                detail: format!(
6209                    "prop {:?} is managed by view {:?} and cannot be written as scores",
6210                    prop_name, view_name
6211                ),
6212            });
6213        }
6214        // Refuse if prop_name is a view name itself (confusing namespace collision).
6215        if self.view_store.has_view(prop_name) {
6216            return Err(GraphError::RuleInvalid {
6217                detail: format!(
6218                    "prop_name {:?} collides with an existing view name",
6219                    prop_name
6220                ),
6221            });
6222        }
6223        // Write all scores in a single crash-atomic batch.
6224        self.write_batch(|b| {
6225            for (key, score) in scores {
6226                b.set_prop(key, prop_name, Value::Float(*score));
6227            }
6228        })?;
6229        Ok(())
6230    }
6231
6232    /// Return the value of `field` for the node with key `key`, or `None` if
6233    /// the node or field is absent.  Reads through the overlay-over-base
6234    /// `ColumnsView`, materialising base values on demand (zero heap cost for
6235    /// overlay hits; one clone per base hit).
6236    pub fn get_prop(&self, key: &str, field: &str) -> Option<Value> {
6237        let id = self.ids.get(key)?;
6238        self.props_view().get(id, field).map(|vr| vr.into_value())
6239    }
6240
6241    pub fn has_node(&self, key: &str) -> bool {
6242        self.ids.get(key).is_some()
6243    }
6244
6245    /// Borrow the raw id map. Used by `NodeMask::from_keys` to resolve keys.
6246    pub(crate) fn ids(&self) -> &IdMap {
6247        &self.ids
6248    }
6249
6250    // -----------------------------------------------------------------------
6251    // RBAC role resolution
6252    // -----------------------------------------------------------------------
6253
6254    /// Parse `roles.json` bytes from `fs`.
6255    ///
6256    /// Return values:
6257    ///   `Ok(Some(roles))` — file absent (returns `vec![]`) **or** file present
6258    ///                       and valid; in both cases `mask_for_role` uses the
6259    ///                       list normally (an absent file means no roles defined).
6260    ///   `Ok(None)`        — file present but corrupt or unrecognised version
6261    ///                       → poisoned state; `mask_for_role` returns `Err` for
6262    ///                       any role name until the file is fixed and the DB
6263    ///                       re-opened (or `apply_schema` is called to repair it).
6264    ///
6265    /// Note: `None` signals corruption, not absence — the opposite of what an
6266    /// optional "file missing" convention would suggest.  The open path stores
6267    /// this result on `db.roles` directly.
6268    fn load_roles_from_fs(fs: &F) -> Result<Option<Vec<RoleDef>>> {
6269        let bytes = fs.read(FileId::Roles).map_err(GraphError::Io)?;
6270        if bytes.is_empty() {
6271            // Empty bytes means either the file is absent or zero-byte — both
6272            // are treated identically as "no roles defined".  A zero-byte
6273            // roles.json does NOT widen access: an absent file and a zero-byte
6274            // file both resolve to an empty role list (sees nothing by default).
6275            return Ok(Some(vec![]));
6276        }
6277        match serde_json::from_slice::<RolesFile>(&bytes) {
6278            Ok(f) if f.version == 1 || f.version == 2 => Ok(Some(f.roles)),
6279            // Corrupt or unrecognised version (>2): poison the roles state.
6280            _ => Ok(None),
6281        }
6282    }
6283
6284    /// Resolve a role to a node-visibility mask against the current graph state.
6285    ///
6286    /// Returns `Err` when:
6287    /// - `roles.json` was present but corrupt at open (poisoned state), or
6288    /// - `role` does not match any defined role name.
6289    ///
6290    /// The mask union is: explicit `keys` (unknown keys silently ignored) plus
6291    /// all live nodes carrying any label in `labels`.  Label resolution is live
6292    /// — new nodes of an allowed label are visible without re-applying the
6293    /// schema.  An empty union = empty mask = sees nothing.
6294    pub fn mask_for_role(&self, role: &str) -> Result<crate::mask::NodeMask> {
6295        let roles = self.roles.as_ref().ok_or_else(|| GraphError::Corrupt {
6296            detail:
6297                "roles.json was corrupt at open; fix the file and re-open to restore role access"
6298                    .into(),
6299        })?;
6300        let def = roles
6301            .iter()
6302            .find(|r| r.name == role)
6303            .ok_or_else(|| GraphError::KeyNotFound {
6304                key: format!("role:{role}"),
6305            })?;
6306
6307        let mut visible = std::collections::HashSet::new();
6308
6309        // Key leg: resolve explicit keys to dense ids (unknown keys ignored).
6310        for key in &def.keys {
6311            if let Some(id) = self.ids.get(key) {
6312                visible.insert(id);
6313            }
6314        }
6315
6316        // Label leg: live scan — iterate labels vec for matching symbol.
6317        for label_name in &def.labels {
6318            if let Some(sym) = self.syms.get(label_name) {
6319                for (i, &s) in self.labels.iter().enumerate() {
6320                    if s == sym {
6321                        visible.insert(i as u32);
6322                    }
6323                }
6324            }
6325        }
6326
6327        Ok(crate::mask::NodeMask::from_ids(visible))
6328    }
6329
6330    /// Return the current list of role definitions.
6331    ///
6332    /// Returns an empty list when no roles are defined or when `roles.json`
6333    /// was corrupt at open (check [`mask_for_role`](Self::mask_for_role) for
6334    /// the fail-loud error in that case).
6335    pub fn roles(&self) -> Vec<RoleDef> {
6336        self.roles.as_deref().unwrap_or(&[]).to_vec()
6337    }
6338
6339    // ── Role-scoped write authz ───────────────────────────────────────────────
6340
6341    /// Execute `ops` with optional role-scoped write authorization.
6342    ///
6343    /// - `None` → full authority, identical to [`write_batch`](Self::write_batch)
6344    ///   (zero-cost bypass of all authz checks).
6345    /// - `Some(authz)` → the decision table is evaluated per-op BEFORE any WAL
6346    ///   record is built.  A denial returns an error with no WAL frame written
6347    ///   (all-or-nothing at the authz boundary, then at the MutPreview boundary).
6348    ///
6349    /// See the plan's "authz decision table" section for the full semantics.
6350    pub fn write_batch_authz(
6351        &mut self,
6352        authz: Option<&WriteAuthz>,
6353        ops: Vec<BatchOp>,
6354    ) -> Result<(usize, usize)> {
6355        // Thread authz as a direct parameter — never touches pending_write_authz.
6356        self.commit_logged_batch(ops, None, authz.cloned())
6357    }
6358
6359    /// Execute a Cypher write statement with role-scoped write authorization.
6360    ///
6361    /// Resolves scope + mask from `self.roles` inside the call (same write-guard
6362    /// lifetime as execution, satisfying §5 lock discipline).  The resolved
6363    /// `WriteAuthz` is stored as `pending_write_authz` for the duration of the
6364    /// call so that all inner `batch.commit()` calls are authz-checked.
6365    ///
6366    /// MERGE is handled specially: the MERGE scope precondition (§3.3) is
6367    /// checked in `exec_merge` BEFORE `has_node` to close the §6.2
6368    /// timing-oracle item (hidden ≡ absent for unscoped roles).
6369    ///
6370    /// Roles with `write: None` (v1 behavior) → `RoleWriteDenied` with
6371    /// "this endpoint is not permitted".
6372    pub fn query_write_authz(
6373        &mut self,
6374        role: &str,
6375        cypher: &str,
6376        params: &BTreeMap<String, Value>,
6377    ) -> Result<ResultSet> {
6378        // Resolve scope (fails fast if role has no write scope).
6379        // write:None → byte-identical v1 blanket-403 body (plan §v1-sidecar mandate).
6380        let scope =
6381            {
6382                let roles = self.roles.as_deref().ok_or_else(|| GraphError::Corrupt {
6383                    detail: "roles.json was corrupt at open; re-open to restore role access".into(),
6384                })?;
6385                let def = roles.iter().find(|r| r.name == role).ok_or_else(|| {
6386                    GraphError::KeyNotFound {
6387                        key: format!("role:{role}"),
6388                    }
6389                })?;
6390                def.write
6391                    .clone()
6392                    .ok_or_else(|| GraphError::RoleWriteDenied {
6393                        reason: "role-bound token: writes are not permitted".into(),
6394                    })?
6395            };
6396        // Resolve mask inside the call (same guard, §5 coherence).
6397        let mask = self.mask_for_role(role)?;
6398        self.pending_write_authz = Some(WriteAuthz {
6399            role: role.into(),
6400            scope,
6401            mask,
6402        });
6403        // RAII guard: always clears pending_write_authz on scope exit, including
6404        // on panic or early-return, mirroring the RestoreEmitDeltas precedent.
6405        struct ClearPendingAuthzOnDrop(*mut Option<WriteAuthz>);
6406        impl Drop for ClearPendingAuthzOnDrop {
6407            fn drop(&mut self) {
6408                // SAFETY: pointer into the owning GraphDb; guard is dropped
6409                // within this function's frame before it returns.
6410                unsafe { *self.0 = None };
6411            }
6412        }
6413        // SAFETY: raw pointer into self; guard dropped before this fn returns.
6414        let _authz_guard = ClearPendingAuthzOnDrop(&mut self.pending_write_authz as *mut _);
6415        let tokens = lex(cypher).map_err(|e| GraphError::QueryError {
6416            detail: format!("lex: {e}"),
6417        })?;
6418        let stmt = parse_write(&tokens).map_err(|e| GraphError::QueryError {
6419            detail: format!("parse: {e}"),
6420        })?;
6421        self.exec_write_stmt(stmt, params)
6422    }
6423
6424    /// Execute `ops` with optional role-scoped write authorization, suppressing
6425    /// fsync (for use inside the group-commit drain thread, which performs one
6426    /// group fsync after releasing the write lock).
6427    ///
6428    /// Identical to [`write_batch_authz`] except the fsync policy is temporarily
6429    /// forced to `Relaxed` for the duration of the call, matching the drain-thread
6430    /// contract established by [`commit_batch_nosync`].
6431    pub(crate) fn write_batch_authz_nosync(
6432        &mut self,
6433        authz: Option<&WriteAuthz>,
6434        ops: Vec<BatchOp>,
6435    ) -> Result<(usize, usize)> {
6436        let saved = self.fsync;
6437        struct RestoreFsync(*mut FsyncPolicy, FsyncPolicy);
6438        impl Drop for RestoreFsync {
6439            fn drop(&mut self) {
6440                // SAFETY: pointer into the owning GraphDb; guard is dropped
6441                // within the enclosing function's frame before it returns.
6442                unsafe { *self.0 = self.1 };
6443            }
6444        }
6445        // SAFETY: raw pointer into self; guard dropped before this fn returns.
6446        let _g = RestoreFsync(&mut self.fsync as *mut FsyncPolicy, saved);
6447        self.fsync = FsyncPolicy::Relaxed;
6448        self.commit_logged_batch(ops, None, authz.cloned())
6449    }
6450
6451    /// Execute a `/ingest` request with role-scoped write authorization.
6452    ///
6453    /// Resolves the role's `WriteScope` and `NodeMask` inside this call (same
6454    /// write-guard lifetime as the mutation, satisfying §5 lock discipline).
6455    /// Sets `pending_write_authz` for the duration of the call so that the
6456    /// `commit_ingest` → `commit_logged_batch` path picks up the authz context
6457    /// and evaluates the decision table per-op before any WAL write.
6458    ///
6459    /// §7.3: roles with empty `create_labels` will see every `InsertNode` op
6460    /// denied by the decision table with the appropriate §4.3 scope reason;
6461    /// no special HTTP-layer check is needed.
6462    ///
6463    /// Roles with `write: None` return `RoleWriteDenied` with
6464    /// "writes are not permitted" (byte-identical to v1 blanket 403).
6465    pub fn ingest_with_edges_authz(
6466        &mut self,
6467        role: &str,
6468        label: &str,
6469        rows: Vec<std::collections::BTreeMap<String, Value>>,
6470        opts: &crate::ingest::IngestOptions,
6471        edges: &[(String, String, String)],
6472    ) -> Result<crate::ingest::IngestReport> {
6473        // Resolve scope (fails fast if role has no write scope).
6474        // write:None → byte-identical v1 blanket-403 body (plan §v1-sidecar mandate).
6475        let scope =
6476            {
6477                let roles = self.roles.as_deref().ok_or_else(|| GraphError::Corrupt {
6478                    detail: "roles.json was corrupt at open; re-open to restore role access".into(),
6479                })?;
6480                let def = roles.iter().find(|r| r.name == role).ok_or_else(|| {
6481                    GraphError::KeyNotFound {
6482                        key: format!("role:{role}"),
6483                    }
6484                })?;
6485                def.write
6486                    .clone()
6487                    .ok_or_else(|| GraphError::RoleWriteDenied {
6488                        reason: "role-bound token: writes are not permitted".into(),
6489                    })?
6490            };
6491        let mask = self.mask_for_role(role)?;
6492        self.pending_write_authz = Some(WriteAuthz {
6493            role: role.into(),
6494            scope,
6495            mask,
6496        });
6497        // RAII guard: always clears pending_write_authz on scope exit, including
6498        // on panic or early-return, mirroring the RestoreEmitDeltas precedent.
6499        struct ClearPendingAuthzOnDrop(*mut Option<WriteAuthz>);
6500        impl Drop for ClearPendingAuthzOnDrop {
6501            fn drop(&mut self) {
6502                // SAFETY: pointer into the owning GraphDb; guard is dropped
6503                // within this function's frame before it returns.
6504                unsafe { *self.0 = None };
6505            }
6506        }
6507        // SAFETY: raw pointer into self; guard dropped before this fn returns.
6508        let _authz_guard = ClearPendingAuthzOnDrop(&mut self.pending_write_authz as *mut _);
6509        self.ingest_with_edges(label, rows, opts, edges)
6510    }
6511
6512    /// Evaluate the write-authz decision table for one `BatchOp`.
6513    ///
6514    /// Called by `commit_logged_batch` for each op when `pending_write_authz`
6515    /// is `Some`, BEFORE MutPreview.  A denial returns an error immediately;
6516    /// the remaining ops are not evaluated and no WAL frame is written.
6517    ///
6518    /// `batch_created` carries the key→label pairs of nodes that earlier ops in
6519    /// THIS batch will create.  Used by `InsertEdgeUpsert` to count same-batch
6520    /// placeholder nodes as visible (spec: "a placeholder endpoint the SAME
6521    /// batch creates counts as visible if its label passed the create-class gate").
6522    fn check_single_op_authz(
6523        &self,
6524        authz: &WriteAuthz,
6525        op: &BatchOp,
6526        batch_created: &BTreeMap<String, String>,
6527    ) -> Result<()> {
6528        // Helper: 3-way node status under the authz mask.
6529        //
6530        // Batch-created nodes (from earlier InsertNode in THIS batch) are treated
6531        // as Visible with their recorded label — their create gate already passed
6532        // and they are not yet in self.ids (not committed).  This fixes the
6533        // MERGE+ON CREATE SET case where InsertNode + SetProp arrive together:
6534        // the SetProp must not see the node as Absent.
6535        let node_status = |key: &str| -> NodeAuthzStatus {
6536            if let Some(label) = batch_created.get(key) {
6537                return NodeAuthzStatus::Visible(label.clone());
6538            }
6539            match self.ids.get(key) {
6540                None => NodeAuthzStatus::Absent,
6541                Some(id) if !authz.mask.contains_id(id) => NodeAuthzStatus::Hidden,
6542                Some(id) => {
6543                    let label = self
6544                        .labels
6545                        .get(id as usize)
6546                        .and_then(|&sym| {
6547                            if sym == u32::MAX {
6548                                None
6549                            } else {
6550                                self.syms.resolve(sym).map(str::to_string)
6551                            }
6552                        })
6553                        .unwrap_or_default();
6554                    NodeAuthzStatus::Visible(label)
6555                }
6556            }
6557        };
6558
6559        // Helper: is an InsertEdgeUpsert endpoint visible?
6560        // A same-batch placeholder counts as visible if its label passed
6561        // the create-class gate (spec "upsert placeholder-counts-as-visible").
6562        let upsert_ep_visible = |ep_key: &str, placeholder_label: &str| -> bool {
6563            // In store and visible?
6564            if let Some(id) = self.ids.get(ep_key) {
6565                return authz.mask.contains_id(id);
6566            }
6567            // Created by an earlier op in this batch?
6568            if let Some(created_label) = batch_created.get(ep_key) {
6569                return authz.scope.create_labels.contains(created_label);
6570            }
6571            // Will be created by THIS InsertEdgeUpsert: placeholder_label
6572            // must pass the create-class gate.
6573            authz
6574                .scope
6575                .create_labels
6576                .contains(&placeholder_label.to_string())
6577        };
6578
6579        match op {
6580            // RenameNode / CreateRule / DeleteRule: defense-in-depth gate.
6581            // These ops are never routed to role-scoped paths by the HTTP layer,
6582            // but we 403 them here to close any future bypass route.
6583            BatchOp::RenameNode { .. } | BatchOp::CreateRule(_) | BatchOp::DeleteRule { .. } => {
6584                return Err(GraphError::RoleWriteDenied {
6585                    reason: "role-bound token: this endpoint is not permitted".into(),
6586                });
6587            }
6588
6589            // ── CREATE-class: InsertNode ─────────────────────────────────────
6590            //
6591            // Decision table row 1 (scope-before-lookup): check label in
6592            // create_labels BEFORE any key lookup.  This is the structural
6593            // closure of the §6.2 timing-oracle item — the denial fires even
6594            // when the store is EMPTY (see test_create_scope_denied_empty_store).
6595            BatchOp::InsertNode { label, key, .. } => {
6596                if !authz.scope.create_labels.contains(label) {
6597                    return Err(GraphError::RoleWriteDenied {
6598                        reason: format!(
6599                            "role-bound token: label '{}' not in write scope (create_labels)",
6600                            label
6601                        ),
6602                    });
6603                }
6604                // Row 2/3: key lookup.
6605                match self.ids.get(key.as_str()) {
6606                    Some(id) if authz.mask.contains_id(id) => {
6607                        // Visible: DuplicateKey — let MutPreview handle this.
6608                    }
6609                    Some(_) => {
6610                        // Hidden: indistinguishable from absent to the role.
6611                        return Err(GraphError::RoleWriteDenied {
6612                            reason: "role-bound token: target node not visible".into(),
6613                        });
6614                    }
6615                    None => {
6616                        // Absent: proceed (create).
6617                    }
6618                }
6619            }
6620
6621            // ── UPDATE-class: SetProp, RemoveProp ────────────────────────────
6622            BatchOp::SetProp { key, .. } | BatchOp::RemoveProp { key, .. } => {
6623                if batch_created.contains_key(key.as_str()) {
6624                    // Batch-created node: create gate already passed this batch.
6625                    // Updating it in the same batch is always allowed, regardless
6626                    // of update_labels (ruling §3.5: "writer just created it").
6627                } else {
6628                    let label = match node_status(key) {
6629                        NodeAuthzStatus::Visible(lbl) => lbl,
6630                        _ => {
6631                            return Err(GraphError::RoleWriteDenied {
6632                                reason: "role-bound token: target node not visible".into(),
6633                            });
6634                        }
6635                    };
6636                    if !authz.scope.update_labels.contains(&label) {
6637                        return Err(GraphError::RoleWriteDenied {
6638                            reason: format!(
6639                                "role-bound token: label '{}' not in write scope (update_labels)",
6640                                label
6641                            ),
6642                        });
6643                    }
6644                }
6645            }
6646
6647            // ── DELETE-class: DeleteNode ─────────────────────────────────────
6648            BatchOp::DeleteNode { key } => {
6649                let label = match node_status(key) {
6650                    NodeAuthzStatus::Visible(lbl) => lbl,
6651                    _ => {
6652                        return Err(GraphError::RoleWriteDenied {
6653                            reason: "role-bound token: target node not visible".into(),
6654                        });
6655                    }
6656                };
6657                if !authz.scope.delete_labels.contains(&label) {
6658                    return Err(GraphError::RoleWriteDenied {
6659                        reason: format!(
6660                            "role-bound token: label '{}' not in write scope (delete_labels)",
6661                            label
6662                        ),
6663                    });
6664                }
6665            }
6666
6667            // ── DELETE-class: DeleteEdge ─────────────────────────────────────
6668            //
6669            // Derived-edge rejection runs BEFORE the delete_edge_types scope
6670            // check (spec §3.5: "existing derived-edge rejection precedes
6671            // delete_edge_types check").
6672            BatchOp::DeleteEdge {
6673                edge_type,
6674                src_key,
6675                dst_key,
6676            } => {
6677                // Check provenance ownership BEFORE scope (spec §3.5 ordering).
6678                if let (Some(src_id), Some(dst_id), Some(et_sym)) = (
6679                    self.ids.get(src_key.as_str()),
6680                    self.ids.get(dst_key.as_str()),
6681                    self.syms.get(edge_type.as_str()),
6682                ) {
6683                    if self.engine.is_owned(et_sym, src_id, dst_id) {
6684                        return Err(GraphError::RuleOwned {
6685                            detail: format!(
6686                                "edge {edge_type} {src_key}→{dst_key} is rule-owned; \
6687                                 delete or change the owning rule"
6688                            ),
6689                        });
6690                    }
6691                    // Also check would_derive via MutPreview (empty overlay, pre-batch).
6692                    let preview = MutPreview::new(self);
6693                    if preview.would_derive(edge_type, src_key, dst_key) {
6694                        return Err(GraphError::RuleOwned {
6695                            detail: format!(
6696                                "edge {edge_type} {src_key}→{dst_key} is rule-owned; \
6697                                 delete or change the owning rule, or a live rule would \
6698                                 re-derive it"
6699                            ),
6700                        });
6701                    }
6702                }
6703                // Scope check (AFTER derived-edge check, BEFORE endpoint visibility).
6704                if !authz.scope.delete_edge_types.contains(edge_type) {
6705                    return Err(GraphError::RoleWriteDenied {
6706                        reason: format!(
6707                            "role-bound token: edge type '{}' not in write scope (delete_edge_types)",
6708                            edge_type
6709                        ),
6710                    });
6711                }
6712                // Both endpoints must be visible.
6713                for ep_key in [src_key.as_str(), dst_key.as_str()] {
6714                    match self.ids.get(ep_key) {
6715                        None => {
6716                            return Err(GraphError::RoleWriteDenied {
6717                                reason: "role-bound token: edge endpoint not visible".into(),
6718                            });
6719                        }
6720                        Some(id) if !authz.mask.contains_id(id) => {
6721                            return Err(GraphError::RoleWriteDenied {
6722                                reason: "role-bound token: edge endpoint not visible".into(),
6723                            });
6724                        }
6725                        _ => {}
6726                    }
6727                }
6728            }
6729
6730            // ── EDGE-CREATE: InsertEdge ──────────────────────────────────────
6731            //
6732            // Scope check BEFORE endpoint lookup (preserves timing symmetry).
6733            BatchOp::InsertEdge {
6734                edge_type,
6735                src_key,
6736                dst_key,
6737            } => {
6738                if !authz.scope.create_edge_types.contains(edge_type) {
6739                    return Err(GraphError::RoleWriteDenied {
6740                        reason: format!(
6741                            "role-bound token: edge type '{}' not in write scope (create_edge_types)",
6742                            edge_type
6743                        ),
6744                    });
6745                }
6746                // Both endpoints must be visible. A node created by an earlier
6747                // InsertNode in the same batch (tracked in batch_created) counts
6748                // as visible if its label passed the create-class gate.
6749                for ep_key in [src_key.as_str(), dst_key.as_str()] {
6750                    if batch_created.contains_key(ep_key) {
6751                        // Created earlier this batch — already scope-checked.
6752                        continue;
6753                    }
6754                    match self.ids.get(ep_key) {
6755                        None => {
6756                            return Err(GraphError::RoleWriteDenied {
6757                                reason: "role-bound token: edge endpoint not visible".into(),
6758                            });
6759                        }
6760                        Some(id) if !authz.mask.contains_id(id) => {
6761                            return Err(GraphError::RoleWriteDenied {
6762                                reason: "role-bound token: edge endpoint not visible".into(),
6763                            });
6764                        }
6765                        _ => {}
6766                    }
6767                }
6768            }
6769
6770            // ── EDGE-CREATE: InsertEdgeUpsert ────────────────────────────────
6771            //
6772            // Scope check first; then endpoint visibility using same-batch
6773            // placeholder awareness (spec: "a placeholder endpoint the SAME
6774            // batch creates counts as visible if its label passed the
6775            // create-class gate").
6776            BatchOp::InsertEdgeUpsert {
6777                edge_type,
6778                src_key,
6779                dst_key,
6780                placeholder_label,
6781            } => {
6782                if !authz.scope.create_edge_types.contains(edge_type) {
6783                    return Err(GraphError::RoleWriteDenied {
6784                        reason: format!(
6785                            "role-bound token: edge type '{}' not in write scope (create_edge_types)",
6786                            edge_type
6787                        ),
6788                    });
6789                }
6790                // Check placeholder label against create_labels (create-class gate).
6791                // This ensures the auto-created endpoints are scope-allowed.
6792                for ep_key in [src_key.as_str(), dst_key.as_str()] {
6793                    if !upsert_ep_visible(ep_key, placeholder_label) {
6794                        return Err(GraphError::RoleWriteDenied {
6795                            reason: "role-bound token: edge endpoint not visible".into(),
6796                        });
6797                    }
6798                }
6799            }
6800        }
6801        Ok(())
6802    }
6803
6804    /// Write `roles` to `roles.json` atomically and update the in-memory list.
6805    ///
6806    /// Called by `apply_schema` when roles change. Never called on unchanged
6807    /// re-apply — this preserves byte-identical idempotency.
6808    pub(crate) fn commit_roles(&mut self, roles: Vec<RoleDef>) -> Result<()> {
6809        let file = RolesFile::new_versioned(roles.clone());
6810        let bytes = serde_json::to_vec(&file).map_err(|e| GraphError::Corrupt {
6811            detail: format!("roles serialization: {e}"),
6812        })?;
6813        self.fs
6814            .write_atomic(FileId::Roles, &bytes)
6815            .map_err(GraphError::Io)?;
6816        self.roles = Some(roles);
6817        // Refresh the MVCC frozen overlay so that reader() immediately sees the
6818        // updated role definitions without waiting for the next K-commit fold.
6819        self.fold_now();
6820        Ok(())
6821    }
6822
6823    fn view(&self) -> GraphView<'_> {
6824        GraphView {
6825            ids: &self.ids,
6826            syms: &self.syms,
6827            labels: &self.labels,
6828            props: self.props_view(),
6829            topo: self.topo_view(),
6830            edge_props: self.edge_props_view(),
6831            mask: None,
6832            prop_index: Some(&self.prop_index),
6833        }
6834    }
6835
6836    fn view_masked<'a>(&'a self, mask: &'a crate::mask::NodeMask) -> GraphView<'a> {
6837        GraphView {
6838            ids: &self.ids,
6839            syms: &self.syms,
6840            labels: &self.labels,
6841            props: self.props_view(),
6842            topo: self.topo_view(),
6843            edge_props: self.edge_props_view(),
6844            mask: Some(&mask.visible),
6845            prop_index: Some(&self.prop_index),
6846        }
6847    }
6848
6849    /// Execute a read-only Cypher query with a node visibility mask.
6850    ///
6851    /// Only nodes whose key is in `mask` are accessible: label scans, key
6852    /// lookups, and neighbor expansions all respect the mask. Edges where
6853    /// either endpoint is hidden are silently dropped.
6854    ///
6855    /// Returns `Err` with a "masked queries are read-only" message when
6856    /// `cypher` is a write statement (CREATE / MERGE / MATCH…SET / DELETE).
6857    pub fn query_masked(
6858        &self,
6859        cypher: &str,
6860        params: &std::collections::BTreeMap<String, Value>,
6861        mask: &crate::mask::NodeMask,
6862    ) -> Result<ResultSet> {
6863        // Reject write statements up front.
6864        let tokens = lex(cypher).map_err(|e| GraphError::QueryError {
6865            detail: format!("lex: {e}"),
6866        })?;
6867        if is_write_tokens(&tokens) {
6868            return Err(GraphError::MaskedReadOnly);
6869        }
6870        let union = parse_read(&tokens).map_err(|e| GraphError::QueryError {
6871            detail: format!("parse: {e}"),
6872        })?;
6873        // Each UNION part executes against the same masked view, so the mask
6874        // applies uniformly across the chain.
6875        execute_union(&self.view_masked(mask), &union, &Params(params)).map_err(|e| {
6876            GraphError::QueryError {
6877                detail: format!("execute: {e}"),
6878            }
6879        })
6880    }
6881
6882    pub fn node_ref(&self, key: &str) -> Option<NodeRef<'_, F>> {
6883        let id = self.ids.get(key)?;
6884        Some(NodeRef { db: self, id })
6885    }
6886
6887    /// BFS neighborhood expansion restricted to visible nodes in `mask`.
6888    ///
6889    /// Hidden nodes are never used as traversal intermediaries in either
6890    /// [`MaskMode::Omit`] or [`MaskMode::Stub`] — a visible node reachable
6891    /// only through a hidden node will not appear in results.
6892    ///
6893    /// In [`MaskMode::Stub`] mode, hidden nodes that are direct neighbours of
6894    /// a visited visible node are appended to the result as stub rows
6895    /// (`label` column is `null`, same key+depth columns as visible rows).
6896    /// They are NOT added to the BFS frontier.
6897    ///
6898    /// Returns `None` when `key` does not exist (caller should 404).
6899    ///
6900    /// **SECURITY**: role-token callers always pass an Omit-mode mask, so
6901    /// stub rows are never produced on the role path.
6902    pub fn neighborhood_masked(
6903        &self,
6904        key: &str,
6905        depth: u32,
6906        edge_types: Option<&[&str]>,
6907        dir: Dir,
6908        mask: &crate::mask::NodeMask,
6909    ) -> Option<ResultSet> {
6910        let start_id = self.ids.get(key)?;
6911        let view = self.view_masked(mask);
6912        let resolved: Option<Vec<u32>> = edge_types.map(|names| {
6913            names
6914                .iter()
6915                .filter_map(|name| view.syms.get(name))
6916                .collect()
6917        });
6918        let nb = neighborhood(&view, start_id, depth, resolved.as_deref(), dir);
6919        let mut rs = ResultSet::new(vec!["key".into(), "label".into(), "depth".into()]);
6920        // Collect visible BFS results (start_id at depth 0, BFS nodes after).
6921        let mut visited: Vec<(u32, u32)> = Vec::with_capacity(nb.nodes.len() + 1);
6922        visited.push((start_id, 0));
6923        for (nid, d) in &nb.nodes {
6924            let k = view.key_of(*nid);
6925            let label = view
6926                .label_of(*nid)
6927                .expect("real nodes always have a label; u32::MAX sentinel cannot occur");
6928            rs.push_row(vec![
6929                Some(Value::Str(k.to_string())),
6930                Some(Value::Str(label.to_string())),
6931                Some(Value::Int(*d as i64)),
6932            ]);
6933            visited.push((*nid, *d));
6934        }
6935        // Stub mode: add hidden direct neighbours of each visited node as stubs.
6936        // Hidden nodes are edge-endpoints only — they are not added to the BFS
6937        // frontier, so the BFS never expands through them.
6938        if mask.mode() == crate::mask::MaskMode::Stub {
6939            let raw_view = self.view();
6940            let mut seen: std::collections::HashSet<u32> =
6941                visited.iter().map(|(id, _)| *id).collect();
6942            for (node_id, node_depth) in &visited {
6943                if *node_depth >= depth {
6944                    continue;
6945                }
6946                for e in expand(&raw_view, *node_id, resolved.as_deref(), dir) {
6947                    let nbr = if e.src == *node_id { e.dst } else { e.src };
6948                    if !mask.contains_id(nbr) && seen.insert(nbr) {
6949                        if let Some(k) = self.ids.key_of(nbr) {
6950                            rs.push_row(vec![
6951                                Some(Value::Str(k.to_string())),
6952                                None,
6953                                Some(Value::Int((*node_depth + 1) as i64)),
6954                            ]);
6955                        }
6956                    }
6957                }
6958            }
6959        }
6960        Some(rs)
6961    }
6962
6963    /// Live node's key, label, and columnar props. Unknown or tombstoned → `None`.
6964    pub fn node_info(&self, key: &str) -> Option<NodeInfo> {
6965        let n = self.node_ref(key)?;
6966        Some(NodeInfo {
6967            key: n.key().to_string(),
6968            label: n.label().to_string(),
6969            props: n.props(),
6970        })
6971    }
6972
6973    /// Look up a node with mask awareness.
6974    ///
6975    /// | Key state         | Omit mode       | Stub mode              |
6976    /// |-------------------|-----------------|------------------------|
6977    /// | does not exist    | `None` (→ 404)  | `None` (→ 404)         |
6978    /// | exists, visible   | `Some(Visible)` | `Some(Visible)`        |
6979    /// | exists, hidden    | `None` (→ 404)  | `Some(Restricted)`     |
6980    ///
6981    /// **SECURITY**: only call from client-mask (full-token) paths.
6982    /// Role-token paths must use [`node_info`] after an explicit visibility check.
6983    pub fn node_info_masked(
6984        &self,
6985        key: &str,
6986        mask: &crate::mask::NodeMask,
6987    ) -> Option<MaskedNodeResult> {
6988        let id = self.ids.get(key)?;
6989        if mask.contains_id(id) {
6990            Some(MaskedNodeResult::Visible(self.node_info(key)?))
6991        } else {
6992            match mask.mode() {
6993                crate::mask::MaskMode::Stub => Some(MaskedNodeResult::Restricted),
6994                crate::mask::MaskMode::Omit => None,
6995            }
6996        }
6997    }
6998
6999    /// Get edges for `key` with mask-aware hidden-endpoint handling.
7000    ///
7001    /// - Omit mode: edges to hidden endpoints are excluded (same as role-path filtering).
7002    /// - Stub mode: edges to hidden endpoints are included; `src_restricted`/`dst_restricted`
7003    ///   is `true` for each hidden endpoint.
7004    ///
7005    /// Unknown key → [`GraphError::KeyNotFound`].
7006    ///
7007    /// **SECURITY**: only call from client-mask (full-token) paths.
7008    pub fn node_edges_masked(
7009        &self,
7010        key: &str,
7011        mask: &crate::mask::NodeMask,
7012    ) -> Result<Vec<MaskedEdge>> {
7013        self.ensure_v8_base_sections_loaded();
7014        let id = self
7015            .ids
7016            .get(key)
7017            .ok_or_else(|| GraphError::KeyNotFound { key: key.into() })?;
7018        let derived: BTreeSet<(u32, u32, u32)> = self
7019            .engine
7020            .provenance_touching(id)
7021            .map(|(_rule, etype, src, dst)| (etype, src, dst))
7022            .collect();
7023        let mut edges = Vec::new();
7024        let tv = self.topo_view();
7025        for etype in tv.etypes() {
7026            // etype comes from the archived CSR (access_unchecked, no eager CRC).
7027            // A bit-flip in the large TOPOLOGY section can produce an etype id
7028            // that is not in the interner.  Return Corrupt rather than panic.
7029            let edge_type = self
7030                .syms
7031                .resolve(etype)
7032                .ok_or_else(|| GraphError::Corrupt {
7033                    detail: format!("v8: topology etype {etype} not in interner"),
7034                })?
7035                .to_string();
7036            for dir in [Direction::Out, Direction::In] {
7037                for &nbr in tv.neighbors(etype, dir, id).as_ref() {
7038                    let nbr_restricted = !mask.contains_id(nbr);
7039                    if nbr_restricted && mask.mode() == crate::mask::MaskMode::Omit {
7040                        continue;
7041                    }
7042                    let nbr_key = self
7043                        .ids
7044                        .key_of(nbr)
7045                        .ok_or_else(|| GraphError::Corrupt {
7046                            detail: format!("topology id {nbr} has no key"),
7047                        })?
7048                        .to_string();
7049                    let (src_id, dst_id, src_key, dst_key, src_restricted, dst_restricted) =
7050                        match dir {
7051                            Direction::Out => {
7052                                (id, nbr, key.to_string(), nbr_key, false, nbr_restricted)
7053                            }
7054                            Direction::In => {
7055                                (nbr, id, nbr_key, key.to_string(), nbr_restricted, false)
7056                            }
7057                        };
7058                    edges.push(MaskedEdge {
7059                        edge_type: edge_type.clone(),
7060                        src_key,
7061                        src_restricted,
7062                        dst_key,
7063                        dst_restricted,
7064                        derived: derived.contains(&(etype, src_id, dst_id)),
7065                    });
7066                }
7067            }
7068        }
7069        edges.sort_by(|a, b| {
7070            a.edge_type
7071                .cmp(&b.edge_type)
7072                .then(a.src_key.cmp(&b.src_key))
7073                .then(a.dst_key.cmp(&b.dst_key))
7074        });
7075        edges.dedup_by(|a, b| {
7076            a.edge_type == b.edge_type && a.src_key == b.src_key && a.dst_key == b.dst_key
7077        });
7078        Ok(edges)
7079    }
7080
7081    /// Every directed edge incident on `key`, both directions, every etype.
7082    ///
7083    /// Walk is `topology.etypes()` × `{Out, In}` × `neighbors()`. `derived` is
7084    /// membership in [`RuleEngine::provenance_touching`] (O(degree) via the
7085    /// Plan-8 `by_node` index). Sorted by `(edge_type, src_key, dst_key)`.
7086    /// Unknown key → [`GraphError::KeyNotFound`].
7087    pub fn node_edges(&self, key: &str) -> Result<Vec<EdgeInfo>> {
7088        self.ensure_v8_base_sections_loaded();
7089        let id = self
7090            .ids
7091            .get(key)
7092            .ok_or_else(|| GraphError::KeyNotFound { key: key.into() })?;
7093        let derived: BTreeSet<(u32, u32, u32)> = self
7094            .engine
7095            .provenance_touching(id)
7096            .map(|(_rule, etype, src, dst)| (etype, src, dst))
7097            .collect();
7098        let mut edges = Vec::new();
7099        let tv = self.topo_view();
7100        for etype in tv.etypes() {
7101            // Same guard as node_edges_masked: etype from unchecked-CRC CSR.
7102            let edge_type = self
7103                .syms
7104                .resolve(etype)
7105                .ok_or_else(|| GraphError::Corrupt {
7106                    detail: format!("v8: topology etype {etype} not in interner"),
7107                })?
7108                .to_string();
7109            for dir in [Direction::Out, Direction::In] {
7110                for &nbr in tv.neighbors(etype, dir, id).as_ref() {
7111                    let (src, dst, src_key, dst_key) = match dir {
7112                        Direction::Out => (
7113                            id,
7114                            nbr,
7115                            key.to_string(),
7116                            self.ids
7117                                .key_of(nbr)
7118                                .ok_or_else(|| GraphError::Corrupt {
7119                                    detail: format!("topology id {nbr} has no key"),
7120                                })?
7121                                .to_string(),
7122                        ),
7123                        Direction::In => (
7124                            nbr,
7125                            id,
7126                            self.ids
7127                                .key_of(nbr)
7128                                .ok_or_else(|| GraphError::Corrupt {
7129                                    detail: format!("topology id {nbr} has no key"),
7130                                })?
7131                                .to_string(),
7132                            key.to_string(),
7133                        ),
7134                    };
7135                    edges.push(EdgeInfo {
7136                        edge_type: edge_type.clone(),
7137                        src_key,
7138                        dst_key,
7139                        derived: derived.contains(&(etype, src, dst)),
7140                    });
7141                }
7142            }
7143        }
7144        edges.sort_by(|a, b| {
7145            a.edge_type
7146                .cmp(&b.edge_type)
7147                .then(a.src_key.cmp(&b.src_key))
7148                .then(a.dst_key.cmp(&b.dst_key))
7149        });
7150        // Self-loops appear in both Out and In; sort makes the pair adjacent
7151        // (sort key matches PartialEq for this case) so one pass drops the dup.
7152        edges.dedup();
7153        Ok(edges)
7154    }
7155
7156    // ── Backup ────────────────────────────────────────────────────────────────
7157
7158    /// Copy this store to `dest` as a consistent, verified snapshot.
7159    ///
7160    /// Copies every durable file in the database directory — `snapshot.bin`,
7161    /// `wal.bin`, all `wal.<N>.archive` files, `wal.floor`, `wal.genesis`, and
7162    /// `roles.json` — into a freshly created `dest` directory using OS-level
7163    /// `copy` calls (no large in-process buffers).
7164    ///
7165    /// # Consistency guarantee
7166    ///
7167    /// The guarantee is **process-local**: the caller holds `&self`, which
7168    /// prevents any concurrent writer in the **same process** from modifying
7169    /// the files during the copy.  Running `mushroomdb backup` against a
7170    /// directory that is **concurrently being written by another process** (e.g.
7171    /// `mushroomdb serve`) is **unsafe** — the copy can be torn.  The post-copy
7172    /// `verified: true` result reduces but does not eliminate the risk of a
7173    /// silent corrupt backup (CRC catches many bit-flips; it cannot catch a
7174    /// consistent mid-write snapshot).
7175    ///
7176    /// **The safe path for a live-served store is `POST /backup` on the HTTP
7177    /// server.** That handler acquires the read lock on the shared database
7178    /// before calling this method, which is the correct cross-process
7179    /// synchronisation point because the server is the single process writing
7180    /// the files.
7181    ///
7182    /// After copying, opens the destination read-only and runs the CRC section
7183    /// verifier (`verify_snapshot`) to confirm byte-for-byte integrity.
7184    /// `BackupReport::verified` reflects whether both checks passed.
7185    ///
7186    /// Returns `Err` when `self` is not backed by a `RealFs` (e.g. `SimFs`).
7187    pub fn backup_to(&self, dest: &std::path::Path) -> Result<BackupReport> {
7188        // Derive source directory from snapshot_path (RealFs only).
7189        let src_dir = match self.fs.snapshot_path() {
7190            Some(p) => p.parent().map(|d| d.to_path_buf()).ok_or_else(|| {
7191                GraphError::Io(std::io::Error::other("snapshot has no parent dir"))
7192            })?,
7193            None => {
7194                return Err(GraphError::Io(std::io::Error::other(
7195                    "backup_to requires a real filesystem (RealFs)",
7196                )))
7197            }
7198        };
7199
7200        std::fs::create_dir_all(dest)?;
7201
7202        let mut files: Vec<String> = Vec::new();
7203        let mut bytes: u64 = 0;
7204
7205        // Helper: copy src_dir/name → dest/name if the file exists.
7206        let mut try_copy = |name: &str| -> std::io::Result<()> {
7207            let src_path = src_dir.join(name);
7208            if src_path.exists() {
7209                let n = std::fs::copy(&src_path, dest.join(name))?;
7210                bytes += n;
7211                files.push(name.to_string());
7212            }
7213            Ok(())
7214        };
7215
7216        try_copy("snapshot.bin")?;
7217        try_copy("snapshot.bin.bak")?;
7218        try_copy("wal.bin")?;
7219        try_copy("wal.floor")?;
7220        try_copy("wal.genesis")?;
7221        try_copy("roles.json")?;
7222
7223        // Copy WAL archives.
7224        let archives = self.fs.list_archives()?;
7225        for n in &archives {
7226            let name = format!("wal.{n}.archive");
7227            let n_bytes = std::fs::copy(src_dir.join(&name), dest.join(&name))?;
7228            bytes += n_bytes;
7229            files.push(name);
7230        }
7231
7232        files.sort();
7233
7234        // Post-copy verification: open dest and run CRC checks.
7235        let snap_in_dest = dest.join("snapshot.bin").exists();
7236        let crc_ok = if snap_in_dest {
7237            crate::verify_snapshot(dest)
7238                .map(|results| results.iter().all(|(_, _, _, r)| r.is_ok()))
7239                .unwrap_or(false)
7240        } else {
7241            true // WAL-only store: nothing to CRC-check in snapshot
7242        };
7243        let opens_ok = GraphDb::<core_storage::fs::RealFs>::open(dest).is_ok();
7244        let verified = crc_ok && opens_ok;
7245
7246        Ok(BackupReport {
7247            files,
7248            bytes,
7249            verified,
7250        })
7251    }
7252
7253    // ── Export helpers ────────────────────────────────────────────────────────
7254
7255    /// All live nodes, sorted by key (deterministic).
7256    ///
7257    /// Reads base + WAL overlay. Tombstoned nodes are excluded.
7258    pub fn all_nodes_for_export(&self) -> Vec<NodeInfo> {
7259        self.ensure_v8_base_sections_loaded();
7260        let pv = self.props_view();
7261        let mut nodes = Vec::new();
7262        for id in 0..self.ids.len() as u32 {
7263            let Some(key) = self.ids.key_of(id) else {
7264                continue;
7265            };
7266            let Some(&sym) = self.labels.get(id as usize) else {
7267                continue;
7268            };
7269            if sym == u32::MAX {
7270                continue; // tombstoned
7271            }
7272            let Some(label) = self.syms.resolve(sym) else {
7273                continue;
7274            };
7275            let mut props = BTreeMap::new();
7276            for field in pv.field_names() {
7277                if let Some(vr) = pv.get(id, &field) {
7278                    props.insert(field, vr.into_value());
7279                }
7280            }
7281            nodes.push(NodeInfo {
7282                key: key.to_string(),
7283                label: label.to_string(),
7284                props,
7285            });
7286        }
7287        nodes.sort_by(|a, b| a.key.cmp(&b.key));
7288        nodes
7289    }
7290
7291    /// All directed edges, sorted by `(edge_type, src, dst)`. Each edge appears once.
7292    ///
7293    /// Derived edges carry `derived: true` and the creating rule's name in `rule`.
7294    /// Manual edges carry `derived: false` and `rule: None`.
7295    /// `weight` is the creating rule's `weight_prop` value read off the edge
7296    /// (numeric only), mirroring the convention used by [`GraphDb::explain`]
7297    /// and [`GraphDb::weighted_edges`]. Deterministic across runs on the same
7298    /// store state.
7299    pub fn all_edges_for_export(&self) -> Vec<ExportEdge> {
7300        self.ensure_v8_base_sections_loaded();
7301
7302        // Build (etype_sym, src_id, dst_id) → rule_name for O(1) derivation lookup.
7303        let mut prov: HashMap<(u32, u32, u32), String> = HashMap::new();
7304        for (rule_name, triples) in self.engine.provenance() {
7305            for &(etype, src, dst) in triples {
7306                prov.insert((etype, src, dst), rule_name.clone());
7307            }
7308        }
7309
7310        // rule_name → weight_prop, for O(1) lookup per derived edge.
7311        let weight_props: HashMap<&str, Option<&str>> = self
7312            .engine
7313            .rules()
7314            .map(|r| (r.name.as_str(), r.weight_prop.as_deref()))
7315            .collect();
7316
7317        let tv = self.topo_view();
7318        let ep = self.edge_props_view();
7319        let mut edges = Vec::new();
7320
7321        for id in 0..self.ids.len() as u32 {
7322            let Some(key) = self.ids.key_of(id) else {
7323                continue;
7324            };
7325            let Some(&lsym) = self.labels.get(id as usize) else {
7326                continue;
7327            };
7328            if lsym == u32::MAX {
7329                continue; // tombstoned
7330            }
7331
7332            for etype_sym in tv.etypes() {
7333                // etype from archived CSR (access_unchecked, no eager CRC).
7334                // Skip edges whose etype is not in the interner; this can only
7335                // occur with a corrupt large TOPOLOGY section (bit-flip on an
7336                // etype field in the archived data).  The function returns Vec,
7337                // not Result, so we continue rather than propagate.
7338                let Some(edge_type) = self.syms.resolve(etype_sym) else {
7339                    continue;
7340                };
7341                let edge_type = edge_type.to_string();
7342                for &nbr in tv.neighbors(etype_sym, Direction::Out, id).as_ref() {
7343                    let Some(dst_key) = self.ids.key_of(nbr) else {
7344                        continue; // skip corrupt entries
7345                    };
7346                    let prov_key = (etype_sym, id, nbr);
7347                    let rule = prov.get(&prov_key).cloned();
7348                    let derived = rule.is_some();
7349                    let weight = rule
7350                        .as_deref()
7351                        .and_then(|rn| weight_props.get(rn).copied().flatten())
7352                        .and_then(|prop| match ep.get(etype_sym, id, nbr, prop) {
7353                            Some(Value::Float(f)) => Some(f),
7354                            Some(Value::Int(i)) => Some(i as f64),
7355                            _ => None,
7356                        });
7357                    edges.push(ExportEdge {
7358                        edge_type: edge_type.clone(),
7359                        src: key.to_string(),
7360                        dst: dst_key.to_string(),
7361                        derived,
7362                        rule,
7363                        weight,
7364                    });
7365                }
7366            }
7367        }
7368
7369        edges.sort_by(|a, b| {
7370            a.edge_type
7371                .cmp(&b.edge_type)
7372                .then(a.src.cmp(&b.src))
7373                .then(a.dst.cmp(&b.dst))
7374        });
7375        edges
7376    }
7377
7378    /// All directed edges of `edge_type`, with the raw value of `weight_prop`
7379    /// on each edge when given.
7380    ///
7381    /// `weight` is `Some(f)` only when `weight_prop` is set and the edge
7382    /// carries that property with a numeric (`Int`/`Float`) value; otherwise
7383    /// `None` — callers that want a default weight (e.g. `1.0` for missing
7384    /// props) apply it themselves, matching the convention used internally
7385    /// by [`GraphDb::pagerank`], [`GraphDb::connected_components`],
7386    /// [`GraphDb::degree_centrality`], and [`GraphDb::communities`].
7387    ///
7388    /// Sorted by `(src, dst)` for determinism. Reads the unified topology
7389    /// (manual + rule-derived edges).  An unknown `edge_type` returns an
7390    /// empty vec.
7391    pub fn weighted_edges(
7392        &self,
7393        edge_type: &str,
7394        weight_prop: Option<&str>,
7395    ) -> Vec<(String, String, Option<f64>)> {
7396        let Some(etype_sym) = self.syms.get(edge_type) else {
7397            return Vec::new();
7398        };
7399        let tv = self.topo_view();
7400        let ep = self.edge_props_view();
7401        let mut out = Vec::new();
7402        for id in 0..self.ids.len() as u32 {
7403            let Some(key) = self.ids.key_of(id) else {
7404                continue;
7405            };
7406            let Some(&sym) = self.labels.get(id as usize) else {
7407                continue;
7408            };
7409            if sym == u32::MAX {
7410                continue; // tombstoned
7411            }
7412            for &nbr in tv.neighbors(etype_sym, Direction::Out, id).as_ref() {
7413                let Some(dst_key) = self.ids.key_of(nbr) else {
7414                    continue;
7415                };
7416                let weight = weight_prop.and_then(|prop| match ep.get(etype_sym, id, nbr, prop) {
7417                    Some(Value::Float(f)) => Some(f),
7418                    Some(Value::Int(i)) => Some(i as f64),
7419                    _ => None,
7420                });
7421                out.push((key.to_string(), dst_key.to_string(), weight));
7422            }
7423        }
7424        out.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.cmp(&b.1)));
7425        out
7426    }
7427
7428    pub fn nodes_with_label(&self, label: &str) -> Vec<NodeRef<'_, F>> {
7429        self.view()
7430            .nodes_with_label(label)
7431            .into_iter()
7432            .map(|id| NodeRef { db: self, id })
7433            .collect()
7434    }
7435
7436    pub fn find_nodes(&self, label: &str, filter: &Filter) -> Vec<NodeRef<'_, F>> {
7437        let view = self.view();
7438        view.nodes_with_label(label)
7439            .into_iter()
7440            .filter(|&id| {
7441                eval_filter(filter, &|field| {
7442                    view.prop(id, field).map(|vr| vr.into_value())
7443                })
7444            })
7445            .map(|id| NodeRef { db: self, id })
7446            .collect()
7447    }
7448
7449    /// Returns `true` if any approximate (HNSW) VectorSimilar rule covers
7450    /// `field`.  Use as a capability probe: when `true`, `find_similar_vector`
7451    /// with `label = None` will use the native ANN path rather than the O(n)
7452    /// brute-force scan.
7453    pub fn has_vector_rule(&self, field: &str) -> bool {
7454        self.engine.hnsw_has_rule(field)
7455    }
7456
7457    /// Find nodes whose `field` vector is most similar to `q` (cosine
7458    /// similarity), returning up to `k` results with similarity ≥ `min`,
7459    /// sorted descending.
7460    ///
7461    /// When `label` is `None` the search spans all labels (via
7462    /// `hnsw_search_any_dst` or a full brute-force scan); when `label` is
7463    /// `Some(lbl)` it restricts to nodes with that label.
7464    ///
7465    /// Uses the HNSW index when one is available (fast path); otherwise falls
7466    /// back to an O(n) brute-force scan.
7467    pub fn find_similar_vector(
7468        &self,
7469        field: &str,
7470        label: Option<&str>,
7471        q: &[f64],
7472        k: usize,
7473        min: f64,
7474    ) -> Vec<(String, f64)> {
7475        // Ensure any HNSW blobs retained from the snapshot are deserialized
7476        // before the first ANN query on a clean-open (no-WAL) path.
7477        self.engine.ensure_hnsw_loaded();
7478        // L2-normalise query for cosine via dot product.
7479        let norm: f64 = q.iter().map(|x| x * x).sum::<f64>().sqrt();
7480        if norm == 0.0 {
7481            return vec![];
7482        }
7483        let q_unit: Vec<f64> = q.iter().map(|x| x / norm).collect();
7484
7485        // Try HNSW fast path.
7486        // `None` label searches across all VectorSimilar rules covering `field`
7487        // (merging their results); `Some(lbl)` restricts to rules whose
7488        // dst_label matches.  Returns `None` when no populated HNSW index
7489        // covers the request — the O(n) brute-force fallback handles that case.
7490        let hnsw_hits = match label {
7491            Some(lbl) => self.engine.hnsw_search_dst(field, lbl, &q_unit, k),
7492            None => self.engine.hnsw_search_any_dst(field, &q_unit, k),
7493        };
7494        if let Some(hits) = hnsw_hits {
7495            let mut out: Vec<(String, f64)> = hits
7496                .into_iter()
7497                .filter(|&(_, sim)| sim >= min)
7498                .filter_map(|(id, sim)| self.ids.key_of(id).map(|key| (key.to_string(), sim)))
7499                .collect();
7500            out.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
7501            out.truncate(k);
7502            return out;
7503        }
7504
7505        // Brute-force fallback: O(n) scan (only reached when no HNSW index
7506        // covers the request).
7507        let view = self.view();
7508        let candidate_ids: Vec<u32> = match label {
7509            Some(lbl) => view.nodes_with_label(lbl),
7510            None => view.nodes_all(),
7511        };
7512        let mut scored: Vec<(String, f64)> = candidate_ids
7513            .into_iter()
7514            .filter_map(|id| {
7515                let v = view.prop(id, field)?;
7516                let v_owned = v.into_value();
7517                let xs = value_as_float_list(&v_owned)?;
7518                let v_norm: f64 = xs.iter().map(|x| x * x).sum::<f64>().sqrt();
7519                if v_norm == 0.0 {
7520                    return None;
7521                }
7522                let dot: f64 = q_unit
7523                    .iter()
7524                    .zip(xs.iter())
7525                    .map(|(a, b)| a * (b / v_norm))
7526                    .sum();
7527                if dot < min {
7528                    return None;
7529                }
7530                let key = self.ids.key_of(id)?.to_string();
7531                Some((key, dot))
7532            })
7533            .collect();
7534        scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
7535        scored.truncate(k);
7536        scored
7537    }
7538
7539    /// Like [`find_similar_vector`] but restricts results to nodes visible in
7540    /// `mask`. Hidden nodes never appear in results; the mask is applied
7541    /// **before** k-truncation so a caller still receives up to `k` visible
7542    /// hits.
7543    ///
7544    /// # HNSW path (over-fetch policy)
7545    ///
7546    /// When an HNSW index covers the request, this function fetches `4 * k`
7547    /// candidates from the index and discards hidden nodes in the post-filter
7548    /// step.  If fewer than `k` visible nodes remain after filtering the caller
7549    /// receives whatever is available — we do not re-query the index.  The 4×
7550    /// multiplier is a heuristic suited for sparsely masked graphs; callers
7551    /// operating under a very selective mask should register a VectorSimilar
7552    /// rule with a non-approximate index, or use the brute-force path (no HNSW
7553    /// rule) which exhaustively filters through the masked [`GraphView`].
7554    ///
7555    /// # Brute-force path
7556    ///
7557    /// When no HNSW index covers the request the function builds a masked
7558    /// [`GraphView`] so that `nodes_all` / `nodes_with_label` return only
7559    /// visible nodes, guaranteeing exact `k` results (or all visible nodes if
7560    /// fewer than `k` exist).
7561    pub fn find_similar_vector_masked(
7562        &self,
7563        field: &str,
7564        label: Option<&str>,
7565        q: &[f64],
7566        k: usize,
7567        min: f64,
7568        mask: &crate::mask::NodeMask,
7569    ) -> Vec<(String, f64)> {
7570        self.engine.ensure_hnsw_loaded();
7571        let norm: f64 = q.iter().map(|x| x * x).sum::<f64>().sqrt();
7572        if norm == 0.0 {
7573            return vec![];
7574        }
7575        let q_unit: Vec<f64> = q.iter().map(|x| x / norm).collect();
7576
7577        // HNSW fast path — over-fetch 4×k so post-masking still yields up to k
7578        // visible hits.  See doc comment above for the policy rationale.
7579        let over_k = k.saturating_mul(4).max(k + 1);
7580        let hnsw_hits = match label {
7581            Some(lbl) => self.engine.hnsw_search_dst(field, lbl, &q_unit, over_k),
7582            None => self.engine.hnsw_search_any_dst(field, &q_unit, over_k),
7583        };
7584        if let Some(hits) = hnsw_hits {
7585            let mut out: Vec<(String, f64)> = hits
7586                .into_iter()
7587                .filter(|&(id, sim)| sim >= min && mask.visible.contains(&id))
7588                .filter_map(|(id, sim)| self.ids.key_of(id).map(|key| (key.to_string(), sim)))
7589                .collect();
7590            out.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
7591            out.truncate(k);
7592            return out;
7593        }
7594
7595        // Brute-force fallback — masked view ensures only visible nodes are
7596        // enumerated by nodes_all(); nodes_with_label() does not filter by
7597        // mask so we apply view.visible() explicitly for the labeled case.
7598        let view = self.view_masked(mask);
7599        let candidate_ids: Vec<u32> = match label {
7600            Some(lbl) => view
7601                .nodes_with_label(lbl)
7602                .into_iter()
7603                .filter(|&id| view.visible(id))
7604                .collect(),
7605            None => view.nodes_all(),
7606        };
7607        let mut scored: Vec<(String, f64)> = candidate_ids
7608            .into_iter()
7609            .filter_map(|id| {
7610                let v = view.prop(id, field)?;
7611                let v_owned = v.into_value();
7612                let xs = value_as_float_list(&v_owned)?;
7613                let v_norm: f64 = xs.iter().map(|x| x * x).sum::<f64>().sqrt();
7614                if v_norm == 0.0 {
7615                    return None;
7616                }
7617                let dot: f64 = q_unit
7618                    .iter()
7619                    .zip(xs.iter())
7620                    .map(|(a, b)| a * (b / v_norm))
7621                    .sum();
7622                if dot < min {
7623                    return None;
7624                }
7625                let key = self.ids.key_of(id)?.to_string();
7626                Some((key, dot))
7627            })
7628            .collect();
7629        scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
7630        scored.truncate(k);
7631        scored
7632    }
7633
7634    /// Read a single property from an edge.
7635    ///
7636    /// Returns `None` when the edge does not exist, the field is absent, or any
7637    /// of the string keys cannot be resolved to interned ids.  Only edge props
7638    /// written by rules (weight fields) are accessible without a `set_edge_prop`
7639    /// binding; topology-only edges (no props set) return `None` for every field.
7640    pub fn get_edge_prop(
7641        &self,
7642        edge_type: &str,
7643        src_key: &str,
7644        dst_key: &str,
7645        field: &str,
7646    ) -> Option<Value> {
7647        let etype = self.syms.get(edge_type)?;
7648        let src = self.ids.get(src_key)?;
7649        let dst = self.ids.get(dst_key)?;
7650        self.edge_props_view().get(etype, src, dst, field)
7651    }
7652
7653    /// Lex → parse → plan → execute `cypher` over a read-only view.
7654    /// Every pipeline `Err(String)` becomes `GraphError::QueryError` with a
7655    /// stage prefix (`lex:` / `parse:` / `plan:` / `execute:`).
7656    pub fn query(&self, cypher: &str, params: &BTreeMap<String, Value>) -> Result<ResultSet> {
7657        let tokens = lex(cypher).map_err(|e| GraphError::QueryError {
7658            detail: format!("lex: {e}"),
7659        })?;
7660        let union = parse_read(&tokens).map_err(|e| GraphError::QueryError {
7661            detail: format!("parse: {e}"),
7662        })?;
7663        let t0 = std::time::Instant::now();
7664        let result = execute_union(&self.view(), &union, &Params(params)).map_err(|e| {
7665            GraphError::QueryError {
7666                detail: format!("execute: {e}"),
7667            }
7668        });
7669        let elapsed_ms = t0.elapsed().as_millis() as u64;
7670        let threshold = self.slow_query_threshold_ms;
7671        if threshold > 0 && elapsed_ms >= threshold {
7672            eprintln!("[mushroomdb] slow query ({elapsed_ms}ms): {cypher}");
7673            let entry = SlowQueryEntry {
7674                ms: elapsed_ms,
7675                query: cypher.to_string(),
7676                at_commit: self.commit_seq,
7677            };
7678            if let Ok(mut log) = self.slow_queries.lock() {
7679                if log.entries.len() == SLOW_QUERY_RING_CAP {
7680                    log.entries.pop_front();
7681                }
7682                log.entries.push_back(entry);
7683                log.total += 1;
7684            }
7685        }
7686        result
7687    }
7688
7689    /// Convenience entry-point that accepts a slice of `(name, value)` pairs
7690    /// instead of a pre-built `BTreeMap`.  Equivalent to building the map and
7691    /// calling [`GraphDb::query`].
7692    pub fn query_with_params(&self, cypher: &str, params: &[(&str, Value)]) -> Result<ResultSet> {
7693        let map: BTreeMap<String, Value> = params
7694            .iter()
7695            .map(|(k, v)| (k.to_string(), v.clone()))
7696            .collect();
7697        self.query(cypher, &map)
7698    }
7699
7700    /// Execute a Cypher write statement (CREATE / MATCH…SET / MATCH…DELETE / MERGE).
7701    ///
7702    /// All mutations flow through the same `insert_node` / `set_prop` /
7703    /// `delete_edge` / `insert_edge` path as the Rust API so the rule engine
7704    /// fires and the WAL captures everything with one fsync per statement.
7705    ///
7706    /// Returns a one-row [`ResultSet`] with columns `created`, `properties_set`,
7707    /// and `deleted` matching the write-result contract.
7708    ///
7709    /// **Mutation routing**: mutations are collected into a single
7710    /// [`BatchBuilder`] and committed atomically (one WAL `Batch` frame, one
7711    /// fsync). The MATCH phase for SET/DELETE uses a read-only `execute` call
7712    /// over `self.view()` — the borrow is dropped before the batch is opened.
7713    ///
7714    /// **Limitations (v1)**:
7715    /// - SET RHS must be a literal, `$param`, or arithmetic; bare property copy → named error.
7716    /// - `DETACH DELETE n` → calls `delete_node` for each matched node (removes all edges).
7717    /// - Bare `DELETE n` → error if n has any incident edges; succeeds for isolated nodes.
7718    /// - MERGE supports `ON CREATE SET` / `ON MATCH SET` in the same write batch.
7719    /// - Deleting a derived edge → named error "cannot delete derived edge".
7720    pub fn query_write(
7721        &mut self,
7722        cypher: &str,
7723        params: &BTreeMap<String, Value>,
7724    ) -> Result<ResultSet> {
7725        let tokens = lex(cypher).map_err(|e| GraphError::QueryError {
7726            detail: format!("lex: {e}"),
7727        })?;
7728        let stmt = parse_write(&tokens).map_err(|e| GraphError::QueryError {
7729            detail: format!("parse: {e}"),
7730        })?;
7731        self.exec_write_stmt(stmt, params)
7732    }
7733
7734    fn exec_write_stmt(
7735        &mut self,
7736        stmt: WriteStatement,
7737        params: &BTreeMap<String, Value>,
7738    ) -> Result<ResultSet> {
7739        match stmt {
7740            WriteStatement::Create(s) => self.exec_create(s, params),
7741            WriteStatement::MatchSet(s) => self.exec_match_set(s, params),
7742            WriteStatement::MatchDelete(s) => self.exec_match_delete(s, params),
7743            WriteStatement::MatchDeleteNode(s) => self.exec_match_delete_node(s, params),
7744            WriteStatement::Merge(s) => self.exec_merge(s, params),
7745        }
7746    }
7747
7748    fn exec_create(
7749        &mut self,
7750        stmt: core_query::cypher::CreateStmt,
7751        params: &BTreeMap<String, Value>,
7752    ) -> Result<ResultSet> {
7753        // Extract the node key from props: require a string-valued `id` field.
7754        let mut var_to_key: BTreeMap<String, String> = BTreeMap::new();
7755        for node in &stmt.nodes {
7756            let var = node.var.as_deref().unwrap_or("_cn0");
7757            let key = node
7758                .props
7759                .iter()
7760                .find(|(f, _)| f == "id")
7761                .and_then(|(_, v)| {
7762                    if let Value::Str(s) = v {
7763                        Some(s.clone())
7764                    } else {
7765                        None
7766                    }
7767                })
7768                .ok_or_else(|| GraphError::QueryError {
7769                    detail: format!(
7770                        "CREATE node ({}:{}) requires a string 'id' property",
7771                        var, node.label
7772                    ),
7773                })?;
7774            var_to_key.insert(var.to_string(), key);
7775        }
7776
7777        let mut batch = self.batch();
7778        let mut created: usize = 0;
7779        for node in &stmt.nodes {
7780            let var = node.var.as_deref().unwrap_or("_cn0");
7781            let key = &var_to_key[var];
7782            batch.insert_node(&node.label, key, node.props.clone());
7783            created += 1;
7784        }
7785        for edge in &stmt.edges {
7786            let src_key = var_to_key
7787                .get(&edge.src_var)
7788                .ok_or_else(|| GraphError::QueryError {
7789                    detail: format!("CREATE edge src variable '{}' is not bound", edge.src_var),
7790                })?;
7791            let dst_key = var_to_key
7792                .get(&edge.dst_var)
7793                .ok_or_else(|| GraphError::QueryError {
7794                    detail: format!("CREATE edge dst variable '{}' is not bound", edge.dst_var),
7795                })?;
7796            batch.insert_edge(&edge.etype, src_key, dst_key);
7797        }
7798        batch.commit()?;
7799
7800        // Optional RETURN clause: project created bindings as a read result.
7801        if let Some(returns) = stmt.returns {
7802            // Each created node is looked up by its key via a separate MATCH pattern.
7803            // Multiple single-node patterns cross-join to produce 1 output row with
7804            // all variables bound (each pattern returns exactly 1 row).
7805            let patterns: Vec<Pattern> = stmt
7806                .nodes
7807                .iter()
7808                .map(|node| {
7809                    let var = node.var.as_deref().unwrap_or("_cn0");
7810                    let key = var_to_key[var].clone();
7811                    Pattern {
7812                        start: NodePat {
7813                            var: Some(var.to_string()),
7814                            label: Some(node.label.clone()),
7815                            props: vec![("id".to_string(), Operand::Lit(Value::Str(key)))],
7816                        },
7817                        chain: vec![],
7818                        shortest: false,
7819                    }
7820                })
7821                .collect();
7822            let q = Query {
7823                matches: patterns,
7824                optional_clauses: vec![],
7825                where_expr: None,
7826                unwinds: vec![],
7827                post_unwind_where: None,
7828                stages: vec![],
7829                returns,
7830                distinct: false,
7831                order_by: vec![],
7832                skip: None,
7833                limit: None,
7834            };
7835            let ops = plan(&q).map_err(|e| GraphError::QueryError {
7836                detail: format!("plan: {e}"),
7837            })?;
7838            return execute(&self.view(), &ops, &Params(params)).map_err(|e| {
7839                GraphError::QueryError {
7840                    detail: format!("execute: {e}"),
7841                }
7842            });
7843        }
7844
7845        let mut rs = write_result_set();
7846        rs.push_row(vec![
7847            Some(Value::Int(created as i64)),
7848            Some(Value::Int(0)),
7849            Some(Value::Int(0)),
7850        ]);
7851        Ok(rs)
7852    }
7853
7854    fn exec_match_set(
7855        &mut self,
7856        stmt: core_query::cypher::MatchSetStmt,
7857        params: &BTreeMap<String, Value>,
7858    ) -> Result<ResultSet> {
7859        let project_returns = stmt.returns.clone();
7860        // Collect unique node vars targeted by SET clauses, plus RETURN bindings
7861        // so the post-write projection can look them up by key.
7862        let mut set_vars: Vec<String> = Vec::new();
7863        for s in &stmt.sets {
7864            if !set_vars.contains(&s.var) {
7865                set_vars.push(s.var.clone());
7866            }
7867        }
7868        let rel_vars = pattern_rel_vars(&stmt.matches);
7869        let mut lookup_vars = set_vars.clone();
7870        for v in pattern_node_vars(&stmt.matches) {
7871            add_var(&mut lookup_vars, &v);
7872        }
7873        if let Some(ref returns) = project_returns {
7874            for v in ret_node_vars(returns) {
7875                if !rel_vars.iter().any(|r| r == &v) {
7876                    add_var(&mut lookup_vars, &v);
7877                }
7878            }
7879        }
7880
7881        // Synthesize a read query: MATCH … WHERE … RETURN <lookup_vars>, <set_values…>
7882        // SET values are projected as ScalarExpr items so that arithmetic expressions
7883        // (e.g. `SET n.score = n.score * 1.5`) are evaluated in the matched-row context.
7884        let mut set_returns: Vec<RetItem> = lookup_vars
7885            .iter()
7886            .map(|v| RetItem {
7887                value: RetVal::Var(v.clone()),
7888                alias: None,
7889            })
7890            .collect();
7891        // One computed column per SET clause; alias is `__sv_<i>`.
7892        let set_val_cols: Vec<String> = stmt
7893            .sets
7894            .iter()
7895            .enumerate()
7896            .map(|(i, _)| format!("__sv_{i}"))
7897            .collect();
7898        for (sc, col) in stmt.sets.iter().zip(&set_val_cols) {
7899            set_returns.push(RetItem {
7900                value: RetVal::ScalarExpr(sc.value.clone()),
7901                alias: Some(col.clone()),
7902            });
7903        }
7904        // Capture relationship types while r is bound; SET does not change them.
7905        for r in &rel_vars {
7906            set_returns.push(RetItem {
7907                value: RetVal::FuncCall {
7908                    name: "type".into(),
7909                    args: vec![Operand::Var(r.clone())],
7910                },
7911                alias: Some(rel_type_alias(r)),
7912            });
7913        }
7914
7915        let read_q = Query {
7916            matches: stmt.matches.clone(),
7917            optional_clauses: vec![],
7918            where_expr: stmt.where_expr.clone(),
7919            unwinds: vec![],
7920            post_unwind_where: None,
7921            stages: vec![],
7922            returns: set_returns,
7923            distinct: false,
7924            order_by: vec![],
7925            skip: None,
7926            limit: None,
7927        };
7928        let ops = plan(&read_q).map_err(|e| GraphError::QueryError {
7929            detail: format!("plan: {e}"),
7930        })?;
7931        // MATCH phase is read-only; borrow ends before batch opens.
7932        //
7933        // When a role-scoped write is in flight, run the MATCH read through
7934        // view_masked so hidden nodes are invisible → hidden ≡ absent ≡
7935        // zero-rows (no SetProp ops generated, no existence-oracle 403).
7936        // Full-authority writes (pending_write_authz=None) keep view().
7937        let match_rs = {
7938            let mask_opt = self.pending_write_authz.as_ref().map(|a| a.mask.clone());
7939            if let Some(ref mask) = mask_opt {
7940                execute(&self.view_masked(mask), &ops, &Params(params))
7941            } else {
7942                execute(&self.view(), &ops, &Params(params))
7943            }
7944        }
7945        .map_err(|e| GraphError::QueryError {
7946            detail: format!("execute: {e}"),
7947        })?;
7948
7949        // Collect (key, field, value) for each matched row × each SET clause.
7950        let mut set_ops: Vec<(String, String, Value)> = Vec::new();
7951        for row_i in 0..match_rs.len() {
7952            for (sc, col) in stmt.sets.iter().zip(&set_val_cols) {
7953                let key = match match_rs.get(row_i, &sc.var) {
7954                    Some(Value::Str(k)) => k.clone(),
7955                    _ => {
7956                        return Err(GraphError::QueryError {
7957                            detail: format!(
7958                                "SET variable '{}' did not resolve to a node key",
7959                                sc.var
7960                            ),
7961                        })
7962                    }
7963                };
7964                // The SET value was already evaluated by the executor.
7965                let value = match match_rs.get(row_i, col) {
7966                    Some(v) => v.clone(),
7967                    None => {
7968                        return Err(GraphError::QueryError {
7969                            detail: format!(
7970                                "SET value for {}.{} evaluated to null",
7971                                sc.var, sc.field
7972                            ),
7973                        })
7974                    }
7975                };
7976                set_ops.push((key, sc.field.clone(), value));
7977            }
7978        }
7979
7980        // Apply as one atomic batch.
7981        let props_set = set_ops.len();
7982        let mut batch = self.batch();
7983        for (key, field, value) in set_ops {
7984            batch.set_prop(&key, &field, value);
7985        }
7986        batch.commit()?;
7987
7988        if let Some(returns) = project_returns {
7989            return project_set_return_rows(self, &rel_vars, &match_rs, &returns, params);
7990        }
7991
7992        let mut rs = write_result_set();
7993        rs.push_row(vec![
7994            Some(Value::Int(0)),
7995            Some(Value::Int(props_set as i64)),
7996            Some(Value::Int(0)),
7997        ]);
7998        Ok(rs)
7999    }
8000
8001    fn exec_match_delete(
8002        &mut self,
8003        stmt: core_query::cypher::MatchDeleteStmt,
8004        params: &BTreeMap<String, Value>,
8005    ) -> Result<ResultSet> {
8006        // Collect unique node vars needed to identify edge endpoints.
8007        let mut node_vars: Vec<String> = Vec::new();
8008        for ed in &stmt.deletes {
8009            if !node_vars.contains(&ed.src_var) {
8010                node_vars.push(ed.src_var.clone());
8011            }
8012            if !node_vars.contains(&ed.dst_var) {
8013                node_vars.push(ed.dst_var.clone());
8014            }
8015        }
8016
8017        // Synthesize read query.
8018        let returns: Vec<RetItem> = node_vars
8019            .iter()
8020            .map(|v| RetItem {
8021                value: RetVal::Var(v.clone()),
8022                alias: None,
8023            })
8024            .collect();
8025        let read_q = Query {
8026            matches: stmt.matches,
8027            optional_clauses: vec![],
8028            where_expr: stmt.where_expr,
8029            unwinds: vec![],
8030            post_unwind_where: None,
8031            stages: vec![],
8032            returns,
8033            distinct: false,
8034            order_by: vec![],
8035            skip: None,
8036            limit: None,
8037        };
8038        let ops = plan(&read_q).map_err(|e| GraphError::QueryError {
8039            detail: format!("plan: {e}"),
8040        })?;
8041        // Role-scoped writes: mask the MATCH read phase so hidden nodes are
8042        // invisible → hidden ≡ absent ≡ zero-rows (spec §3.1, hidden ≡ absent).
8043        let match_rs = {
8044            let mask_opt = self.pending_write_authz.as_ref().map(|a| a.mask.clone());
8045            if let Some(ref mask) = mask_opt {
8046                execute(&self.view_masked(mask), &ops, &Params(params))
8047            } else {
8048                execute(&self.view(), &ops, &Params(params))
8049            }
8050        }
8051        .map_err(|e| GraphError::QueryError {
8052            detail: format!("execute: {e}"),
8053        })?;
8054
8055        // Collect (etype, src_key, dst_key) for each row × each delete target.
8056        let mut del_ops: Vec<(String, String, String)> = Vec::new();
8057        for row_i in 0..match_rs.len() {
8058            for ed in &stmt.deletes {
8059                let src_key = match match_rs.get(row_i, &ed.src_var) {
8060                    Some(Value::Str(k)) => k.clone(),
8061                    _ => {
8062                        return Err(GraphError::QueryError {
8063                            detail: format!(
8064                                "DELETE src variable '{}' did not resolve to a node key",
8065                                ed.src_var
8066                            ),
8067                        })
8068                    }
8069                };
8070                let dst_key = match match_rs.get(row_i, &ed.dst_var) {
8071                    Some(Value::Str(k)) => k.clone(),
8072                    _ => {
8073                        return Err(GraphError::QueryError {
8074                            detail: format!(
8075                                "DELETE dst variable '{}' did not resolve to a node key",
8076                                ed.dst_var
8077                            ),
8078                        })
8079                    }
8080                };
8081                del_ops.push((ed.etype.clone(), src_key, dst_key));
8082            }
8083        }
8084
8085        // Apply as one atomic batch.
8086        let deleted = del_ops.len();
8087        let mut batch = self.batch();
8088        for (etype, src_key, dst_key) in del_ops {
8089            batch.delete_edge(&etype, &src_key, &dst_key);
8090        }
8091        batch.commit().map_err(|e| match e {
8092            GraphError::RuleOwned { .. } => GraphError::QueryError {
8093                detail: "cannot delete derived edge; retract via the rule or change the property"
8094                    .to_string(),
8095            },
8096            other => other,
8097        })?;
8098
8099        let mut rs = write_result_set();
8100        rs.push_row(vec![
8101            Some(Value::Int(0)),
8102            Some(Value::Int(0)),
8103            Some(Value::Int(deleted as i64)),
8104        ]);
8105        Ok(rs)
8106    }
8107
8108    /// Execute `MATCH … [DETACH] DELETE <node_var> [, …]`.
8109    ///
8110    /// Collects the matching node keys via an ephemeral read query, then calls
8111    /// `delete_node` on each one.  When `stmt.detach` is `false` (bare DELETE)
8112    /// the executor first checks that the node has no incident edges; if any
8113    /// remain it returns a named error matching openCypher semantics.
8114    fn exec_match_delete_node(
8115        &mut self,
8116        stmt: MatchDeleteNodeStmt,
8117        params: &BTreeMap<String, Value>,
8118    ) -> Result<ResultSet> {
8119        // Build a read query returning only the node keys we need.
8120        let returns: Vec<RetItem> = stmt
8121            .node_vars
8122            .iter()
8123            .map(|v| RetItem {
8124                value: RetVal::Var(v.clone()),
8125                alias: None,
8126            })
8127            .collect();
8128        let read_q = Query {
8129            matches: stmt.matches,
8130            optional_clauses: vec![],
8131            where_expr: stmt.where_expr,
8132            unwinds: vec![],
8133            post_unwind_where: None,
8134            stages: vec![],
8135            returns,
8136            distinct: false,
8137            order_by: vec![],
8138            skip: None,
8139            limit: None,
8140        };
8141        let ops = plan(&read_q).map_err(|e| GraphError::QueryError {
8142            detail: format!("plan: {e}"),
8143        })?;
8144        // Role-scoped writes: mask the MATCH read phase so hidden nodes are
8145        // invisible → hidden ≡ absent ≡ zero-rows (spec §3.1, hidden ≡ absent).
8146        let match_rs = {
8147            let mask_opt = self.pending_write_authz.as_ref().map(|a| a.mask.clone());
8148            if let Some(ref mask) = mask_opt {
8149                execute(&self.view_masked(mask), &ops, &Params(params))
8150            } else {
8151                execute(&self.view(), &ops, &Params(params))
8152            }
8153        }
8154        .map_err(|e| GraphError::QueryError {
8155            detail: format!("execute: {e}"),
8156        })?;
8157
8158        // Collect unique node keys to delete (deduplicate across rows × vars).
8159        let mut keys: Vec<String> = Vec::new();
8160        for row_i in 0..match_rs.len() {
8161            for var in &stmt.node_vars {
8162                if let Some(Value::Str(k)) = match_rs.get(row_i, var) {
8163                    if !keys.contains(k) {
8164                        keys.push(k.clone());
8165                    }
8166                }
8167            }
8168        }
8169
8170        if !stmt.detach {
8171            // openCypher bare DELETE: error if any matched node has incident edges.
8172            for key in &keys {
8173                if let Some(id) = self.ids.get(key) {
8174                    let tv = self.topo_view();
8175                    let has_edges = tv.etypes().any(|et| {
8176                        !tv.neighbors(et, Direction::Out, id).is_empty()
8177                            || !tv.neighbors(et, Direction::In, id).is_empty()
8178                    });
8179                    if has_edges {
8180                        return Err(GraphError::QueryError {
8181                            detail: format!(
8182                                "Cannot delete node `{key}` because it still has incident edges. \
8183                                 Use DETACH DELETE to remove the node and all its edges."
8184                            ),
8185                        });
8186                    }
8187                }
8188            }
8189        }
8190
8191        let mut nodes_deleted = 0i64;
8192        let mut edges_deleted = 0i64;
8193        for key in keys {
8194            match self.delete_node(&key) {
8195                Ok(report) => {
8196                    nodes_deleted += 1;
8197                    edges_deleted += (report.manual_edges + report.derived_edges) as i64;
8198                }
8199                Err(GraphError::KeyNotFound { .. }) => {
8200                    // Node may have been deleted by an earlier iteration (e.g., via
8201                    // multiple MATCH rows for the same node).  Safe to skip.
8202                }
8203                Err(e) => return Err(e),
8204            }
8205        }
8206
8207        let mut rs = write_result_set();
8208        rs.push_row(vec![
8209            Some(Value::Int(0)),
8210            Some(Value::Int(0)),
8211            Some(Value::Int(nodes_deleted + edges_deleted)),
8212        ]);
8213        Ok(rs)
8214    }
8215
8216    fn exec_merge(
8217        &mut self,
8218        stmt: core_query::cypher::MergeStmt,
8219        params: &BTreeMap<String, Value>,
8220    ) -> Result<ResultSet> {
8221        // MERGE: check if a node with the given key already exists.
8222        let key = match &stmt.key_value {
8223            Value::Str(s) => s.clone(),
8224            _ => {
8225                return Err(GraphError::QueryError {
8226                    detail: format!(
8227                        "MERGE key value must be a string (got {:?})",
8228                        stmt.key_value
8229                    ),
8230                })
8231            }
8232        };
8233
8234        if let Some(var) = stmt.var.as_deref() {
8235            for sc in stmt.on_create.iter().chain(&stmt.on_match) {
8236                if sc.var != var {
8237                    return Err(GraphError::QueryError {
8238                        detail: format!(
8239                            "SET variable '{}' does not match MERGE variable '{var}'",
8240                            sc.var
8241                        ),
8242                    });
8243                }
8244            }
8245        }
8246
8247        // ── MERGE authz pre-check (when role-scoped) ─────────────────────────
8248        //
8249        // MERGE scope precondition: check create OR update scope for the
8250        // declared label BEFORE calling `has_node` (timing-oracle closure,
8251        // spec §6.2 "MERGE visibility oracle" item: hidden ≡ absent for
8252        // unscoped roles — the scope denial fires without touching the key store).
8253        //
8254        // Clone to avoid holding a borrow on `self.pending_write_authz` while
8255        // also calling `self.ids.get(key)`.
8256        let merge_existed: bool = if let Some(authz) = self.pending_write_authz.clone() {
8257            let has_create = authz.scope.create_labels.contains(&stmt.label);
8258            let has_update = authz.scope.update_labels.contains(&stmt.label);
8259            if !has_create && !has_update {
8260                // Scope-before-lookup: 403 without has_node call (timing oracle
8261                // closure — see test_merge_unscoped_no_key_lookup).
8262                return Err(GraphError::RoleWriteDenied {
8263                    reason: format!(
8264                        "role-bound token: label '{}' not in write scope (create_labels)",
8265                        stmt.label
8266                    ),
8267                });
8268            }
8269            // Key lookup under mask.
8270            match self.ids.get(key.as_str()) {
8271                Some(id) if authz.mask.contains_id(id) => {
8272                    // Visible: must have update scope to proceed to match arm.
8273                    if !has_update {
8274                        return Err(GraphError::RoleWriteDenied {
8275                            reason: format!(
8276                                "role-bound token: label '{}' not in write scope (update_labels)",
8277                                stmt.label
8278                            ),
8279                        });
8280                    }
8281                    true // existed = true → match arm
8282                }
8283                Some(_) => {
8284                    // Hidden: same error as absent to the role (spec §3.1/§3.3).
8285                    return Err(GraphError::RoleWriteDenied {
8286                        reason: "role-bound token: target node not visible".into(),
8287                    });
8288                }
8289                None => {
8290                    // Absent: must have create scope to proceed to the create arm.
8291                    //
8292                    // Update-only roles (create_labels empty, update_labels set):
8293                    // return the SAME "not visible" error as the hidden-key branch
8294                    // so hidden ≡ absent — no distinguishing oracle (spec §6.1
8295                    // "confirm existence of hidden nodes: No").
8296                    //
8297                    // Create-scoped roles (has_create=true): absent → create arm
8298                    // as before.  The accepted structural key-existence disclosure
8299                    // (§THREAT-MODEL) applies only when the role holds create scope.
8300                    if !has_create {
8301                        return Err(GraphError::RoleWriteDenied {
8302                            reason: "role-bound token: target node not visible".into(),
8303                        });
8304                    }
8305                    false // existed = false → create arm
8306                }
8307            }
8308        } else {
8309            // Full authority: use the existing non-masked has_node check.
8310            self.has_node(&key)
8311        };
8312
8313        let existed = merge_existed;
8314        let mut created = 0i64;
8315        if !existed || !stmt.on_match.is_empty() {
8316            let mut batch = self.batch();
8317            if !existed {
8318                let props = vec![(stmt.key_field.clone(), stmt.key_value.clone())];
8319                batch.insert_node(&stmt.label, &key, props);
8320                for sc in &stmt.on_create {
8321                    let value = resolve_merge_set_value(&sc.value, params)?;
8322                    batch.set_prop(&key, &sc.field, value);
8323                }
8324                created = 1;
8325            } else {
8326                for sc in &stmt.on_match {
8327                    let value = resolve_merge_set_value(&sc.value, params)?;
8328                    batch.set_prop(&key, &sc.field, value);
8329                }
8330            }
8331            batch.commit()?;
8332        }
8333
8334        // Refresh the role mask so the just-created node is visible to this
8335        // statement's RETURN (read-after-write). Safe: create_labels ⊆ read labels
8336        // (apply_schema subset rule), so the new node's label is already in the
8337        // role's read scope — this never widens beyond the role's declared labels.
8338        if !existed {
8339            if let Some(role) = self.pending_write_authz.as_ref().map(|a| a.role.clone()) {
8340                let new_mask = self.mask_for_role(&role)?;
8341                if let Some(a) = self.pending_write_authz.as_mut() {
8342                    a.mask = new_mask;
8343                }
8344            }
8345        }
8346
8347        // Optional RETURN clause: project the node (created or matched) as a read result.
8348        if let Some(returns) = stmt.returns {
8349            let var = stmt.var.as_deref().unwrap_or("_mn0");
8350            let q = Query {
8351                matches: vec![Pattern {
8352                    start: NodePat {
8353                        var: Some(var.to_string()),
8354                        label: Some(stmt.label.clone()),
8355                        props: vec![("id".to_string(), Operand::Lit(stmt.key_value.clone()))],
8356                    },
8357                    chain: vec![],
8358                    shortest: false,
8359                }],
8360                optional_clauses: vec![],
8361                where_expr: None,
8362                unwinds: vec![],
8363                post_unwind_where: None,
8364                stages: vec![],
8365                returns,
8366                distinct: false,
8367                order_by: vec![],
8368                skip: None,
8369                limit: None,
8370            };
8371            let ops = plan(&q).map_err(|e| GraphError::QueryError {
8372                detail: format!("plan: {e}"),
8373            })?;
8374            // Use view_masked when a role-scoped write is in flight so the
8375            // post-merge projection is consistent with the masked read phase.
8376            let mask_opt = self.pending_write_authz.as_ref().map(|a| a.mask.clone());
8377            return (if let Some(ref mask) = mask_opt {
8378                execute(&self.view_masked(mask), &ops, &Params(params))
8379            } else {
8380                execute(&self.view(), &ops, &Params(params))
8381            })
8382            .map_err(|e| GraphError::QueryError {
8383                detail: format!("execute: {e}"),
8384            });
8385        }
8386
8387        let mut rs = write_result_set();
8388        rs.push_row(vec![
8389            Some(Value::Int(created)),
8390            Some(Value::Int(0)),
8391            Some(Value::Int(0)),
8392        ]);
8393        Ok(rs)
8394    }
8395
8396    /// Return all rule-owned edges between `key_a` and `key_b` (either direction),
8397    /// annotated with rule name, edge type, direction, and weight.
8398    /// Results are sorted by (rule, edge_type).
8399    /// Returns `Err(KeyNotFound)` if either key is unknown.
8400    pub fn explain(&self, key_a: &str, key_b: &str) -> Result<Vec<Explanation>> {
8401        self.ensure_v8_base_sections_loaded();
8402        let id_a = self
8403            .ids
8404            .get(key_a)
8405            .ok_or_else(|| GraphError::KeyNotFound { key: key_a.into() })?;
8406        let id_b = self
8407            .ids
8408            .get(key_b)
8409            .ok_or_else(|| GraphError::KeyNotFound { key: key_b.into() })?;
8410
8411        let mut results = Vec::new();
8412
8413        // Walk the smaller incident set so explain is O(min(deg(a), deg(b)))
8414        // rather than O(total provenance).
8415        let scan = if self.engine.provenance_touching_len(id_a)
8416            <= self.engine.provenance_touching_len(id_b)
8417        {
8418            id_a
8419        } else {
8420            id_b
8421        };
8422        for (rule_name, etype, src, dst) in self.engine.provenance_touching(scan) {
8423            if !((src == id_a && dst == id_b) || (src == id_b && dst == id_a)) {
8424                continue;
8425            }
8426            let Some(rule_def) = self.engine.rules().find(|r| r.name == rule_name) else {
8427                continue;
8428            };
8429            let edge_type = match self.syms.resolve(etype) {
8430                Some(s) => s.to_string(),
8431                None => continue,
8432            };
8433            // Provenance (src, dst) ids come from the archived PROVENANCE section
8434            // (large, no eager CRC).  A corrupt section can produce ids that are
8435            // out of range; return Corrupt rather than panic.
8436            let src_key = self
8437                .ids
8438                .key_of(src)
8439                .ok_or_else(|| GraphError::Corrupt {
8440                    detail: format!("v8: provenance src id {src} not in id table"),
8441                })?
8442                .to_string();
8443            let dst_key = self
8444                .ids
8445                .key_of(dst)
8446                .ok_or_else(|| GraphError::Corrupt {
8447                    detail: format!("v8: provenance dst id {dst} not in id table"),
8448                })?
8449                .to_string();
8450            let stored = rule_def.weight_prop.as_deref().and_then(|prop| {
8451                self.edge_props_view()
8452                    .get(etype, src, dst, prop)
8453                    .and_then(|v| {
8454                        if let Value::Float(f) = v {
8455                            Some(f)
8456                        } else {
8457                            None
8458                        }
8459                    })
8460            });
8461            // Rules that store no weight (KeyMatch/FieldEqual defaults, auto-FK)
8462            // still have a score: recompute it from the predicate so explain
8463            // never reports "no score" for an edge the engine scored.  Via-hop
8464            // rules score over their via set, not over (src, dst), so leave
8465            // those None rather than report a number the rule did not produce.
8466            let weight = stored.or_else(|| {
8467                if rule_def.via_edge.is_some() {
8468                    return None;
8469                }
8470                let props_view = build_props_view(&self.props, &self.base);
8471                let src_get = |field: &str| props_view.get(src, field).map(|vr| vr.into_value());
8472                let dst_get = |field: &str| props_view.get(dst, field).map(|vr| vr.into_value());
8473                let src_view = NodeView {
8474                    key: &src_key,
8475                    props: &src_get,
8476                };
8477                let dst_view = NodeView {
8478                    key: &dst_key,
8479                    props: &dst_get,
8480                };
8481                evaluate(&rule_def.predicate, &src_view, &dst_view)
8482            });
8483            results.push(Explanation {
8484                rule: rule_name.to_string(),
8485                edge_type,
8486                src_key,
8487                dst_key,
8488                weight,
8489                predicate: PredicateSummary {
8490                    approximate: rule_def.approximate,
8491                    ..PredicateSummary::from(&rule_def.predicate)
8492                },
8493                via_edge: rule_def.via_edge.clone(),
8494            });
8495        }
8496
8497        results.sort_by(|a, b| a.rule.cmp(&b.rule).then(a.edge_type.cmp(&b.edge_type)));
8498        Ok(results)
8499    }
8500
8501    pub fn neighbors(&self, key: &str, edge_type: &str, dir: Direction) -> Result<Vec<String>> {
8502        let id = self
8503            .ids
8504            .get(key)
8505            .ok_or_else(|| GraphError::KeyNotFound { key: key.into() })?;
8506        let Some(sym) = self.syms.get(edge_type) else {
8507            return Ok(Vec::new());
8508        };
8509        self.topo_view()
8510            .neighbors(sym, dir, id)
8511            .iter()
8512            .map(|&n| {
8513                self.ids
8514                    .key_of(n)
8515                    .map(|k| k.to_string())
8516                    .ok_or_else(|| GraphError::Corrupt {
8517                        detail: format!("topology id {n} has no key"),
8518                    })
8519            })
8520            .collect::<Result<Vec<_>>>()
8521    }
8522
8523    /// Return the last-change commit sequence for `key`, or `None` if the node
8524    /// does not exist or has never been mutated since the last V5-V7 snapshot
8525    /// (horizon-bounded for legacy stores).
8526    ///
8527    /// The returned sequence is a monotonically increasing counter that starts
8528    /// at 1 for the first commit after `open` and increments with every
8529    /// successful write.  WAL replay at open also assigns sequences (1..N for N
8530    /// replayed frames), so sequences are consistent across snapshot+WAL cycles.
8531    ///
8532    /// For V5-V7 stores opened without a V8 snapshot, nodes that were present
8533    /// in the snapshot but not touched by any WAL frame will return `None`
8534    /// (horizon-bounded: CAS against such nodes is only safe after the first
8535    /// V8 snapshot or after the node is next mutated).
8536    pub fn last_changed(&self, key: &str) -> Option<u64> {
8537        let id = self.ids.get(key)?;
8538        self.last_change.get(&id).copied()
8539    }
8540
8541    /// The current commit sequence (number of successful commits since open,
8542    /// including WAL replay frames).  Useful for recording a baseline before
8543    /// a read-modify-write cycle.
8544    pub fn commit_seq(&self) -> u64 {
8545        self.commit_seq
8546    }
8547
8548    /// Check that all `preconds` are satisfied against the current db state.
8549    /// Returns `Err(GraphError::CasConflict)` on the first failing precondition.
8550    pub(crate) fn check_preconditions(&self, preconds: &[Precondition]) -> Result<()> {
8551        for precond in preconds {
8552            match precond {
8553                Precondition::NodeUnchangedSince { key, expected } => {
8554                    // Missing entry means the node predates the WAL window or
8555                    // does not exist; treat as 0 (before any commit).
8556                    let actual = self.last_changed(key).unwrap_or_default();
8557                    if actual != *expected {
8558                        return Err(GraphError::CasConflict {
8559                            key: key.clone(),
8560                            expected: *expected,
8561                            actual,
8562                        });
8563                    }
8564                }
8565                Precondition::NodeAbsent { key } => {
8566                    // Node must not exist (not live).
8567                    if self.ids.get(key).is_some() {
8568                        let actual = self.last_changed(key).unwrap_or(0);
8569                        return Err(GraphError::CasConflict {
8570                            key: key.clone(),
8571                            expected: u64::MAX,
8572                            actual,
8573                        });
8574                    }
8575                }
8576            }
8577        }
8578        Ok(())
8579    }
8580
8581    /// Apply a batch of mutations with compare-and-set preconditions.
8582    ///
8583    /// All preconditions are checked atomically before any operation is applied.
8584    /// If any precondition fails, the entire batch is rejected with
8585    /// [`GraphError::CasConflict`] and no WAL frame is written.
8586    ///
8587    /// # Returns
8588    /// `(nodes_inserted, edges_inserted)` on success, same as [`write_batch`].
8589    ///
8590    /// # Errors
8591    /// - [`GraphError::CasConflict`] if any precondition is not satisfied.
8592    /// - Any error that [`write_batch`] would return for the ops themselves.
8593    pub fn write_batch_cas(
8594        &mut self,
8595        preconds: Vec<Precondition>,
8596        ops: Vec<BatchOp>,
8597    ) -> Result<(usize, usize)> {
8598        self.check_preconditions(&preconds)?;
8599        self.commit_logged_batch(ops, None, None)
8600    }
8601
8602    /// Update the per-node last-change map for a WAL record at commit `seq`.
8603    ///
8604    /// Called after a successful apply to record which nodes were touched.
8605    /// For replay, called with the WAL-frame's replayed seq.
8606    ///
8607    /// Touch definition (see [`Precondition`] doc):
8608    /// - InsertNode / InsertNodeId / SetProp / SetPropId / RemoveProp → the node.
8609    /// - InsertEdge / InsertEdgeId / DeleteEdge → both src and dst.
8610    /// - DeleteNode → node tombstoned; last_changed() returns None so no update needed.
8611    /// - DerivedEdge markers, Intern, rule/view records → no-ops.
8612    /// - Batch → recurse into inner records.
8613    fn update_last_change_from_rec(&mut self, rec: &WalRecord, seq: u64) {
8614        match rec {
8615            WalRecord::InsertNode { key, .. }
8616            | WalRecord::SetProp { key, .. }
8617            | WalRecord::RemoveProp { key, .. } => {
8618                if let Some(id) = self.ids.get(key) {
8619                    self.last_change.insert(id, seq);
8620                }
8621            }
8622            WalRecord::InsertNodeId { key, .. } => {
8623                if let Some(id) = self.ids.get(key) {
8624                    self.last_change.insert(id, seq);
8625                }
8626            }
8627            WalRecord::SetPropId { id, .. } => {
8628                self.last_change.insert(*id, seq);
8629            }
8630            WalRecord::InsertEdge {
8631                src_key, dst_key, ..
8632            }
8633            | WalRecord::DeleteEdge {
8634                src_key, dst_key, ..
8635            } => {
8636                if let Some(src_id) = self.ids.get(src_key) {
8637                    self.last_change.insert(src_id, seq);
8638                }
8639                if let Some(dst_id) = self.ids.get(dst_key) {
8640                    self.last_change.insert(dst_id, seq);
8641                }
8642            }
8643            WalRecord::InsertEdgeId { src, dst, .. } => {
8644                self.last_change.insert(*src, seq);
8645                self.last_change.insert(*dst, seq);
8646            }
8647            // DeleteNode: node is tombstoned; last_changed(key) returns None for
8648            // deleted keys (ids.get() returns None post-tombstone), so no update needed.
8649            // History markers: state no-ops; the underlying mutation already
8650            // touched the relevant nodes' last_change entries.
8651            WalRecord::DeleteNode { .. }
8652            | WalRecord::DerivedEdgeAdded { .. }
8653            | WalRecord::DerivedEdgeRetracted { .. }
8654            | WalRecord::Intern { .. }
8655            | WalRecord::CreateRule { .. }
8656            | WalRecord::DeleteRule { .. }
8657            | WalRecord::RebuildRule { .. }
8658            | WalRecord::CreateView { .. }
8659            | WalRecord::DeleteView { .. }
8660            | WalRecord::EnableFulltext { .. }
8661            | WalRecord::DisableFulltext { .. }
8662            | WalRecord::EnableIndex { .. }
8663            | WalRecord::DisableIndex { .. } => {}
8664            // RenameNode: node id is stable; update last_change via the new key.
8665            // Called after apply(), so ids already reflects new_key.
8666            WalRecord::RenameNode { new_key, .. } => {
8667                if let Some(id) = self.ids.get(new_key) {
8668                    self.last_change.insert(id, seq);
8669                }
8670            }
8671            WalRecord::Batch(inner) => {
8672                for inner_rec in inner {
8673                    self.update_last_change_from_rec(inner_rec, seq);
8674                }
8675            }
8676        }
8677    }
8678
8679    pub fn node_count(&self) -> usize {
8680        self.ids.len()
8681    }
8682
8683    /// Configure archive retention: keep the `N` newest WAL archives at each
8684    /// [`snapshot_with`] call when `archive_wal: true`.
8685    ///
8686    /// `Some(N)` where N > 0 → prune oldest archives keeping the newest N.
8687    /// `Some(0)` or `None` → unlimited (no pruning).
8688    ///
8689    /// Pruning only ever happens inside [`snapshot_with`]; this method only
8690    /// stores the policy.  Archives below the retention limit are deleted
8691    /// oldest-first.  The horizon floor is updated so that
8692    /// [`was_linked`] / history APIs return `CommitOutOfRange` for commits
8693    /// in pruned archives rather than silently returning wrong data.
8694    pub fn set_wal_archive_retention(&mut self, keep: Option<u32>) {
8695        self.wal_archive_retention = keep;
8696    }
8697
8698    /// Delete any WAL archives that are fully below the current horizon floor.
8699    ///
8700    /// Orphaned archives arise when the floor is written first during retention
8701    /// pruning and then a crash interrupts the archive-delete sequence.  The
8702    /// opening cleanup ensures no subsequent read path sees stale data.
8703    ///
8704    /// Under the monotonic naming scheme, the archive name N equals the
8705    /// cumulative end-frame index of the archive in global commit space (i.e.
8706    /// the archive covers global frames `[prev_n, N)`).  An archive is
8707    /// fully orphaned when `N <= wal_horizon_floor`: all of its frames fall
8708    /// below the floor and have already been counted in it.
8709    fn cleanup_orphaned_archives(&mut self) -> Result<()> {
8710        if self.wal_horizon_floor == 0 {
8711            // Floor at 0 means no pruning has ever occurred; nothing to clean.
8712            return Ok(());
8713        }
8714        let archive_ns = self.fs.list_archives()?;
8715        for n in archive_ns {
8716            if n <= self.wal_horizon_floor {
8717                // Archive N ends at global frame N; all its frames are below
8718                // the floor (floor already accounts for them) → orphaned.
8719                self.fs.delete_archive(n).map_err(GraphError::Io)?;
8720            } else {
8721                // Archives are sorted ascending; first one above floor stops scan.
8722                break;
8723            }
8724        }
8725        Ok(())
8726    }
8727
8728    /// Collect all WAL frames from surviving archives (oldest-first) then the
8729    /// live WAL into one flat list, and return the total along with the number
8730    /// of archive frames at the front of the list.
8731    ///
8732    /// Commit indices into the returned list are LOCAL (0 = first frame of
8733    /// oldest surviving archive).  To obtain the GLOBAL index add
8734    /// `self.wal_horizon_floor`.
8735    fn all_frames(&self) -> Result<(Vec<WalRecord>, u64)> {
8736        let archive_ns = self.fs.list_archives()?;
8737        let mut all: Vec<WalRecord> = Vec::new();
8738        for n in archive_ns {
8739            let bytes = self.fs.read_archive(n)?;
8740            let (frames, _) = decode_all(&bytes);
8741            all.extend(frames);
8742        }
8743        let archive_count = all.len() as u64;
8744        let live_bytes = self.fs.read(FileId::Wal)?;
8745        let (live_frames, _) = decode_all(&live_bytes);
8746        all.extend(live_frames);
8747        Ok((all, archive_count))
8748    }
8749
8750    /// Return the total number of committed WAL frames visible in the current
8751    /// horizon window, including frames in surviving WAL archives.
8752    ///
8753    /// This is the exclusive upper bound for valid `at_commit` indices in
8754    /// `was_linked`. Valid indices are `wal_horizon_floor()..wal_total_commits()`.
8755    ///
8756    /// Returns the horizon floor when all surviving history is empty.
8757    pub fn wal_total_commits(&self) -> Result<u64> {
8758        let (frames, _) = self.all_frames()?;
8759        Ok(self.wal_horizon_floor + frames.len() as u64)
8760    }
8761
8762    /// The global frame index of the first commit reachable through surviving
8763    /// archives (0 when no archives have been pruned).
8764    pub fn wal_horizon_floor(&self) -> u64 {
8765        self.wal_horizon_floor
8766    }
8767
8768    /// Return the per-node change history for `key` by scanning the on-disk WAL.
8769    ///
8770    /// ## Horizon
8771    ///
8772    /// History reaches back only to the last WAL-truncating snapshot, exactly like `open_at`.
8773    /// Snapshots written with `keep_wal: true` preserve deeper history. This is the honest,
8774    /// zero-cost contract; a durable history log is out of scope.
8775    ///
8776    /// ## Derived edges
8777    ///
8778    /// Rule-created (derived) edges are **not** in the WAL and therefore do not appear in
8779    /// history. Only edges written directly by the application are recorded.
8780    ///
8781    /// ## Deleted nodes
8782    ///
8783    /// For nodes that have been deleted, dense-id records (SetPropId, InsertEdgeId) that
8784    /// predate the deletion may not resolve (the id is tombstoned in the live map). The
8785    /// string-keyed `DeleteNode` record still matches and produces a `NodeDeleted` entry.
8786    /// Prop/edge history of a deleted node may therefore be partially unresolvable.
8787    ///
8788    /// ## Dense-id edge entries and tombstoned partners
8789    ///
8790    /// Edge entries from dense-id WAL records (`InsertEdgeId`) are omitted when the partner
8791    /// endpoint's dense id is tombstoned. As a result, a live node's history can contain an
8792    /// `EdgeRemoved` (string-keyed, always resolves) without a corresponding `EdgeAdded`.
8793    /// Build commit-bounded alias intervals for `queried_key`.
8794    ///
8795    /// Returns a list of `(key, valid_from_inclusive, valid_until_exclusive)` tuples.
8796    /// A record written under `key` at commit `c` matches the queried identity iff
8797    /// `c >= valid_from && (valid_until.is_none() || c < valid_until)`.
8798    ///
8799    /// Each alias entry carries both a lower and an upper bound so that key-reuse
8800    /// after a rename is handled correctly: if "a" is renamed to "b" at commit 5,
8801    /// then a NEW node is created as "a" at commit 7 and renamed to "c" at commit 10,
8802    /// querying "c" must NOT surface identity-1's events (commits 0–4 under "a");
8803    /// only identity-2's events (commits 7–9 under "a") are in scope.
8804    ///
8805    /// Only **forward aliasing**: querying the *new* key surfaces events written
8806    /// under the *old* key.  The reverse direction is not supported.
8807    fn build_key_alias_intervals(
8808        &self,
8809        frames: &[core_storage::wal::WalRecord],
8810        queried_key: &str,
8811    ) -> Vec<(String, u64, Option<u64>)> {
8812        use core_storage::wal::WalRecord;
8813
8814        // Pre-pass: build reverse_rename and key_starts maps.
8815        let mut reverse_rename: HashMap<String, (String, u64)> = HashMap::new();
8816        let mut key_starts: HashMap<String, Vec<u64>> = HashMap::new();
8817
8818        for (local_i, frame) in frames.iter().enumerate() {
8819            let commit = self.wal_horizon_floor + local_i as u64;
8820            let records: &[WalRecord] = match frame {
8821                WalRecord::Batch(inner) => inner.as_slice(),
8822                single => std::slice::from_ref(single),
8823            };
8824            for rec in records {
8825                match rec {
8826                    WalRecord::InsertNode { key, .. } | WalRecord::InsertNodeId { key, .. } => {
8827                        key_starts.entry(key.clone()).or_default().push(commit);
8828                    }
8829                    WalRecord::RenameNode { old_key, new_key } => {
8830                        // new_key came into existence at this commit.
8831                        key_starts.entry(new_key.clone()).or_default().push(commit);
8832                        // Record the reverse rename: new_key was introduced by renaming old_key.
8833                        reverse_rename.insert(new_key.clone(), (old_key.clone(), commit));
8834                    }
8835                    _ => {}
8836                }
8837            }
8838        }
8839
8840        // Build alias intervals by following the reverse rename chain.
8841        let mut result: Vec<(String, u64, Option<u64>)> = Vec::new();
8842        let mut current_key = queried_key.to_string();
8843        let mut current_valid_until: Option<u64> = None;
8844
8845        loop {
8846            // valid_from: the most recent commit where current_key was assigned to this
8847            // identity.  For aliases (valid_until = Some(vu)), find the last start event
8848            // for the key strictly before vu — this is where the alias's occupancy by
8849            // this identity began, correctly excluding prior identities that reused the key.
8850            let valid_from = if let Some(vu) = current_valid_until {
8851                key_starts
8852                    .get(&current_key)
8853                    .and_then(|starts| starts.iter().rev().find(|&&s| s < vu).copied())
8854                    .unwrap_or(self.wal_horizon_floor)
8855            } else {
8856                // Queried key — no upper bound; may have been introduced at any commit.
8857                self.wal_horizon_floor
8858            };
8859
8860            result.push((current_key.clone(), valid_from, current_valid_until));
8861
8862            match reverse_rename.get(&current_key) {
8863                Some((old_key, rename_commit)) => {
8864                    current_valid_until = Some(*rename_commit);
8865                    current_key = old_key.clone();
8866                }
8867                None => break,
8868            }
8869        }
8870
8871        result
8872    }
8873
8874    /// Returns true if `record_key` matches any alias interval that covers `commit`.
8875    fn aliases_match(
8876        intervals: &[(String, u64, Option<u64>)],
8877        record_key: &str,
8878        commit: u64,
8879    ) -> bool {
8880        intervals
8881            .iter()
8882            .any(|(k, vf, vu)| k == record_key && commit >= *vf && vu.is_none_or(|u| commit < u))
8883    }
8884
8885    pub fn node_history(&self, key: &str) -> Result<Vec<crate::history::HistoryEntry>> {
8886        use crate::history::{HistoryChange, HistoryEntry};
8887        use core_storage::wal::WalRecord;
8888
8889        let (frames, _) = self.all_frames()?;
8890
8891        // Resolve commit-bounded alias intervals for `key` (handles renames in the WAL).
8892        let alias_intervals = self.build_key_alias_intervals(&frames, key);
8893
8894        let mut out: Vec<HistoryEntry> = Vec::new();
8895
8896        for (local_i, frame) in frames.iter().enumerate() {
8897            let commit = self.wal_horizon_floor + local_i as u64;
8898            // Collect the inner records to process — Batch is one commit, single records are one commit.
8899            let records: &[WalRecord] = match frame {
8900                WalRecord::Batch(inner) => inner.as_slice(),
8901                single => std::slice::from_ref(single),
8902            };
8903
8904            for rec in records {
8905                let change = match rec {
8906                    WalRecord::InsertNode { label, key: k, .. }
8907                        if Self::aliases_match(&alias_intervals, k, commit) =>
8908                    {
8909                        Some(HistoryChange::NodeInserted {
8910                            label: label.clone(),
8911                        })
8912                    }
8913                    WalRecord::InsertNodeId { label, key: k, .. }
8914                        if Self::aliases_match(&alias_intervals, k, commit) =>
8915                    {
8916                        let label_str = match self.syms.resolve(*label) {
8917                            Some(s) => s.to_string(),
8918                            None => continue,
8919                        };
8920                        Some(HistoryChange::NodeInserted { label: label_str })
8921                    }
8922                    WalRecord::SetProp {
8923                        key: k,
8924                        field,
8925                        value,
8926                    } if Self::aliases_match(&alias_intervals, k, commit) => {
8927                        Some(HistoryChange::PropSet {
8928                            field: field.clone(),
8929                            value: value.clone(),
8930                        })
8931                    }
8932                    WalRecord::SetPropId { id, field, value } => match self.ids.key_of(*id) {
8933                        // key_of returns the current (post-rename) key; compare to queried key.
8934                        Some(resolved) if resolved == key => {
8935                            let field_str = match self.syms.resolve(*field) {
8936                                Some(s) => s.to_string(),
8937                                None => continue,
8938                            };
8939                            Some(HistoryChange::PropSet {
8940                                field: field_str,
8941                                value: value.clone(),
8942                            })
8943                        }
8944                        _ => None,
8945                    },
8946                    WalRecord::RemoveProp { key: k, field }
8947                        if Self::aliases_match(&alias_intervals, k, commit) =>
8948                    {
8949                        Some(HistoryChange::PropRemoved {
8950                            field: field.clone(),
8951                        })
8952                    }
8953                    WalRecord::InsertEdge {
8954                        edge_type,
8955                        src_key,
8956                        dst_key,
8957                    } => {
8958                        if Self::aliases_match(&alias_intervals, src_key, commit) {
8959                            Some(HistoryChange::EdgeAdded {
8960                                edge_type: edge_type.clone(),
8961                                other: dst_key.clone(),
8962                                outgoing: true,
8963                            })
8964                        } else if Self::aliases_match(&alias_intervals, dst_key, commit) {
8965                            Some(HistoryChange::EdgeAdded {
8966                                edge_type: edge_type.clone(),
8967                                other: src_key.clone(),
8968                                outgoing: false,
8969                            })
8970                        } else {
8971                            None
8972                        }
8973                    }
8974                    WalRecord::InsertEdgeId { etype, src, dst } => {
8975                        let etype_str = match self.syms.resolve(*etype) {
8976                            Some(s) => s.to_string(),
8977                            None => continue,
8978                        };
8979                        let src_key = self.ids.key_of(*src);
8980                        let dst_key = self.ids.key_of(*dst);
8981                        if src_key == Some(key) {
8982                            let other = match dst_key {
8983                                Some(s) => s.to_string(),
8984                                None => continue,
8985                            };
8986                            Some(HistoryChange::EdgeAdded {
8987                                edge_type: etype_str,
8988                                other,
8989                                outgoing: true,
8990                            })
8991                        } else if dst_key == Some(key) {
8992                            let other = match src_key {
8993                                Some(s) => s.to_string(),
8994                                None => continue,
8995                            };
8996                            Some(HistoryChange::EdgeAdded {
8997                                edge_type: etype_str,
8998                                other,
8999                                outgoing: false,
9000                            })
9001                        } else {
9002                            None
9003                        }
9004                    }
9005                    WalRecord::DeleteEdge {
9006                        edge_type,
9007                        src_key,
9008                        dst_key,
9009                    } => {
9010                        if Self::aliases_match(&alias_intervals, src_key, commit) {
9011                            Some(HistoryChange::EdgeRemoved {
9012                                edge_type: edge_type.clone(),
9013                                other: dst_key.clone(),
9014                                outgoing: true,
9015                            })
9016                        } else if Self::aliases_match(&alias_intervals, dst_key, commit) {
9017                            Some(HistoryChange::EdgeRemoved {
9018                                edge_type: edge_type.clone(),
9019                                other: src_key.clone(),
9020                                outgoing: false,
9021                            })
9022                        } else {
9023                            None
9024                        }
9025                    }
9026                    WalRecord::DeleteNode { key: k }
9027                        if Self::aliases_match(&alias_intervals, k, commit) =>
9028                    {
9029                        Some(HistoryChange::NodeDeleted)
9030                    }
9031                    // Skip: rule/view/fulltext/intern metadata; Batch wrapper handled above.
9032                    _ => None,
9033                };
9034
9035                if let Some(change) = change {
9036                    out.push(HistoryEntry { commit, change });
9037                }
9038            }
9039        }
9040
9041        Ok(out)
9042    }
9043
9044    /// Return the per-edge change history between nodes `a` and `b` by scanning
9045    /// the on-disk WAL.
9046    ///
9047    /// ## Horizon
9048    ///
9049    /// History reaches back only to the last WAL-truncating snapshot, exactly
9050    /// like `node_history` and `open_at`. The returned [`HistoryResult`] carries
9051    /// `total_commits` (= number of WAL frames), which is the exclusive upper
9052    /// bound for valid commit indices.
9053    ///
9054    /// ## Derived edges
9055    ///
9056    /// Rule-derived edges appear via `DerivedEdgeAdded` / `DerivedEdgeRetracted`
9057    /// WAL markers written by `log_then_apply_with` after each rule-firing
9058    /// mutation. The `rule` field of those events carries the rule name.
9059    ///
9060    /// ## DeleteNode
9061    ///
9062    /// When a node is deleted, its manual incident edges are swept inline without
9063    /// individual `DeleteEdge` WAL records. `edge_history` detects `DeleteNode`
9064    /// events for either endpoint and synthesises `Retracted(rule:None)` events
9065    /// for each manual edge that was active at that point. Derived edges active at
9066    /// the time of deletion are handled by the `DerivedEdgeRetracted` marker that
9067    /// the engine appends immediately after the `DeleteNode` record; those events
9068    /// carry correct rule attribution and are emitted by the marker arm, not the
9069    /// synthetic sweep.
9070    ///
9071    /// ## Masks
9072    ///
9073    /// Like `node_history`, this method has no mask parameter and returns WAL
9074    /// history regardless of any role mask. For masked history semantics, apply
9075    /// the mask at the caller level.
9076    pub fn edge_history(
9077        &self,
9078        a: &str,
9079        b: &str,
9080    ) -> Result<crate::history::HistoryResult<crate::history::EdgeHistoryEvent>> {
9081        use crate::history::{EdgeEvent, EdgeHistoryEvent, HistoryResult};
9082        use core_storage::wal::WalRecord;
9083
9084        let (frames, _) = self.all_frames()?;
9085        let total_commits = self.wal_horizon_floor + frames.len() as u64;
9086
9087        // Resolve all historical names for a and b (handles RenameNode in the WAL).
9088        // Intervals are commit-bounded so recycled keys don't contaminate histories.
9089        let alias_a = self.build_key_alias_intervals(&frames, a);
9090        let alias_b = self.build_key_alias_intervals(&frames, b);
9091
9092        // Active edges between a and b tracked as (edge_type, src_key, dst_key, is_derived).
9093        // The is_derived flag is used by the DeleteNode sweep: manual edges are
9094        // swept with a synthetic Retracted(rule:None); derived edges are skipped
9095        // because the engine writes a DerivedEdgeRetracted marker immediately after
9096        // the DeleteNode record, which carries the correct rule attribution.
9097        let mut active: Vec<(String, String, String, bool)> = Vec::new();
9098        let mut out: Vec<EdgeHistoryEvent> = Vec::new();
9099
9100        for (local_i, frame) in frames.iter().enumerate() {
9101            let commit = self.wal_horizon_floor + local_i as u64;
9102            let records: &[WalRecord] = match frame {
9103                WalRecord::Batch(inner) => inner.as_slice(),
9104                single => std::slice::from_ref(single),
9105            };
9106
9107            for rec in records {
9108                match rec {
9109                    WalRecord::InsertEdge {
9110                        edge_type,
9111                        src_key,
9112                        dst_key,
9113                    } => {
9114                        let is_ab = Self::aliases_match(&alias_a, src_key, commit)
9115                            && Self::aliases_match(&alias_b, dst_key, commit);
9116                        let is_ba = Self::aliases_match(&alias_b, src_key, commit)
9117                            && Self::aliases_match(&alias_a, dst_key, commit);
9118                        if is_ab || is_ba {
9119                            active.push((
9120                                edge_type.clone(),
9121                                src_key.clone(),
9122                                dst_key.clone(),
9123                                false,
9124                            ));
9125                            out.push(EdgeHistoryEvent {
9126                                edge_type: edge_type.clone(),
9127                                commit,
9128                                event: EdgeEvent::Added,
9129                                rule: None,
9130                            });
9131                        }
9132                    }
9133                    WalRecord::InsertEdgeId { etype, src, dst } => {
9134                        let etype_str = match self.syms.resolve(*etype) {
9135                            Some(s) => s.to_string(),
9136                            None => continue,
9137                        };
9138                        // Use key_of_historical so tombstoned nodes (deleted
9139                        // later in the WAL) still resolve during the scan.
9140                        let src_key = self.ids.key_of_historical(*src);
9141                        let dst_key = self.ids.key_of_historical(*dst);
9142                        let is_ab = src_key == Some(a) && dst_key == Some(b);
9143                        let is_ba = src_key == Some(b) && dst_key == Some(a);
9144                        if is_ab || is_ba {
9145                            let src_str = src_key.unwrap().to_string();
9146                            let dst_str = dst_key.unwrap().to_string();
9147                            active.push((etype_str.clone(), src_str, dst_str, false));
9148                            out.push(EdgeHistoryEvent {
9149                                edge_type: etype_str,
9150                                commit,
9151                                event: EdgeEvent::Added,
9152                                rule: None,
9153                            });
9154                        }
9155                    }
9156                    WalRecord::DeleteEdge {
9157                        edge_type,
9158                        src_key,
9159                        dst_key,
9160                    } => {
9161                        let is_ab = Self::aliases_match(&alias_a, src_key, commit)
9162                            && Self::aliases_match(&alias_b, dst_key, commit);
9163                        let is_ba = Self::aliases_match(&alias_b, src_key, commit)
9164                            && Self::aliases_match(&alias_a, dst_key, commit);
9165                        if is_ab || is_ba {
9166                            // Remove the first matching active entry (flag ignored).
9167                            if let Some(pos) = active.iter().position(|(et, s, d, _)| {
9168                                et == edge_type && s == src_key && d == dst_key
9169                            }) {
9170                                active.remove(pos);
9171                            }
9172                            out.push(EdgeHistoryEvent {
9173                                edge_type: edge_type.clone(),
9174                                commit,
9175                                event: EdgeEvent::Retracted,
9176                                rule: None,
9177                            });
9178                        }
9179                    }
9180                    WalRecord::DeleteNode { key: k }
9181                        if Self::aliases_match(&alias_a, k, commit)
9182                            || Self::aliases_match(&alias_b, k, commit) =>
9183                    {
9184                        // Sweep: implicitly retract only MANUAL active edges.
9185                        // Derived active edges are skipped here because the rule
9186                        // engine appends a DerivedEdgeRetracted marker immediately
9187                        // after this DeleteNode record; that marker produces the
9188                        // single correctly-attributed Retracted event.  Derived
9189                        // entries are dropped from `active` (the marker arm's
9190                        // idempotent retain finds nothing to remove).
9191                        for (et, _, _, is_derived) in active.drain(..) {
9192                            if !is_derived {
9193                                out.push(EdgeHistoryEvent {
9194                                    edge_type: et,
9195                                    commit,
9196                                    event: EdgeEvent::Retracted,
9197                                    rule: None,
9198                                });
9199                            }
9200                            // Derived: drop silently; marker carries the Retracted event.
9201                        }
9202                    }
9203                    WalRecord::DerivedEdgeAdded {
9204                        rule,
9205                        edge_type: et,
9206                        src_key,
9207                        dst_key,
9208                    } => {
9209                        let is_ab = Self::aliases_match(&alias_a, src_key, commit)
9210                            && Self::aliases_match(&alias_b, dst_key, commit);
9211                        let is_ba = Self::aliases_match(&alias_b, src_key, commit)
9212                            && Self::aliases_match(&alias_a, dst_key, commit);
9213                        if is_ab || is_ba {
9214                            active.push((et.clone(), src_key.clone(), dst_key.clone(), true));
9215                            out.push(EdgeHistoryEvent {
9216                                edge_type: et.clone(),
9217                                commit,
9218                                event: EdgeEvent::Added,
9219                                rule: Some(rule.clone()),
9220                            });
9221                        }
9222                    }
9223                    WalRecord::DerivedEdgeRetracted {
9224                        rule,
9225                        edge_type: et,
9226                        src_key,
9227                        dst_key,
9228                    } => {
9229                        let is_ab = Self::aliases_match(&alias_a, src_key, commit)
9230                            && Self::aliases_match(&alias_b, dst_key, commit);
9231                        let is_ba = Self::aliases_match(&alias_b, src_key, commit)
9232                            && Self::aliases_match(&alias_a, dst_key, commit);
9233                        if is_ab || is_ba {
9234                            // Push unconditionally: a derived edge whose Added marker
9235                            // predates the history horizon has no `active` entry, but
9236                            // the retraction is still a real in-window event.
9237                            // Remove from active idempotently if present.
9238                            active.retain(|(aet, s, d, _)| {
9239                                !(aet == et && s == src_key && d == dst_key)
9240                            });
9241                            out.push(EdgeHistoryEvent {
9242                                edge_type: et.clone(),
9243                                commit,
9244                                event: EdgeEvent::Retracted,
9245                                rule: Some(rule.clone()),
9246                            });
9247                        }
9248                    }
9249                    // All other records (InsertNode, SetProp, CreateRule, etc.)
9250                    // do not affect edges between a and b.
9251                    _ => {}
9252                }
9253            }
9254        }
9255
9256        Ok(HistoryResult {
9257            items: out,
9258            total_commits,
9259        })
9260    }
9261
9262    /// Return `true` iff an edge of `edge_type` existed between `a` and `b`
9263    /// (in either direction) at the WAL commit `at_commit`.
9264    ///
9265    /// ## Horizon
9266    ///
9267    /// Valid commit indices are `0..total_commits` where `total_commits` is the
9268    /// number of WAL frames. An `at_commit >= total_commits` is outside the
9269    /// visible horizon and returns [`GraphError::CommitOutOfRange`].
9270    ///
9271    /// ## Derived edges
9272    ///
9273    /// Rule-derived edges are tracked via `DerivedEdgeAdded` / `DerivedEdgeRetracted`
9274    /// WAL markers appended at firing time (Task 1). `was_linked` reads these markers
9275    /// and therefore includes derived edges in its point-in-time evaluation,
9276    /// matching `edge_history`'s fidelity.
9277    pub fn was_linked(&self, a: &str, b: &str, edge_type: &str, at_commit: u64) -> Result<bool> {
9278        use core_storage::wal::WalRecord;
9279
9280        let (frames, _) = self.all_frames()?;
9281        let total_commits = self.wal_horizon_floor + frames.len() as u64;
9282
9283        // Horizon floor: commits in pruned archives are unreachable.
9284        if at_commit < self.wal_horizon_floor {
9285            return Err(GraphError::CommitOutOfRange {
9286                commit: at_commit,
9287                total: total_commits,
9288            });
9289        }
9290        if at_commit >= total_commits {
9291            return Err(GraphError::CommitOutOfRange {
9292                commit: at_commit,
9293                total: total_commits,
9294            });
9295        }
9296
9297        // Resolve all historical names for a and b (handles RenameNode in the WAL).
9298        // Intervals are commit-bounded so recycled keys don't contaminate point-in-time reads.
9299        let alias_a = self.build_key_alias_intervals(&frames, a);
9300        let alias_b = self.build_key_alias_intervals(&frames, b);
9301
9302        // Local index into surviving frames (0 = first frame of oldest archive).
9303        let local_commit = at_commit - self.wal_horizon_floor;
9304
9305        // Replay local frames 0..=local_commit, tracking active edges.
9306        let mut active: BTreeSet<(String, String, String)> = BTreeSet::new();
9307
9308        for (local_i, frame) in frames.iter().enumerate().take((local_commit + 1) as usize) {
9309            let commit = self.wal_horizon_floor + local_i as u64;
9310            let records: &[WalRecord] = match frame {
9311                WalRecord::Batch(inner) => inner.as_slice(),
9312                single => std::slice::from_ref(single),
9313            };
9314
9315            for rec in records {
9316                match rec {
9317                    WalRecord::InsertEdge {
9318                        edge_type: et,
9319                        src_key,
9320                        dst_key,
9321                    } => {
9322                        let is_ab = Self::aliases_match(&alias_a, src_key, commit)
9323                            && Self::aliases_match(&alias_b, dst_key, commit);
9324                        let is_ba = Self::aliases_match(&alias_b, src_key, commit)
9325                            && Self::aliases_match(&alias_a, dst_key, commit);
9326                        if is_ab || is_ba {
9327                            active.insert((et.clone(), src_key.clone(), dst_key.clone()));
9328                        }
9329                    }
9330                    WalRecord::InsertEdgeId { etype, src, dst } => {
9331                        let etype_str = match self.syms.resolve(*etype) {
9332                            Some(s) => s.to_string(),
9333                            None => continue,
9334                        };
9335                        // Use key_of_historical so tombstoned nodes resolve.
9336                        let src_key = self.ids.key_of_historical(*src);
9337                        let dst_key = self.ids.key_of_historical(*dst);
9338                        let is_ab = src_key == Some(a) && dst_key == Some(b);
9339                        let is_ba = src_key == Some(b) && dst_key == Some(a);
9340                        if is_ab || is_ba {
9341                            active.insert((
9342                                etype_str,
9343                                src_key.unwrap().to_string(),
9344                                dst_key.unwrap().to_string(),
9345                            ));
9346                        }
9347                    }
9348                    WalRecord::DeleteEdge {
9349                        edge_type: et,
9350                        src_key,
9351                        dst_key,
9352                    } => {
9353                        let is_ab = Self::aliases_match(&alias_a, src_key, commit)
9354                            && Self::aliases_match(&alias_b, dst_key, commit);
9355                        let is_ba = Self::aliases_match(&alias_b, src_key, commit)
9356                            && Self::aliases_match(&alias_a, dst_key, commit);
9357                        if is_ab || is_ba {
9358                            active.remove(&(et.clone(), src_key.clone(), dst_key.clone()));
9359                        }
9360                    }
9361                    WalRecord::DeleteNode { key: k }
9362                        if Self::aliases_match(&alias_a, k, commit)
9363                            || Self::aliases_match(&alias_b, k, commit) =>
9364                    {
9365                        // All edges touching the deleted node are gone.
9366                        active.retain(|(_, s, d)| s != k && d != k);
9367                    }
9368                    WalRecord::DerivedEdgeAdded {
9369                        edge_type: et,
9370                        src_key,
9371                        dst_key,
9372                        ..
9373                    } => {
9374                        let is_ab = Self::aliases_match(&alias_a, src_key, commit)
9375                            && Self::aliases_match(&alias_b, dst_key, commit);
9376                        let is_ba = Self::aliases_match(&alias_b, src_key, commit)
9377                            && Self::aliases_match(&alias_a, dst_key, commit);
9378                        if is_ab || is_ba {
9379                            active.insert((et.clone(), src_key.clone(), dst_key.clone()));
9380                        }
9381                    }
9382                    WalRecord::DerivedEdgeRetracted {
9383                        edge_type: et,
9384                        src_key,
9385                        dst_key,
9386                        ..
9387                    } => {
9388                        let is_ab = Self::aliases_match(&alias_a, src_key, commit)
9389                            && Self::aliases_match(&alias_b, dst_key, commit);
9390                        let is_ba = Self::aliases_match(&alias_b, src_key, commit)
9391                            && Self::aliases_match(&alias_a, dst_key, commit);
9392                        if is_ab || is_ba {
9393                            active.remove(&(et.clone(), src_key.clone(), dst_key.clone()));
9394                        }
9395                    }
9396                    _ => {}
9397                }
9398            }
9399        }
9400
9401        Ok(active.iter().any(|(et, _, _)| et == edge_type))
9402    }
9403
9404    pub fn edge_count(&self) -> u64 {
9405        self.topo_view().edge_count()
9406    }
9407
9408    /// Live/tombstone/edge counts plus per-rule provenance size, trip latch,
9409    /// and fire counter (includes rebuild evaluations). Rules are sorted by name.
9410    pub fn stats(&self) -> Stats {
9411        self.ensure_v8_base_sections_loaded();
9412        let rules: Vec<RuleStats> = self
9413            .engine
9414            .rules()
9415            .map(|r| RuleStats {
9416                name: r.name.clone(),
9417                edges: self
9418                    .engine
9419                    .provenance()
9420                    .get(&r.name)
9421                    .map(|s| s.len() as u64)
9422                    .unwrap_or(0),
9423                tripped: self.engine.is_tripped(&r.name),
9424                fires: self.engine.fire_count(&r.name),
9425                approximate: r.approximate,
9426            })
9427            .collect();
9428        Stats {
9429            nodes_live: self.ids.live_len(),
9430            nodes_tombstoned: self.ids.len() - self.ids.live_len(),
9431            edges: self.topo_view().edge_count(),
9432            rules,
9433            chain_truncations: self.engine.chain_truncations(),
9434        }
9435    }
9436
9437    /// On-disk size of the WAL file in bytes.
9438    ///
9439    /// Reads file metadata without loading WAL contents.  Returns `Err` for
9440    /// in-memory (`SimFs`) databases where no WAL file exists on disk.
9441    pub fn wal_size_bytes(&self) -> std::io::Result<u64> {
9442        let path = self.fs.wal_path().ok_or_else(|| {
9443            std::io::Error::new(
9444                std::io::ErrorKind::Unsupported,
9445                "wal_path not available for this Fs implementation",
9446            )
9447        })?;
9448        Ok(std::fs::metadata(path)?.len())
9449    }
9450
9451    /// Set the slow-query threshold.  Queries whose execution time equals or
9452    /// exceeds `ms` milliseconds are logged.  Pass `0` to disable.
9453    ///
9454    /// Use this setter in tests — the environment variable
9455    /// `MUSHROOMDB_SLOW_QUERY_MS` is process-global and races parallel test
9456    /// threads.
9457    pub fn set_slow_query_threshold_ms(&mut self, ms: u64) {
9458        self.slow_query_threshold_ms = ms;
9459    }
9460
9461    /// Snapshot of the slow-query ring buffer and lifetime counter.
9462    pub fn slow_query_snapshot(&self) -> SlowQuerySnapshot {
9463        let log = self.slow_queries.lock().unwrap_or_else(|e| e.into_inner());
9464        SlowQuerySnapshot {
9465            threshold_ms: self.slow_query_threshold_ms,
9466            count: log.total,
9467            last: log.entries.iter().cloned().collect(),
9468        }
9469    }
9470
9471    /// Instant the database was opened.  Used by consumers (e.g. `/metrics`)
9472    /// to compute uptime.
9473    pub fn started_at(&self) -> std::time::Instant {
9474        self.started_at
9475    }
9476
9477    /// On-disk snapshot format version this binary writes and reads.
9478    pub fn format_version() -> u16 {
9479        core_storage::snapshot::VERSION
9480    }
9481
9482    /// Test-support: total bytes appended (SimFs only usage).
9483    pub fn fs_total_appended(&self) -> usize
9484    where
9485        F: FsIntrospect,
9486    {
9487        self.fs.total_appended()
9488    }
9489
9490    /// Test-support: successful `Fs::sync` calls (SimFs / counting fs).
9491    pub fn fs_sync_count(&self) -> usize
9492    where
9493        F: FsIntrospect,
9494    {
9495        self.fs.sync_count()
9496    }
9497
9498    /// Consume the db, returning its fs (for crash simulation).
9499    pub fn into_fs(self) -> F {
9500        self.fs
9501    }
9502
9503    pub fn snapshot(&mut self) -> Result<()> {
9504        self.snapshot_with(SnapshotOptions::default())
9505    }
9506
9507    /// Snapshot with explicit options.
9508    ///
9509    /// # `keep_wal`
9510    ///
9511    /// When `keep_wal` is `false` (the default, same as [`snapshot`]):
9512    ///   - The WAL is replaced with a minimal baseline containing one
9513    ///     `EnableFulltext` record per active declaration.  All pre-snapshot
9514    ///     history is discarded; `open_at` can only reach post-snapshot commits.
9515    ///
9516    /// When `keep_wal` is `true`:
9517    ///   - The WAL is left intact.  All pre-snapshot commits remain reachable
9518    ///     via `open_at`.  The existing WAL already contains the original
9519    ///     `EnableFulltext` records, so no baseline re-write is needed; the
9520    ///     recovery guards in `apply()` silently skip any duplicate records on
9521    ///     replay.
9522    ///   - Crash window: a crash after the snapshot write but before the next
9523    ///     WAL write leaves the full pre-snapshot WAL intact.  On reopen the
9524    ///     snapshot is loaded and the WAL replayed idempotently over it — safe
9525    ///     because every `apply()` arm is idempotent when replayed over an
9526    ///     already-current snapshot.
9527    pub fn snapshot_with(&mut self, opts: SnapshotOptions) -> Result<()> {
9528        if self.read_only {
9529            return Err(GraphError::ReadOnly);
9530        }
9531        // A snapshot rewrites `wal.bin` through a tmp+rename, so a peer that is
9532        // appending ends up holding a descriptor on an unlinked inode and loses
9533        // commits it believes durable. Snapshotting therefore requires the
9534        // cross-process write lock, exactly as appending does. Unlike the WAL
9535        // append path this does not go through `log_then_apply_with`, so both
9536        // guards are repeated here.
9537        if self.degraded {
9538            return Err(GraphError::Io(std::io::Error::other(
9539                "database degraded after group-commit fsync failure; reopen required",
9540            )));
9541        }
9542        if self.lock_denied {
9543            return Err(GraphError::Busy { holder: None });
9544        }
9545        // Capture whether snapshot.bin already existed BEFORE this snapshot write.
9546        // Used by the archive path's conservative genesis-chain check: if a prior
9547        // snapshot exists but wal.truncated does not, we cannot distinguish a
9548        // legacy store (may have been truncated in an older code version) from a
9549        // new store that only used keep_wal=true.  Conservative: refuse genesis in
9550        // both cases.  Must be sampled here, before the snapshot write below.
9551        let had_prior_snapshot = self.fs.snapshot_path().map(|p| p.exists()).unwrap_or(false);
9552        self.ensure_v8_base_sections_loaded();
9553        // Ensure provenance is decoded before to_persist() clones it.
9554        self.engine.ensure_provenance_loaded_mut();
9555        let (rule_defs_typed, provenance, rule_tripped, rule_fires) = self.engine.to_persist();
9556        let rule_defs = rule_defs_typed
9557            .iter()
9558            .map(|r| bincode::serialize(r).expect("RuleDef serialize cannot fail"))
9559            .collect();
9560        // Collect HNSW state and IVF state.  When indexes are not yet
9561        // populated (clean open, no mutation since open), pass the retained
9562        // raw bytes through directly so that migrate/snapshot does not
9563        // silently discard fitted approximate-rule indexes.
9564        let hnsw_state = self.engine.export_hnsw_state_passthrough();
9565        let ivf_bytes = if !self.engine.indexes_populated() {
9566            // Pass retained IVF bytes through unchanged (no re-encode).
9567            self.engine.retained_ivf_bytes_clone().unwrap_or_default()
9568        } else {
9569            // Indexes live: encode from current state.
9570            let raw_ivf = self.engine.export_ivf_state();
9571            let ivf_state_map: BTreeMap<String, core_storage::snapshot::PerRuleIvfState> = raw_ivf
9572                .into_iter()
9573                .map(|(name, ((sc, sa, sd), (dc, da, dd)))| {
9574                    (
9575                        name,
9576                        core_storage::snapshot::PerRuleIvfState {
9577                            src: core_storage::snapshot::SideIvfState {
9578                                centroids: sc,
9579                                clusters: sa,
9580                                drift: sd,
9581                            },
9582                            dst: core_storage::snapshot::SideIvfState {
9583                                centroids: dc,
9584                                clusters: da,
9585                                drift: dd,
9586                            },
9587                        },
9588                    )
9589                })
9590                .collect();
9591            if ivf_state_map.is_empty() {
9592                Vec::new()
9593            } else {
9594                bincode::serialize(&ivf_state_map).expect("IVF state serialize cannot fail")
9595            }
9596        };
9597        let view_defs: Vec<Vec<u8>> = self
9598            .view_store
9599            .views()
9600            .map(|v| bincode::serialize(v).expect("ViewDef serialize cannot fail"))
9601            .collect();
9602        if self.base.is_some() {
9603            // V8 merge-snapshot path: encode base+overlay into a new V8 snapshot,
9604            // write it atomically, remap it as the new base, then clear the overlay.
9605            let meta = V8Meta {
9606                labels: self.labels.clone(),
9607                edge_props: self.edge_props.clone(),
9608                rule_defs,
9609                provenance,
9610                rule_tripped,
9611                rule_fires,
9612                ivf_bytes,
9613                view_defs,
9614                wal_truncated: !opts.keep_wal,
9615                hnsw: hnsw_state,
9616                last_change: self.last_change.clone(),
9617            };
9618            let mut buf: Vec<u8> = Vec::new();
9619            {
9620                // Clone the Arc so the old base stays alive while we encode.
9621                // The borrow of archived_csr (into old_base's mmap) is released
9622                // at the end of this block, before we replace self.base.
9623                let old_base = self.base.clone().expect("is_some checked above");
9624                let archived_csr = old_base.topology().map_err(|e| GraphError::Corrupt {
9625                    detail: format!("v8 snapshot: topology section: {e:?}"),
9626                })?;
9627                let archived_cols = old_base.columns().map_err(|e| GraphError::Corrupt {
9628                    detail: format!("v8 snapshot: columns section: {e:?}"),
9629                })?;
9630                let archived_edge_props =
9631                    old_base
9632                        .edge_props_section()
9633                        .map_err(|e| GraphError::Corrupt {
9634                            detail: format!("v8 snapshot: edge_props section: {e:?}"),
9635                        })?;
9636                let edge_props_raw =
9637                    old_base
9638                        .edge_props_raw_bytes()
9639                        .map_err(|e| GraphError::Corrupt {
9640                            detail: format!("v8 snapshot: edge_props raw bytes: {e:?}"),
9641                        })?;
9642                let prov_raw =
9643                    old_base
9644                        .provenance_raw_bytes()
9645                        .map_err(|e| GraphError::Corrupt {
9646                            detail: format!("v8 snapshot: provenance raw bytes: {e:?}"),
9647                        })?;
9648                encode_v8(
9649                    Some(archived_csr),
9650                    Some(archived_cols),
9651                    Some((archived_edge_props, edge_props_raw)),
9652                    Some(prov_raw),
9653                    &self.topo,
9654                    &self.props,
9655                    &self.ids,
9656                    &self.syms,
9657                    &meta,
9658                    &mut buf,
9659                )?;
9660            }
9661            self.fs.write_atomic(FileId::Snapshot, &buf)?;
9662            // Remap the freshly-written snapshot as the new base.
9663            // C2: use file mmap on RealFs; fall back to from_bytes on SimFs.
9664            let new_base = if let Some(snap_path) = self.fs.snapshot_path() {
9665                core_storage::v8::MappedBase::map(&snap_path)
9666            } else {
9667                core_storage::v8::MappedBase::from_bytes(buf)
9668            }
9669            .map_err(|e| GraphError::Corrupt {
9670                detail: format!("v8 snapshot: remap new base: {e:?}"),
9671            })?;
9672            self.base = Some(Arc::new(new_base));
9673            // Clear the overlay and prop tombstones — all data is now in the new base.
9674            self.topo = Topology::new();
9675            self.props = core_storage::columns::ColumnStore::new();
9676        } else {
9677            // Legacy path (V5–V7 stores without a V8 base).
9678            //
9679            // Memory-diet path: build V8Meta directly from &self — no SnapshotState
9680            // clone and no encode_v8_from_state intermediate clones.  The big
9681            // structures (self.topo, self.props) are borrowed, not cloned.
9682            // self.edge_props is moved (not cloned) because we immediately clear it
9683            // when we remap the new V8 snapshot as self.base (see below).
9684            //
9685            // Eliminates from peak RSS vs. the old SnapshotState path:
9686            //   • self.topo.clone()      (~topology HashMap footprint)
9687            //   • self.props.clone()     (~column-store footprint)
9688            //   • encode_v8_from_state V8Meta secondary clones (labels, edge_props, …)
9689            let meta = V8Meta {
9690                labels: self.labels.clone(),
9691                wal_truncated: !opts.keep_wal,
9692                // Move edge_props out so the large overlay is freed when meta
9693                // drops at end of this block (self.edge_props is now empty; reads
9694                // after base assignment go through the mmap'd base section).
9695                edge_props: std::mem::take(&mut self.edge_props),
9696                rule_defs,
9697                provenance,
9698                rule_tripped,
9699                rule_fires,
9700                ivf_bytes,
9701                view_defs,
9702                hnsw: hnsw_state,
9703                last_change: self.last_change.clone(),
9704            };
9705            let mut buf = Vec::new();
9706            encode_v8(
9707                None,
9708                None,
9709                None,
9710                None,
9711                &self.topo,
9712                &self.props,
9713                &self.ids,
9714                &self.syms,
9715                &meta,
9716                &mut buf,
9717            )?;
9718            // meta (and the moved edge_props inside it) is no longer needed;
9719            // drop it before the write to keep the peak window narrow.
9720            drop(meta);
9721            self.fs.write_atomic(FileId::Snapshot, &buf)?;
9722            // Remap the freshly-written V8 snapshot as self.base.
9723            // On RealFs: drop the encode buffer before mmap to recover ~1.9 GiB.
9724            // On SimFs (tests): pass buf to from_bytes.
9725            let new_base = if let Some(snap_path) = self.fs.snapshot_path() {
9726                drop(buf);
9727                core_storage::v8::MappedBase::map(&snap_path)
9728            } else {
9729                core_storage::v8::MappedBase::from_bytes(buf)
9730            }
9731            .map_err(|e| GraphError::Corrupt {
9732                detail: format!("v8 snapshot: remap new base (legacy path): {e:?}"),
9733            })?;
9734            self.base = Some(Arc::new(new_base));
9735            // Free the large heap-allocated decoded state — all data is now in the
9736            // mmap'd base.  Mirrors the V8 merge-snapshot path (see above).
9737            // self.edge_props was already moved into meta and is effectively empty.
9738            self.topo = Topology::new();
9739            self.props = core_storage::columns::ColumnStore::new();
9740        }
9741
9742        if opts.archive_wal {
9743            // History-preserving snapshot (Task 4):
9744            //   1. Snapshot already written above (write_atomic → fsynced).
9745            //   2. Rename WAL → wal.<commit_seq>.archive  (atomic, same fs).
9746            //      Crash window B: crash here leaves archive present, WAL
9747            //      absent.  Reopen: snapshot loaded (full state), no WAL
9748            //      replay.  Archive is NOT replayed into live state — it is
9749            //      pre-snapshot by construction.  Safe.
9750            //   3. Optionally write genesis marker (first archive only, no
9751            //      prior WAL truncation).
9752            //   4. Prune old archives (retention), update horizon floor.
9753            //      Pruning invalidates the genesis chain; delete marker.
9754            //   5. Write new minimal baseline WAL (write_atomic).
9755            //      Crash window C: crash here leaves new archive plus no live
9756            //      WAL.  Same as window B — handled above.
9757            //
9758            // Sample existing archives BEFORE the rename so we can detect
9759            // whether this is the first archive.
9760            let existing_archives = self.fs.list_archives()?;
9761            let is_first_archive = existing_archives.is_empty();
9762
9763            // Compute a globally-monotonic archive name: the name equals the
9764            // cumulative end-frame index of the archive in global commit space.
9765            //
9766            // Using `commit_seq` directly is UNSOUND across sessions: on reopen
9767            // commit_seq is seeded from max(last_change), which underestimates
9768            // the WAL depth when trailing commits (e.g. insert_edge) do not
9769            // update last_change.  A session-2 archive could then receive a name
9770            // ≤ the session-1 archive, causing incorrect sort order or collision.
9771            //
9772            // Instead: read and decode the live WAL here (before the rename) to
9773            // get its exact frame count, then add it to the last known global
9774            // end-frame index (the name of the most recent existing archive, or
9775            // wal_horizon_floor if no archives exist).  This is O(WAL size) but
9776            // snapshot is already serialising the full graph state, so the cost
9777            // is dominated.
9778            let live_wal_bytes_for_name = self.fs.read(FileId::Wal)?;
9779            let (live_frames_for_name, _) = decode_all(&live_wal_bytes_for_name);
9780            let archive_n = existing_archives
9781                .last()
9782                .copied()
9783                .unwrap_or(self.wal_horizon_floor)
9784                + live_frames_for_name.len() as u64;
9785            self.fs.archive_wal(archive_n)?;
9786
9787            // Genesis marker: written once when the first archive is taken
9788            // from a store that has never undergone a WAL-truncating snapshot.
9789            // When present, `open_at` may replay archive-resident commits from
9790            // empty state (the archive chain covers from global index 0).
9791            //
9792            // Two conditions must ALL hold:
9793            //   1. This is the first archive (existing_archives was empty).
9794            //   2. No snapshot.bin existed before this operation (had_prior_snapshot=false).
9795            //      A WAL-truncating snapshot (keep_wal=false) always writes snapshot.bin
9796            //      before truncating the WAL, so if any prior truncating snapshot was taken
9797            //      — even in a previous session — snapshot.bin is present and this condition
9798            //      is false.  This subsumes the cross-session truncation case without
9799            //      requiring a separate wal.truncated sidecar file.
9800            //      For legacy stores (snapshot.bin written by an older code version that
9801            //      may have truncated the WAL), the same conservative refusal applies:
9802            //      we cannot prove the chain is complete, so we refuse genesis (cost =
9803            //      no as-of-through-archives; never silent wrong data).
9804            //      On SimFs (snapshot_path() == None) had_prior_snapshot is always false,
9805            //      so SimFs always passes this check.
9806            if is_first_archive && !had_prior_snapshot {
9807                self.fs.write_genesis_marker()?;
9808                self.archive_genesis_chain = true;
9809            }
9810
9811            // Retention pruning: keep newest `keep` archives; delete oldest.
9812            // Pruning is the ONLY deletion site for archives.
9813            //
9814            // Crash-safety ordering (C1 fix):
9815            //   1. Count frames in surplus archives (reads only — no mutation).
9816            //   2. Advance and PERSIST the horizon floor FIRST via write-then-
9817            //      rename (atomic).  A crash after this point leaves orphaned
9818            //      archives on disk, but the floor is correct.  The opening
9819            //      cleanup sweep (`cleanup_orphaned_archives`) removes them on
9820            //      the next open, so the store is always safe to reopen.
9821            //   3. Delete the genesis marker (floor > 0 already blocks open_at
9822            //      via the conjunctive gate; marker cleanup is belt-and-suspenders).
9823            //   4. Delete surplus archives.  A crash between any two deletes
9824            //      leaves the floor committed and orphaned archives cleaned at
9825            //      next open — never a stale floor with a missing archive prefix.
9826            if let Some(keep) = self.wal_archive_retention {
9827                if keep > 0 {
9828                    let archives = self.fs.list_archives()?;
9829                    // archives is sorted ascending (oldest first)
9830                    if archives.len() as u32 > keep {
9831                        let surplus = archives.len() - keep as usize;
9832                        // Step 1: count pruned frames (reads, no mutation).
9833                        let mut pruned_frames = 0u64;
9834                        for &n in &archives[..surplus] {
9835                            let bytes = self.fs.read_archive(n)?;
9836                            let (frames, _) = decode_all(&bytes);
9837                            pruned_frames += frames.len() as u64;
9838                        }
9839                        // Step 2: advance and persist floor FIRST.
9840                        self.wal_horizon_floor += pruned_frames;
9841                        self.fs.write_horizon_floor(self.wal_horizon_floor)?;
9842                        // Step 3: delete genesis marker (floor > 0 already
9843                        // blocks open_at; this is belt-and-suspenders cleanup).
9844                        if pruned_frames > 0 && self.archive_genesis_chain {
9845                            self.fs.delete_genesis_marker()?;
9846                            self.archive_genesis_chain = false;
9847                        }
9848                        // Step 4: delete surplus archives.  Crash here →
9849                        // orphaned archives; cleaned at next open.
9850                        for &n in &archives[..surplus] {
9851                            self.fs.delete_archive(n)?;
9852                        }
9853                    }
9854                }
9855            }
9856
9857            // Write new minimal baseline WAL (mirrors the keep_wal=false path).
9858            let mut baseline_wal: Vec<u8> = Vec::new();
9859            for (label, field) in self.fulltext.enabled_pairs() {
9860                let rec = WalRecord::EnableFulltext {
9861                    label: label.clone(),
9862                    field: field.clone(),
9863                };
9864                baseline_wal.extend_from_slice(&encode_record(&rec));
9865            }
9866            for (label, field) in self.prop_index.enabled_pairs() {
9867                let rec = WalRecord::EnableIndex {
9868                    label: label.clone(),
9869                    field: field.clone(),
9870                };
9871                baseline_wal.extend_from_slice(&encode_record(&rec));
9872            }
9873            self.fs.write_atomic(FileId::Wal, &baseline_wal)?;
9874        } else if opts.keep_wal {
9875            // keep_wal=true: WAL is left untouched.  The existing WAL already
9876            // contains the EnableFulltext records from the original enable calls;
9877            // replay is idempotent (guards in apply() skip already-live entries).
9878            // No baseline re-write is needed or safe here — the full WAL history
9879            // must remain intact for open_at to reach pre-snapshot commits.
9880        } else {
9881            // keep_wal=false (default): truncate by replacing the WAL with a
9882            // minimal baseline of one EnableFulltext record per active pair.
9883            //
9884            // Crash-ordering: write_atomic is atomic.
9885            //   • Crash before snapshot write  → WAL unchanged.  Safe.
9886            //   • Crash after snapshot write but before this WAL write → full
9887            //     pre-snapshot WAL still present; open_with replays idempotently.
9888            //   • Crash after both writes → normal post-snapshot state.
9889            //
9890            // Genesis chain: a WAL-truncating snapshot breaks the archive chain
9891            // for any archives taken AFTER this point (their WAL slices would
9892            // not start at genesis).  Delete any existing genesis marker so that
9893            // open_at refuses archive-resident commits.  Future sessions are
9894            // covered by had_prior_snapshot: snapshot.bin written here persists
9895            // across sessions and prevents a later archiving session from
9896            // incorrectly claiming a complete genesis chain.
9897            if self.archive_genesis_chain {
9898                self.fs.delete_genesis_marker()?;
9899                self.archive_genesis_chain = false;
9900            }
9901            let mut baseline_wal: Vec<u8> = Vec::new();
9902            for (label, field) in self.fulltext.enabled_pairs() {
9903                let rec = WalRecord::EnableFulltext {
9904                    label: label.clone(),
9905                    field: field.clone(),
9906                };
9907                baseline_wal.extend_from_slice(&encode_record(&rec));
9908            }
9909            for (label, field) in self.prop_index.enabled_pairs() {
9910                let rec = WalRecord::EnableIndex {
9911                    label: label.clone(),
9912                    field: field.clone(),
9913                };
9914                baseline_wal.extend_from_slice(&encode_record(&rec));
9915            }
9916            self.fs.write_atomic(FileId::Wal, &baseline_wal)?;
9917        }
9918        // After snapshot the overlay may have changed (V8 merge path clears
9919        // self.topo and self.props). Refresh the MVCC fold so future readers
9920        // see the post-snapshot state rather than stale overlay data.
9921        self.fold_now();
9922        // We wrote the snapshot and (unless keep_wal) replaced the WAL, so both
9923        // markers this handle uses to detect other processes' work must be
9924        // re-taken from disk. Skipping this would make our own snapshot look
9925        // like a peer's on the next staleness check and force a needless
9926        // reload.
9927        self.wal_consumed = self.fs.wal_len().map_err(GraphError::Io)?;
9928        self.snapshot_ident = self.fs.snapshot_ident().map_err(GraphError::Io)?;
9929        Ok(())
9930    }
9931}
9932
9933/// Queued mutation for a [`BatchBuilder`] or [`GraphDb::commit_group`].
9934///
9935/// The `submit_batch` / `commit_group` APIs accept `Vec<BatchOp>` so that
9936/// callers can build a set of mutations without holding `&mut GraphDb` and
9937/// hand them off to the group-committing writer for durable, batched I/O.
9938pub enum BatchOp {
9939    InsertNode {
9940        label: String,
9941        key: String,
9942        props: Vec<(String, Value)>,
9943    },
9944    InsertEdge {
9945        edge_type: String,
9946        src_key: String,
9947        dst_key: String,
9948    },
9949    SetProp {
9950        key: String,
9951        field: String,
9952        value: Value,
9953    },
9954    RemoveProp {
9955        key: String,
9956        field: String,
9957    },
9958    DeleteEdge {
9959        edge_type: String,
9960        src_key: String,
9961        dst_key: String,
9962    },
9963    DeleteNode {
9964        key: String,
9965    },
9966    CreateRule(RuleDef),
9967    DeleteRule {
9968        name: String,
9969    },
9970    /// Rename a node's key. Validated: old must exist, new must not.
9971    RenameNode {
9972        old_key: String,
9973        new_key: String,
9974    },
9975    /// Insert an edge, auto-creating any missing endpoint as a plain node with
9976    /// `placeholder_label` and no props. Rules fire and last-change is updated
9977    /// for each created endpoint (normal InsertNode semantics in the batch frame).
9978    InsertEdgeUpsert {
9979        edge_type: String,
9980        src_key: String,
9981        dst_key: String,
9982        placeholder_label: String,
9983    },
9984}
9985
9986/// Three-way node visibility status used by `check_single_op_authz`.
9987enum NodeAuthzStatus {
9988    /// Node exists in the store and is in the role's read mask.
9989    Visible(String), // carries the node's label
9990    /// Node exists in the store but is NOT in the role's read mask.
9991    Hidden,
9992    /// Node does not exist in the store.
9993    Absent,
9994}
9995
9996/// Overlay of ops already accepted earlier in the same batch. Never written
9997/// back to the database — validation only.
9998#[derive(Default)]
9999struct Overlay {
10000    extra_keys: BTreeSet<String>,
10001    deleted_keys: BTreeSet<String>,
10002    extra_props: BTreeMap<(String, String), Value>,
10003    removed_props: BTreeSet<(String, String)>,
10004    extra_edges: BTreeSet<(String, String, String)>,
10005    deleted_edges: BTreeSet<(String, String, String)>,
10006    extra_rules: BTreeSet<String>,
10007    deleted_rules: BTreeSet<String>,
10008    /// `rule name → (via_edge, edge_type)` for every via-hop rule accepted
10009    /// earlier in this batch. Feeds the rule-chain cycle check, which otherwise
10010    /// sees only the rules already committed to the engine. Keyed by name so a
10011    /// later `DeleteRule` in the same batch drops the arc with the rule.
10012    extra_rule_arcs: BTreeMap<String, (String, String)>,
10013}
10014
10015/// Read-only view of live db state plus a batch overlay. Shared by single-op
10016/// public methods (empty overlay) and `commit_batch`.
10017struct MutPreview<'a, F: Fs> {
10018    db: &'a GraphDb<F>,
10019    overlay: Overlay,
10020}
10021
10022/// Shortest path from `start` to `target` following `arcs` (`from → to`), or
10023/// `None` if `target` is unreachable.
10024///
10025/// Used for rule-chain cycle detection, where an arc is "a rule hops over
10026/// `from` and writes `to`". Breadth-first over BTree-ordered adjacency, so the
10027/// reported path is stable for a given rule set, and iterative so a pathological
10028/// rule graph cannot overflow the stack.
10029fn find_cycle_through(arcs: &[(String, String)], start: &str, target: &str) -> Option<Vec<String>> {
10030    let mut adj: BTreeMap<&str, BTreeSet<&str>> = BTreeMap::new();
10031    for (from, to) in arcs {
10032        adj.entry(from.as_str()).or_default().insert(to.as_str());
10033    }
10034    let mut parent: BTreeMap<&str, &str> = BTreeMap::new();
10035    let mut visited: BTreeSet<&str> = BTreeSet::new();
10036    let mut queue: std::collections::VecDeque<&str> = std::collections::VecDeque::new();
10037    visited.insert(start);
10038    queue.push_back(start);
10039    while let Some(node) = queue.pop_front() {
10040        if node == target {
10041            let mut path = vec![node.to_string()];
10042            let mut cur = node;
10043            while let Some(&p) = parent.get(cur) {
10044                path.push(p.to_string());
10045                cur = p;
10046            }
10047            path.reverse();
10048            return Some(path);
10049        }
10050        for &next in adj.get(node).into_iter().flatten() {
10051            if visited.insert(next) {
10052                parent.insert(next, node);
10053                queue.push_back(next);
10054            }
10055        }
10056    }
10057    None
10058}
10059
10060impl<'a, F: Fs> MutPreview<'a, F> {
10061    fn new(db: &'a GraphDb<F>) -> Self {
10062        Self {
10063            db,
10064            overlay: Overlay::default(),
10065        }
10066    }
10067
10068    fn has_key(&self, key: &str) -> bool {
10069        if self.overlay.extra_keys.contains(key) {
10070            return true;
10071        }
10072        if self.overlay.deleted_keys.contains(key) {
10073            return false;
10074        }
10075        self.db.ids.get(key).is_some()
10076    }
10077
10078    fn has_prop(&self, key: &str, field: &str) -> bool {
10079        if !self.has_key(key) {
10080            return false;
10081        }
10082        let k = (key.to_string(), field.to_string());
10083        if self.overlay.removed_props.contains(&k) {
10084            return false;
10085        }
10086        if self.overlay.extra_props.contains_key(&k) {
10087            return true;
10088        }
10089        // Fresh identity (first insert in this batch, or delete+reinsert):
10090        // ignore props still sitting on the soon-to-be-tombstoned slot.
10091        if self.overlay.extra_keys.contains(key) {
10092            return false;
10093        }
10094        self.db.get_prop(key, field).is_some()
10095    }
10096
10097    fn has_edge(&self, edge_type: &str, src_key: &str, dst_key: &str) -> bool {
10098        let k = (
10099            edge_type.to_string(),
10100            src_key.to_string(),
10101            dst_key.to_string(),
10102        );
10103        if self.overlay.deleted_edges.contains(&k) {
10104            return false;
10105        }
10106        if self.overlay.extra_edges.contains(&k) {
10107            return true;
10108        }
10109        // A key created in this batch (including reinsert) has no db edges.
10110        if self.overlay.extra_keys.contains(src_key) || self.overlay.extra_keys.contains(dst_key) {
10111            return false;
10112        }
10113        if self.overlay.deleted_keys.contains(src_key)
10114            || self.overlay.deleted_keys.contains(dst_key)
10115        {
10116            return false;
10117        }
10118        let Some(src) = self.db.ids.get(src_key) else {
10119            return false;
10120        };
10121        let Some(dst) = self.db.ids.get(dst_key) else {
10122            return false;
10123        };
10124        let Some(sym) = self.db.syms.get(edge_type) else {
10125            return false;
10126        };
10127        self.db
10128            .topo_view()
10129            .neighbors(sym, Direction::Out, src)
10130            .binary_search(&dst)
10131            .is_ok()
10132    }
10133
10134    fn has_rule(&self, name: &str) -> bool {
10135        if self.overlay.extra_rules.contains(name) {
10136            return true;
10137        }
10138        if self.overlay.deleted_rules.contains(name) {
10139            return false;
10140        }
10141        self.db.engine.rules().any(|r| r.name == name)
10142    }
10143
10144    fn is_rule_owned(&self, edge_type: &str, src_key: &str, dst_key: &str) -> bool {
10145        if self.overlay.extra_keys.contains(src_key) || self.overlay.extra_keys.contains(dst_key) {
10146            return false;
10147        }
10148        if self.overlay.deleted_keys.contains(src_key)
10149            || self.overlay.deleted_keys.contains(dst_key)
10150        {
10151            return false;
10152        }
10153        let Some(src) = self.db.ids.get(src_key) else {
10154            return false;
10155        };
10156        let Some(dst) = self.db.ids.get(dst_key) else {
10157            return false;
10158        };
10159        let Some(et) = self.db.syms.get(edge_type) else {
10160            return false;
10161        };
10162        // extra_rules is deliberately not consulted: a CreateRule earlier in
10163        // this batch has not fired, so it contributes no provenance. That is
10164        // the documented rule-window gap (see GraphDb::batch).
10165        if self.overlay.deleted_rules.is_empty() {
10166            return self.db.engine.is_owned(et, src, dst);
10167        }
10168        for (rule, triples) in self.db.engine.provenance() {
10169            if self.overlay.deleted_rules.contains(rule) {
10170                continue;
10171            }
10172            if triples.contains(&(et, src, dst)) {
10173                return true;
10174            }
10175        }
10176        false
10177    }
10178
10179    fn check_insert_node(&self, key: &str) -> Result<()> {
10180        if self.has_key(key) {
10181            Err(GraphError::DuplicateKey { key: key.into() })
10182        } else {
10183            Ok(())
10184        }
10185    }
10186
10187    fn check_live_key(&self, key: &str) -> Result<()> {
10188        if self.has_key(key) {
10189            Ok(())
10190        } else {
10191            Err(GraphError::KeyNotFound { key: key.into() })
10192        }
10193    }
10194
10195    fn prepare_insert_edge(&self, edge_type: &str, src_key: &str, dst_key: &str) -> Result<bool> {
10196        for k in [src_key, dst_key] {
10197            if !self.has_key(k) {
10198                return Err(GraphError::KeyNotFound { key: k.into() });
10199            }
10200        }
10201        if self.is_rule_owned(edge_type, src_key, dst_key) {
10202            return Err(GraphError::RuleOwned {
10203                detail: format!("edge {edge_type} {src_key}→{dst_key} is rule-owned"),
10204            });
10205        }
10206        Ok(!self.has_edge(edge_type, src_key, dst_key))
10207    }
10208
10209    fn prepare_remove_prop(&self, key: &str, field: &str) -> Result<bool> {
10210        self.check_live_key(key)?;
10211        Ok(self.has_prop(key, field))
10212    }
10213
10214    fn prepare_delete_edge(&self, edge_type: &str, src_key: &str, dst_key: &str) -> Result<bool> {
10215        for k in [src_key, dst_key] {
10216            if !self.has_key(k) {
10217                return Err(GraphError::KeyNotFound { key: k.into() });
10218            }
10219        }
10220        // Provenance-owned OR a live rule would derive this pair. User-first
10221        // edges that a later rule matches are not in `owned`, but deleting
10222        // them would leave a hole `rebuild_rule` immediately fills.
10223        if self.is_rule_owned(edge_type, src_key, dst_key) {
10224            return Err(GraphError::RuleOwned {
10225                detail: format!(
10226                    "edge {edge_type} {src_key}→{dst_key} is rule-owned; \
10227                     delete or change the owning rule"
10228                ),
10229            });
10230        }
10231        if self.would_derive(edge_type, src_key, dst_key) {
10232            return Err(GraphError::RuleOwned {
10233                detail: format!(
10234                    "edge {edge_type} {src_key}→{dst_key} is rule-owned; \
10235                     delete or change the owning rule, or a live rule would re-derive it"
10236                ),
10237            });
10238        }
10239        Ok(self.has_edge(edge_type, src_key, dst_key))
10240    }
10241
10242    /// True if any live rule (minus overlay-deleted names) would derive
10243    /// `(edge_type, src, dst)` from current overlay-visible props/labels.
10244    /// CreateRule names in `extra_rules` are ignored — same documented
10245    /// same-batch rule-window as [`Self::is_rule_owned`].
10246    fn would_derive(&self, edge_type: &str, src_key: &str, dst_key: &str) -> bool {
10247        if src_key == dst_key {
10248            return false;
10249        }
10250        let Some(src_label) = self.label_of(src_key) else {
10251            return false;
10252        };
10253        let Some(dst_label) = self.label_of(dst_key) else {
10254            return false;
10255        };
10256        for rule in self.db.engine.rules() {
10257            if self.overlay.deleted_rules.contains(&rule.name) {
10258                continue;
10259            }
10260            if rule.edge_type != edge_type {
10261                continue;
10262            }
10263            if rule.src_label != src_label || rule.dst_label != dst_label {
10264                continue;
10265            }
10266            let src_props = |f: &str| self.prop_value(src_key, f);
10267            let dst_props = |f: &str| self.prop_value(dst_key, f);
10268            let src_view = NodeView {
10269                key: src_key,
10270                props: &src_props,
10271            };
10272            let dst_view = NodeView {
10273                key: dst_key,
10274                props: &dst_props,
10275            };
10276            if evaluate(&rule.predicate, &src_view, &dst_view).is_some() {
10277                return true;
10278            }
10279        }
10280        false
10281    }
10282
10283    fn label_of(&self, key: &str) -> Option<String> {
10284        if self.overlay.deleted_keys.contains(key) {
10285            return None;
10286        }
10287        // Fresh identities created in this batch have no stored label in the
10288        // overlay; they cannot be provenance-owned yet either.
10289        let id = self.db.ids.get(key)?;
10290        let sym = self.db.labels.get(id as usize).copied()?;
10291        if sym == u32::MAX {
10292            return None;
10293        }
10294        self.db.syms.resolve(sym).map(str::to_string)
10295    }
10296
10297    fn prop_value(&self, key: &str, field: &str) -> Option<Value> {
10298        if !self.has_key(key) {
10299            return None;
10300        }
10301        let k = (key.to_string(), field.to_string());
10302        if self.overlay.removed_props.contains(&k) {
10303            return None;
10304        }
10305        if let Some(v) = self.overlay.extra_props.get(&k) {
10306            return Some(v.clone());
10307        }
10308        if self.overlay.extra_keys.contains(key) {
10309            return None;
10310        }
10311        self.db.get_prop(key, field)
10312    }
10313
10314    fn check_create_rule(&self, def: &RuleDef) -> Result<()> {
10315        def.validate()
10316            .map_err(|e| GraphError::RuleInvalid { detail: e })?;
10317        if self.has_rule(&def.name) {
10318            return Err(GraphError::RuleInvalid {
10319                detail: format!("rule {:?} already exists", def.name),
10320            });
10321        }
10322        // Rule-chain cycle rejection. Derived edges feed via-hop rules, so a
10323        // rule set forms a graph whose arcs are "hops over `via_edge`, writes
10324        // `edge_type`". A cycle in that graph is a rule set that would re-fire
10325        // itself forever; the engine's depth cap would silently truncate it
10326        // instead, leaving an arbitrary partial result. Reject it here, the one
10327        // place that sees the whole rule set.
10328        //
10329        // Rules accepted earlier in the same batch count too: the overlay
10330        // carries their arcs, so a cycle cannot be assembled one op at a time.
10331        if let Some(via) = def.via_edge.as_deref() {
10332            if via == def.edge_type {
10333                return Err(GraphError::RuleInvalid {
10334                    detail: format!("rule chain cycle: {} -> {}", via, def.edge_type),
10335                });
10336            }
10337            let mut arcs: Vec<(String, String)> = self
10338                .db
10339                .engine
10340                .rules()
10341                .filter(|r| !self.overlay.deleted_rules.contains(&r.name))
10342                .filter_map(|r| r.via_edge.clone().map(|v| (v, r.edge_type.clone())))
10343                .collect();
10344            arcs.extend(self.overlay.extra_rule_arcs.values().cloned());
10345            arcs.push((via.to_string(), def.edge_type.clone()));
10346            if let Some(path) = find_cycle_through(&arcs, &def.edge_type, via) {
10347                return Err(GraphError::RuleInvalid {
10348                    detail: format!("rule chain cycle: {} -> {}", via, path.join(" -> ")),
10349                });
10350            }
10351        }
10352        Ok(())
10353    }
10354
10355    fn check_delete_rule(&self, name: &str) -> Result<()> {
10356        if self.has_rule(name) {
10357            Ok(())
10358        } else {
10359            Err(GraphError::RuleNotFound { name: name.into() })
10360        }
10361    }
10362
10363    fn note_insert_node(&mut self, key: &str, props: &[(String, Value)]) {
10364        self.overlay.deleted_keys.remove(key);
10365        self.overlay.extra_keys.insert(key.to_string());
10366        self.overlay.extra_props.retain(|(k, _), _| k != key);
10367        self.overlay.removed_props.retain(|(k, _)| k != key);
10368        for (field, value) in props {
10369            self.overlay
10370                .extra_props
10371                .insert((key.to_string(), field.clone()), value.clone());
10372        }
10373    }
10374
10375    fn note_insert_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) {
10376        let k = (
10377            edge_type.to_string(),
10378            src_key.to_string(),
10379            dst_key.to_string(),
10380        );
10381        self.overlay.deleted_edges.remove(&k);
10382        self.overlay.extra_edges.insert(k);
10383    }
10384
10385    fn note_set_prop(&mut self, key: &str, field: &str, value: &Value) {
10386        let k = (key.to_string(), field.to_string());
10387        self.overlay.removed_props.remove(&k);
10388        self.overlay.extra_props.insert(k, value.clone());
10389    }
10390
10391    fn note_remove_prop(&mut self, key: &str, field: &str) {
10392        let k = (key.to_string(), field.to_string());
10393        self.overlay.extra_props.remove(&k);
10394        self.overlay.removed_props.insert(k);
10395    }
10396
10397    fn note_delete_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) {
10398        let k = (
10399            edge_type.to_string(),
10400            src_key.to_string(),
10401            dst_key.to_string(),
10402        );
10403        self.overlay.extra_edges.remove(&k);
10404        self.overlay.deleted_edges.insert(k);
10405    }
10406
10407    fn note_delete_node(&mut self, key: &str) {
10408        self.overlay.extra_keys.remove(key);
10409        self.overlay.deleted_keys.insert(key.to_string());
10410        self.overlay.extra_props.retain(|(k, _), _| k != key);
10411        self.overlay.removed_props.retain(|(k, _)| k != key);
10412        self.overlay
10413            .extra_edges
10414            .retain(|(_, s, d)| s != key && d != key);
10415        self.overlay
10416            .deleted_edges
10417            .retain(|(_, s, d)| s != key && d != key);
10418    }
10419
10420    fn note_create_rule(&mut self, def: &RuleDef) {
10421        self.overlay.deleted_rules.remove(&def.name);
10422        self.overlay.extra_rules.insert(def.name.clone());
10423        // Rules accepted earlier in this batch are not in the engine yet, so
10424        // the cycle check would not see their arcs. Keep the arc, not just the
10425        // name, so a batch cannot smuggle in a cycle one op at a time.
10426        if let Some(via) = def.via_edge.clone() {
10427            self.overlay
10428                .extra_rule_arcs
10429                .insert(def.name.clone(), (via, def.edge_type.clone()));
10430        }
10431    }
10432
10433    fn check_rename_node(&self, old: &str, new: &str) -> Result<()> {
10434        if !self.has_key(old) {
10435            return Err(GraphError::KeyNotFound { key: old.into() });
10436        }
10437        if self.has_key(new) {
10438            return Err(GraphError::DuplicateKey { key: new.into() });
10439        }
10440        Ok(())
10441    }
10442
10443    fn note_rename_node(&mut self, old: &str, new: &str) {
10444        // Mark old as deleted so subsequent batch ops cannot reference it.
10445        self.overlay.extra_keys.remove(old);
10446        self.overlay.deleted_keys.insert(old.to_string());
10447        // Mark new as extra so subsequent batch ops can reference it.
10448        self.overlay.deleted_keys.remove(new);
10449        self.overlay.extra_keys.insert(new.to_string());
10450        // Migrate any overlay props from old key to new key.
10451        let new_str = new.to_string();
10452        let transferred: Vec<((String, String), Value)> = self
10453            .overlay
10454            .extra_props
10455            .iter()
10456            .filter(|((k, _), _)| k.as_str() == old)
10457            .map(|((_, f), v)| ((new_str.clone(), f.clone()), v.clone()))
10458            .collect();
10459        self.overlay
10460            .extra_props
10461            .retain(|(k, _), _| k.as_str() != old);
10462        for (k, v) in transferred {
10463            self.overlay.extra_props.insert(k, v);
10464        }
10465        // Migrate removed_props.
10466        let transferred_removed: Vec<(String, String)> = self
10467            .overlay
10468            .removed_props
10469            .iter()
10470            .filter(|(k, _)| k.as_str() == old)
10471            .map(|(_, f)| (new_str.clone(), f.clone()))
10472            .collect();
10473        self.overlay
10474            .removed_props
10475            .retain(|(k, _)| k.as_str() != old);
10476        for k in transferred_removed {
10477            self.overlay.removed_props.insert(k);
10478        }
10479    }
10480
10481    fn note_delete_rule(&mut self, name: &str) {
10482        self.overlay.extra_rules.remove(name);
10483        // Drop its chain arc too: a rule created and then deleted in the same
10484        // batch must not make a later, legal rule look like a cycle.
10485        self.overlay.extra_rule_arcs.remove(name);
10486        self.overlay.deleted_rules.insert(name.to_string());
10487        // Treat the deleted rule's current provenance as gone so a later
10488        // delete_edge of those triples is a no-op (matches sequential).
10489        if let Some(triples) = self.db.engine.provenance().get(name) {
10490            for &(et, s, d) in triples {
10491                let Some(etype) = self.db.syms.resolve(et) else {
10492                    continue;
10493                };
10494                let Some(src) = self.db.ids.key_of(s) else {
10495                    continue;
10496                };
10497                let Some(dst) = self.db.ids.key_of(d) else {
10498                    continue;
10499                };
10500                let k = (etype.to_string(), src.to_string(), dst.to_string());
10501                self.overlay.extra_edges.remove(&k);
10502                self.overlay.deleted_edges.insert(k);
10503            }
10504        }
10505    }
10506}
10507
10508/// Collects mutations and commits them as one WAL `Batch` frame.
10509///
10510/// Holds `&mut GraphDb` for its lifetime. Queue with the same method names
10511/// as [`GraphDb`]; call [`commit`](Self::commit) to validate, log, and apply.
10512/// See [`GraphDb::batch`] for validation and atomicity rules.
10513pub struct BatchBuilder<'a, F: Fs> {
10514    db: &'a mut GraphDb<F>,
10515    ops: Vec<BatchOp>,
10516}
10517
10518impl<'a, F: Fs> BatchBuilder<'a, F> {
10519    pub fn insert_node(
10520        &mut self,
10521        label: &str,
10522        key: &str,
10523        props: Vec<(String, Value)>,
10524    ) -> &mut Self {
10525        self.ops.push(BatchOp::InsertNode {
10526            label: label.into(),
10527            key: key.into(),
10528            props,
10529        });
10530        self
10531    }
10532
10533    pub fn insert_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) -> &mut Self {
10534        self.ops.push(BatchOp::InsertEdge {
10535            edge_type: edge_type.into(),
10536            src_key: src_key.into(),
10537            dst_key: dst_key.into(),
10538        });
10539        self
10540    }
10541
10542    pub fn set_prop(&mut self, key: &str, field: &str, value: Value) -> &mut Self {
10543        self.ops.push(BatchOp::SetProp {
10544            key: key.into(),
10545            field: field.into(),
10546            value,
10547        });
10548        self
10549    }
10550
10551    pub fn remove_prop(&mut self, key: &str, field: &str) -> &mut Self {
10552        self.ops.push(BatchOp::RemoveProp {
10553            key: key.into(),
10554            field: field.into(),
10555        });
10556        self
10557    }
10558
10559    pub fn delete_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) -> &mut Self {
10560        self.ops.push(BatchOp::DeleteEdge {
10561            edge_type: edge_type.into(),
10562            src_key: src_key.into(),
10563            dst_key: dst_key.into(),
10564        });
10565        self
10566    }
10567
10568    pub fn delete_node(&mut self, key: &str) -> &mut Self {
10569        self.ops.push(BatchOp::DeleteNode { key: key.into() });
10570        self
10571    }
10572
10573    pub fn create_rule(&mut self, def: RuleDef) -> &mut Self {
10574        self.ops.push(BatchOp::CreateRule(def));
10575        self
10576    }
10577
10578    pub fn delete_rule(&mut self, name: &str) -> &mut Self {
10579        self.ops.push(BatchOp::DeleteRule { name: name.into() });
10580        self
10581    }
10582
10583    /// Queue a node-rename in this batch.
10584    ///
10585    /// Validation (old exists, new not taken) runs at commit time.
10586    pub fn rename_node(&mut self, old_key: &str, new_key: &str) -> &mut Self {
10587        self.ops.push(BatchOp::RenameNode {
10588            old_key: old_key.into(),
10589            new_key: new_key.into(),
10590        });
10591        self
10592    }
10593
10594    /// Queue an edge insert with endpoint auto-creation.
10595    ///
10596    /// Any missing endpoint is created as a plain node `{key, label:
10597    /// placeholder_label, no props}` inside this batch frame. Rules fire and
10598    /// last-change is updated for each auto-created node.
10599    pub fn insert_edge_upsert(
10600        &mut self,
10601        edge_type: &str,
10602        src_key: &str,
10603        dst_key: &str,
10604        placeholder_label: &str,
10605    ) -> &mut Self {
10606        self.ops.push(BatchOp::InsertEdgeUpsert {
10607            edge_type: edge_type.into(),
10608            src_key: src_key.into(),
10609            dst_key: dst_key.into(),
10610            placeholder_label: placeholder_label.into(),
10611        });
10612        self
10613    }
10614
10615    /// Validate every queued op, then log one `Batch` frame and apply.
10616    /// Empty / all-noop batches return `Ok(())` without writing the WAL.
10617    /// A second `commit()` after a successful one is an empty-batch no-op
10618    /// (queued ops were taken).
10619    /// Takes `&mut self` so it chains after the queue methods (`b.insert_node(..).commit()`)
10620    /// and also works as `let mut b = db.batch(); b.insert_node(..); b.commit()`.
10621    ///
10622    /// **Rule-window limitation:** batch validation cannot see edges that a
10623    /// rule created earlier in the *same* batch will derive at apply time, so
10624    /// a `delete_edge` / `insert_edge` in that window is silently no-oped
10625    /// where sequential calls would return `Err(RuleOwned)`. State integrity
10626    /// is unaffected (idempotent apply, provenance intact). Create rules in
10627    /// their own batch, or sequentially, when later ops may touch derived
10628    /// edges.
10629    /// Validate every queued op and commit atomically.
10630    ///
10631    /// Returns `(nodes_inserted, edges_inserted)` — the counts of node and edge
10632    /// WAL records actually written (duplicate edges are silent no-ops and are
10633    /// NOT counted). Both are 0 when the batch is empty or all-noop.
10634    pub fn commit(&mut self) -> Result<(usize, usize)> {
10635        let ops = std::mem::take(&mut self.ops);
10636        self.db.commit_batch(ops)
10637    }
10638
10639    /// Same as [`commit`](Self::commit) but tail the inner events with
10640    /// [`MutationEvent::Ingested`] instead of [`MutationEvent::BatchApplied`].
10641    pub(crate) fn commit_ingest(&mut self, label: &str, inserted: usize) -> Result<(usize, usize)> {
10642        let ops = std::mem::take(&mut self.ops);
10643        self.db
10644            .commit_logged_batch(ops, Some((label.to_string(), inserted)), None)
10645    }
10646}
10647
10648pub struct NodeRef<'a, F: Fs> {
10649    db: &'a GraphDb<F>,
10650    id: u32,
10651}
10652
10653impl<'a, F: Fs> NodeRef<'a, F> {
10654    pub fn key(&self) -> &str {
10655        self.db.ids.key_of(self.id).expect("dense ids")
10656    }
10657
10658    pub fn label(&self) -> &str {
10659        let sym = self
10660            .db
10661            .labels
10662            .get(self.id as usize)
10663            .copied()
10664            .filter(|&s| s != u32::MAX)
10665            .expect("real nodes always have a label; u32::MAX sentinel cannot occur");
10666        self.db.syms.resolve(sym).expect("interned label symbol")
10667    }
10668
10669    pub fn prop(&self, field: &str) -> Option<Value> {
10670        self.db
10671            .props_view()
10672            .get(self.id, field)
10673            .map(|vr| vr.into_value())
10674    }
10675
10676    /// All stored fields for this node, sorted by field name.
10677    ///
10678    /// Reads from the full base+overlay view so that props stored only in the
10679    /// V8 snapshot base (i.e. before any post-snapshot WAL writes) are visible.
10680    pub fn props(&self) -> BTreeMap<String, Value> {
10681        let mut out = BTreeMap::new();
10682        let pv = self.db.props_view();
10683        for field in pv.field_names() {
10684            if let Some(vr) = pv.get(self.id, &field) {
10685                out.insert(field, vr.into_value());
10686            }
10687        }
10688        out
10689    }
10690
10691    /// depth-N BFS as a ResultSet: columns ["key","label","depth"], BFS order.
10692    pub fn neighborhood(&self, depth: u32, edge_types: Option<&[&str]>, dir: Dir) -> ResultSet {
10693        let view = self.db.view();
10694        let resolved: Option<Vec<u32>> = edge_types.map(|names| {
10695            names
10696                .iter()
10697                .filter_map(|name| view.syms.get(name))
10698                .collect()
10699        });
10700        let nb = neighborhood(&view, self.id, depth, resolved.as_deref(), dir);
10701        let mut rs = ResultSet::new(vec!["key".into(), "label".into(), "depth".into()]);
10702        for (nid, d) in nb.nodes {
10703            let key = view.key_of(nid);
10704            let label = view
10705                .label_of(nid)
10706                .expect("real nodes always have a label; u32::MAX sentinel cannot occur");
10707            rs.push_row(vec![
10708                Some(Value::Str(key.to_string())),
10709                Some(Value::Str(label.to_string())),
10710                Some(Value::Int(d as i64)),
10711            ]);
10712        }
10713        rs
10714    }
10715
10716    /// 1-hop, Both directions: edge-type name → sorted unique neighbor keys.
10717    pub fn grouped_by_edge_type(&self) -> BTreeMap<String, Vec<String>> {
10718        let view = self.db.view();
10719        let mut groups: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
10720        for e in expand(&view, self.id, None, Dir::Both) {
10721            // Skip edges with unknown etypes (only possible from corrupt large
10722            // TOPOLOGY section; function returns BTreeMap not Result).
10723            let Some(etype) = view.syms.resolve(e.etype) else {
10724                continue;
10725            };
10726            let etype = etype.to_string();
10727            let nbr = if e.src == self.id { e.dst } else { e.src };
10728            groups
10729                .entry(etype)
10730                .or_default()
10731                .insert(view.key_of(nbr).to_string());
10732        }
10733        groups
10734            .into_iter()
10735            .map(|(k, v)| (k, v.into_iter().collect()))
10736            .collect()
10737    }
10738}
10739
10740#[cfg(test)]
10741mod tests {
10742    use super::*;
10743    use core_rules::Predicate;
10744
10745    fn tmp_dir(name: &str) -> std::path::PathBuf {
10746        let d =
10747            std::env::temp_dir().join(format!("graphdb-db-unit-{}-{}", name, std::process::id()));
10748        let _ = std::fs::remove_dir_all(&d);
10749        d
10750    }
10751
10752    fn fk_rule() -> RuleDef {
10753        RuleDef {
10754            name: "works_at".into(),
10755            src_label: "Person".into(),
10756            dst_label: "Org".into(),
10757            predicate: Predicate::KeyMatch {
10758                field: "org_id".into(),
10759            },
10760            edge_type: "WORKS_AT".into(),
10761            weight_prop: None,
10762            max_edges: None,
10763            approximate: false,
10764            via_label: None,
10765            via_edge: None,
10766            via_dir: None,
10767        }
10768    }
10769
10770    /// Regression guard for the no-views delta-copy fast path.
10771    ///
10772    /// When no views are defined, `pending_deltas_since().to_vec()` must never
10773    /// be called — even during a large CreateRule backfill. The DELTA_COPY_COUNT
10774    /// thread-local is incremented inside every `if !view_store.is_empty()` block;
10775    /// a count of 0 after the entire sequence proves the guard fires correctly.
10776    #[test]
10777    fn no_delta_copy_when_no_views() {
10778        DELTA_COPY_COUNT.with(|c| c.set(0));
10779        let dir = tmp_dir("no-delta-copy");
10780        {
10781            let mut db = GraphDb::open(&dir).unwrap();
10782            // Insert 50 Org + 50 Person nodes with FK links.
10783            for i in 0..50u32 {
10784                db.insert_node("Org", &format!("o{i}"), vec![]).unwrap();
10785            }
10786            for i in 0..50u32 {
10787                db.insert_node(
10788                    "Person",
10789                    &format!("p{i}"),
10790                    vec![("org_id".into(), Value::Str(format!("o{i}")))],
10791                )
10792                .unwrap();
10793            }
10794            // CreateRule backfill should NOT invoke to_vec() when no views are defined.
10795            db.create_rule(fk_rule()).unwrap();
10796
10797            // Counter must stay 0 — no views, no copies.
10798            let copies = DELTA_COPY_COUNT.with(|c| c.get());
10799            assert_eq!(
10800                copies, 0,
10801                "pending_deltas_since().to_vec() called despite no views"
10802            );
10803
10804            // Derived edges must still be correct (the guard skips only the
10805            // empty delta propagation loop, not the rule application itself).
10806            let nbrs = db.neighbors("p0", "WORKS_AT", Direction::Out).unwrap();
10807            assert_eq!(
10808                nbrs,
10809                vec!["o0"],
10810                "rule must derive edges even with no views"
10811            );
10812        }
10813        let _ = std::fs::remove_dir_all(&dir);
10814    }
10815
10816    /// Gating regression: subscribe AFTER a backfill must see no stale events.
10817    /// subscribe BEFORE a backfill must see every edge-fire event.
10818    #[test]
10819    fn subscribe_after_backfill_no_stale_events() {
10820        let dir = tmp_dir("sub-after-backfill");
10821        {
10822            let mut db = GraphDb::open(&dir).unwrap();
10823            for i in 0..10u32 {
10824                db.insert_node("Org", &format!("o{i}"), vec![]).unwrap();
10825                db.insert_node(
10826                    "Person",
10827                    &format!("p{i}"),
10828                    vec![("org_id".into(), Value::Str(format!("o{i}")))],
10829                )
10830                .unwrap();
10831            }
10832            // Create rule BEFORE subscribing — emit_deltas is false during backfill.
10833            db.create_rule(fk_rule()).unwrap();
10834
10835            // Subscribe AFTER the backfill — queue must be empty (no stale events).
10836            let sub = db.subscribe_all_rules().unwrap();
10837            // No events should have queued for the prior backfill.
10838            assert!(
10839                sub.try_recv().is_none(),
10840                "subscribe after backfill must see no stale events"
10841            );
10842
10843            // Inserting a new node now should fire an event (emit_deltas is now true).
10844            db.insert_node("Org", "o_new", vec![]).unwrap();
10845            db.insert_node(
10846                "Person",
10847                "p_new",
10848                vec![("org_id".into(), Value::Str("o_new".into()))],
10849            )
10850            .unwrap();
10851            let ev = sub.recv_timeout(std::time::Duration::from_millis(200));
10852            assert!(
10853                ev.is_some(),
10854                "edge-fire event must arrive after subscribe (emit_deltas=true)"
10855            );
10856        }
10857        let _ = std::fs::remove_dir_all(&dir);
10858    }
10859
10860    /// Gating regression: subscribe BEFORE a backfill → events flow.
10861    #[test]
10862    fn subscribe_before_backfill_events_flow() {
10863        let dir = tmp_dir("sub-before-backfill");
10864        {
10865            let mut db = GraphDb::open(&dir).unwrap();
10866            // Subscribe FIRST — emit_deltas becomes true.
10867            let sub = db.subscribe_all_rules().unwrap();
10868
10869            for i in 0..5u32 {
10870                db.insert_node("Org", &format!("o{i}"), vec![]).unwrap();
10871                db.insert_node(
10872                    "Person",
10873                    &format!("p{i}"),
10874                    vec![("org_id".into(), Value::Str(format!("o{i}")))],
10875                )
10876                .unwrap();
10877            }
10878            // Backfill fires with emit_deltas=true → events queued.
10879            db.create_rule(fk_rule()).unwrap();
10880
10881            // Should receive at least one edge-fired event from the backfill.
10882            let mut received = 0usize;
10883            while sub.try_recv().is_some() {
10884                received += 1;
10885            }
10886            assert!(
10887                received > 0,
10888                "subscribe before backfill must receive edge-fire events (got 0)"
10889            );
10890        }
10891        let _ = std::fs::remove_dir_all(&dir);
10892    }
10893
10894    /// Companion: when a view IS defined, the delta path fires and view values update.
10895    #[test]
10896    fn delta_copy_fires_when_view_exists() {
10897        use core_rules::ViewSource;
10898        DELTA_COPY_COUNT.with(|c| c.set(0));
10899        let dir = tmp_dir("delta-copy-with-view");
10900        {
10901            let mut db = GraphDb::open(&dir).unwrap();
10902            db.insert_node("Org", "o1", vec![]).unwrap();
10903            db.insert_node(
10904                "Person",
10905                "p1",
10906                vec![("org_id".into(), Value::Str("o1".into()))],
10907            )
10908            .unwrap();
10909            // Declare a Degree view so is_empty() returns false.
10910            db.create_view(ViewDef {
10911                name: "degree_out".into(),
10912                label: "Person".into(),
10913                view_prop: "degree_out".into(),
10914                source: ViewSource::Degree {
10915                    edge_type: "WORKS_AT".into(),
10916                    direction: Direction::Out,
10917                },
10918            })
10919            .unwrap();
10920            db.create_rule(fk_rule()).unwrap();
10921
10922            // At least one delta copy should have happened (CreateRule backfill).
10923            let copies = DELTA_COPY_COUNT.with(|c| c.get());
10924            assert!(
10925                copies > 0,
10926                "expected delta copy to fire when a view is defined"
10927            );
10928
10929            // View value should be computed: p1 has one WORKS_AT out-edge.
10930            let info = db.node_info("p1").unwrap();
10931            let degree = info.props.get("degree_out");
10932            assert!(
10933                degree.is_some(),
10934                "view prop should be written to node props"
10935            );
10936        }
10937        let _ = std::fs::remove_dir_all(&dir);
10938    }
10939
10940    /// Regression: `open_at_with` must call `rebuild_all` after WAL replay so
10941    /// derived-edge-driven view values reflect the as-of state rather than just
10942    /// the initial backfill written at `CreateView` time.
10943    ///
10944    /// Base WAL frames (indices 0..=5 before history markers):
10945    ///   0: insert Org "o1"
10946    ///   1: create_view "employee_count" (Degree / WORKS_AT / In) on Org
10947    ///   2: create_rule fk_rule (WORKS_AT, Person→Org via org_id)
10948    ///   3: insert Person "p1" → rule fires WORKS_AT p1→o1 (degree = 1)  ← mid
10949    ///   4: insert Person "p2" → rule fires WORKS_AT p2→o1 (degree = 2)
10950    ///   5: insert Person "p3" → rule fires WORKS_AT p3→o1 (degree = 3)  ← latest
10951    ///
10952    /// Each rule-fire also appends a DerivedEdgeAdded history-marker frame (state
10953    /// no-op), so the total commit count is higher than the base frame count.
10954    /// The "latest" open_at commit is computed dynamically via `wal_commit_count_at`.
10955    ///
10956    /// Without `rebuild_all`, the as-of instance's "emp" view stays at the
10957    /// initial backfill value (0) instead of reflecting the replayed derived edges.
10958    #[test]
10959    fn open_at_derived_edge_view_values_correct() {
10960        use core_rules::ViewSource;
10961        let dir = tmp_dir("open-at-view-rebuild");
10962        {
10963            let mut db = GraphDb::open(&dir).unwrap();
10964            // frame 0
10965            db.insert_node("Org", "o1", vec![]).unwrap();
10966            // frame 1: create view — initial backfill sees 0 derived edges (none fired yet)
10967            db.create_view(ViewDef {
10968                name: "employee_count".into(),
10969                label: "Org".into(),
10970                view_prop: "emp".into(),
10971                source: ViewSource::Degree {
10972                    edge_type: "WORKS_AT".into(),
10973                    direction: Direction::In,
10974                },
10975            })
10976            .unwrap();
10977            // frame 2: create rule — no Persons yet; backfill is a no-op
10978            db.create_rule(fk_rule()).unwrap();
10979            // frame 3: p1 — rule fires WORKS_AT p1→o1; degree = 1
10980            db.insert_node(
10981                "Person",
10982                "p1",
10983                vec![("org_id".into(), Value::Str("o1".into()))],
10984            )
10985            .unwrap();
10986            // frame 4: p2 — degree = 2
10987            db.insert_node(
10988                "Person",
10989                "p2",
10990                vec![("org_id".into(), Value::Str("o1".into()))],
10991            )
10992            .unwrap();
10993            // frame 5: p3 — degree = 3
10994            db.insert_node(
10995                "Person",
10996                "p3",
10997                vec![("org_id".into(), Value::Str("o1".into()))],
10998            )
10999            .unwrap();
11000            // Sanity: normal open sees degree = 3.
11001            assert_eq!(
11002                db.get_view_prop("o1", "emp"),
11003                Some(Value::Int(3)),
11004                "normal db must show degree 3 after 3 derived edges"
11005            );
11006        } // WAL flushed
11007
11008        // Re-open normally to get the authoritative reference value.
11009        let normal_db = GraphDb::open(&dir).unwrap();
11010        let normal_emp = normal_db.get_view_prop("o1", "emp");
11011        assert_eq!(
11012            normal_emp,
11013            Some(Value::Int(3)),
11014            "re-opened normal db must show degree 3"
11015        );
11016
11017        // Latest as-of (last WAL commit): must match the normal open.
11018        // History-marker frames are appended after each rule-fire, so the total
11019        // commit count is computed dynamically rather than hardcoded.
11020        let total = crate::wal_commit_count_at(&dir).unwrap();
11021        let aof_latest = GraphDb::open_at(&dir, total - 1).unwrap();
11022        assert_eq!(
11023            aof_latest.get_view_prop("o1", "emp"),
11024            normal_emp,
11025            "open_at latest: derived-edge view must equal normal open (rebuild_all required)"
11026        );
11027
11028        // Mid-history as-of (commit 3 = p1 insert Batch frame): only p1; degree = 1.
11029        // The DerivedEdgeAdded marker for p1 is at frame 4 (state no-op on replay),
11030        // so replaying 0..=3 correctly re-derives only the p1→o1 edge.
11031        let aof_mid = GraphDb::open_at(&dir, 3).unwrap();
11032        assert_eq!(
11033            aof_mid.get_view_prop("o1", "emp"),
11034            Some(Value::Int(1)),
11035            "open_at mid-history: only p1 exists at frame 3, degree must be 1"
11036        );
11037
11038        let _ = std::fs::remove_dir_all(&dir);
11039    }
11040
11041    /// Pin: subscribe_* on an as-of instance must return Err(ReadOnly) —
11042    /// as-of instances never commit, so distribute_events never runs and any
11043    /// subscription would wait forever.
11044    #[test]
11045    fn subscribe_on_as_of_returns_read_only_error() {
11046        let dir = tmp_dir("sub-as-of-read-only");
11047        {
11048            let mut db = GraphDb::open(&dir).unwrap();
11049            db.insert_node("Org", "o1", vec![]).unwrap();
11050            db.create_rule(fk_rule()).unwrap();
11051        }
11052        let mut aof = GraphDb::open_at(&dir, 0).unwrap();
11053
11054        assert!(
11055            matches!(
11056                aof.subscribe_all_rules(),
11057                Err(core_storage::GraphError::ReadOnly)
11058            ),
11059            "subscribe_all_rules on as-of must return ReadOnly"
11060        );
11061        assert!(
11062            matches!(
11063                aof.subscribe_writes(),
11064                Err(core_storage::GraphError::ReadOnly)
11065            ),
11066            "subscribe_writes on as-of must return ReadOnly"
11067        );
11068        assert!(
11069            matches!(
11070                aof.subscribe_rule("works_at"),
11071                Err(core_storage::GraphError::ReadOnly)
11072            ),
11073            "subscribe_rule on as-of must return ReadOnly"
11074        );
11075        let _ = std::fs::remove_dir_all(&dir);
11076    }
11077
11078    /// Regression: a failed dense WAL rewrite must not leave speculative
11079    /// interns in `syms`. If it does, the next successful mutation logs an
11080    /// `Intern` record with an inflated id; replay (which never saw the
11081    /// orphans) assigns a smaller id and the WAL becomes unreplayable.
11082    #[test]
11083    fn dense_rewrite_error_rolls_back_speculative_interns() {
11084        let dir = tmp_dir("dense-rewrite-rollback");
11085        {
11086            let mut db = GraphDb::open(&dir).unwrap();
11087            db.insert_node("Person", "a", vec![]).unwrap();
11088
11089            // Bypass MutPreview validation to hit the rewrite's own error path
11090            // (same shape as an id-exhaustion failure mid-rewrite). The
11091            // InsertEdge arm interns the edge type before it resolves keys.
11092            let err = db.rewrite_wal_dense(vec![WalRecord::InsertEdge {
11093                edge_type: "ORPHAN_TYPE".into(),
11094                src_key: "missing".into(),
11095                dst_key: "a".into(),
11096            }]);
11097            assert!(err.is_err(), "rewrite of a missing src key must fail");
11098            assert_eq!(
11099                db.syms.get("ORPHAN_TYPE"),
11100                None,
11101                "failed rewrite must roll back speculative interns"
11102            );
11103
11104            // A later successful mutation must produce a replayable WAL.
11105            db.set_prop("a", "later_field", Value::Int(2)).unwrap();
11106        }
11107        let db = GraphDb::open(&dir).expect("WAL must replay after failed rewrite");
11108        assert_eq!(db.get_prop("a", "later_field"), Some(Value::Int(2)));
11109        let _ = std::fs::remove_dir_all(&dir);
11110    }
11111}