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::{ret_val_label, 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    /// The oldest commit index history still reaches (the WAL horizon floor).
279    /// `0` means nothing has been pruned and history is complete; a non-zero
280    /// value means events before that commit were pruned and are gone.
281    #[serde(default)]
282    pub history_floor: u64,
283}
284
285/// One rule's provenance size, trip latch, and fire counter.
286///
287/// `tripped` is a one-way latch: once set, the engine adds no new edges for
288/// that rule until [`GraphDb::rebuild_rule`] (and only if the full desired
289/// set then fits). `fires` counts `on_node_changed` evaluations plus
290/// backfill/rebuild participant ticks (rebuild counts even when it is a
291/// provenance no-op).
292#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
293pub struct RuleStats {
294    pub name: String,
295    pub edges: u64,
296    pub tripped: bool,
297    pub fires: u64,
298    /// Whether this rule uses the approximate IVF-Flat candidate path.
299    pub approximate: bool,
300}
301
302/// One entry in the slow-query ring buffer.
303#[derive(Debug, Clone, Serialize)]
304pub struct SlowQueryEntry {
305    /// Execution time in whole milliseconds.
306    pub ms: u64,
307    /// The Cypher query string that was slow.
308    pub query: String,
309    /// The commit sequence number at the time the query ran.
310    pub at_commit: u64,
311}
312
313/// Snapshot of the slow-query log returned by [`GraphDb::slow_query_snapshot`].
314#[derive(Debug, Clone, Serialize)]
315pub struct SlowQuerySnapshot {
316    /// Current threshold in milliseconds (0 = disabled).
317    pub threshold_ms: u64,
318    /// Total number of slow queries ever recorded (not capped by ring size).
319    pub count: u64,
320    /// Most-recent slow queries (up to 16), oldest first.
321    pub last: Vec<SlowQueryEntry>,
322}
323
324/// Internal ring-buffer state protected by a `Mutex` so `query(&self)` can
325/// write to it without a mutable borrow.
326struct SlowQueryLog {
327    entries: std::collections::VecDeque<SlowQueryEntry>,
328    total: u64,
329}
330
331/// Maximum number of entries kept in the slow-query ring buffer.
332const SLOW_QUERY_RING_CAP: usize = 16;
333
334/// Wire summary of a [`Predicate`]. JSON only — `Explanation` is never
335/// bincode-persisted (WAL/snapshots store `RuleDef` bytes, not this type).
336#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
337pub struct PredicateSummary {
338    pub kind: String,
339    pub fields: Vec<String>,
340    pub min: Option<f64>,
341    pub tolerance: Option<f64>,
342    pub km: Option<f64>,
343    pub parts: Option<Vec<PredicateSummary>>,
344    /// True when the owning rule has `approximate=true` (IVF-Flat candidate path).
345    /// Always false for predicates reported without rule context (sub-predicates in `parts`).
346    #[serde(default)]
347    pub approximate: bool,
348}
349
350impl From<&Predicate> for PredicateSummary {
351    fn from(p: &Predicate) -> Self {
352        match p {
353            Predicate::KeyMatch { field } => PredicateSummary {
354                kind: "key_match".into(),
355                fields: vec![field.clone()],
356                min: None,
357                tolerance: None,
358                km: None,
359                parts: None,
360                approximate: false,
361            },
362            Predicate::FieldEqual { field } => PredicateSummary {
363                kind: "field_equal".into(),
364                fields: vec![field.clone()],
365                min: None,
366                tolerance: None,
367                km: None,
368                parts: None,
369                approximate: false,
370            },
371            Predicate::Overlap { field, min } => PredicateSummary {
372                kind: "overlap".into(),
373                fields: vec![field.clone()],
374                min: Some(*min),
375                tolerance: None,
376                km: None,
377                parts: None,
378                approximate: false,
379            },
380            Predicate::NumericWithin { field, tolerance } => PredicateSummary {
381                kind: "numeric_within".into(),
382                fields: vec![field.clone()],
383                min: None,
384                tolerance: Some(*tolerance),
385                km: None,
386                parts: None,
387                approximate: false,
388            },
389            Predicate::GeoRadius { field, km } => PredicateSummary {
390                kind: "geo_radius".into(),
391                fields: vec![field.clone()],
392                min: None,
393                tolerance: None,
394                km: Some(*km),
395                parts: None,
396                approximate: false,
397            },
398            Predicate::VectorSimilar { field, min } => PredicateSummary {
399                kind: "vector_similar".into(),
400                fields: vec![field.clone()],
401                min: Some(*min),
402                tolerance: None,
403                km: None,
404                parts: None,
405                approximate: false,
406            },
407            Predicate::All(inner) => {
408                let parts: Vec<PredicateSummary> = inner.iter().map(Self::from).collect();
409                let mut fields = Vec::new();
410                for part in &parts {
411                    for f in &part.fields {
412                        if !fields.contains(f) {
413                            fields.push(f.clone());
414                        }
415                    }
416                }
417                PredicateSummary {
418                    kind: "all".into(),
419                    fields,
420                    min: None,
421                    tolerance: None,
422                    km: None,
423                    parts: Some(parts),
424                    approximate: false,
425                }
426            }
427            Predicate::Any(inner) => {
428                let parts: Vec<PredicateSummary> = inner.iter().map(Self::from).collect();
429                let mut fields = Vec::new();
430                for part in &parts {
431                    for f in &part.fields {
432                        if !fields.contains(f) {
433                            fields.push(f.clone());
434                        }
435                    }
436                }
437                PredicateSummary {
438                    kind: "any".into(),
439                    fields,
440                    min: None,
441                    tolerance: None,
442                    km: None,
443                    parts: Some(parts),
444                    approximate: false,
445                }
446            }
447        }
448    }
449}
450
451/// Snapshot of a live node's key, label, and columnar properties.
452///
453/// `props` is a [`BTreeMap`] so field order is deterministic (sorted by name)
454/// regardless of insert order or the columnar store's `HashMap` iteration.
455///
456/// Deliberately does not derive `Serialize`: `Value`'s serde form is
457/// internally tagged. Wire JSON is built by `value_to_json` in the server.
458#[derive(Debug, Clone, PartialEq)]
459pub struct NodeInfo {
460    pub key: String,
461    pub label: String,
462    pub props: BTreeMap<String, Value>,
463}
464
465/// Counts returned by [`GraphDb::delete_node`].
466#[derive(Debug, Clone, PartialEq, Eq, Default)]
467pub struct DeleteReport {
468    /// Number of manual (user-inserted) edges removed.
469    pub manual_edges: u64,
470    /// Number of derived (rule-owned) edges retracted.
471    pub derived_edges: u64,
472}
473
474/// One directed edge incident on a node, with provenance membership.
475///
476/// `derived` is true iff `(edge_type, src, dst)` is in the rule engine's
477/// Plan-8 `by_node` provenance index.
478#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
479pub struct EdgeInfo {
480    pub edge_type: String,
481    pub src_key: String,
482    pub dst_key: String,
483    pub derived: bool,
484}
485
486/// One directed edge incident on a node at a point in WAL history, with the
487/// rule that derived it when it is rule-owned.
488///
489/// Returned by [`GraphDb::edges_at`] (sorted by `(edge_type, src_key, dst_key)`)
490/// and by [`GraphDb::what_if_set_prop`].
491#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)]
492pub struct EdgeAt {
493    pub edge_type: String,
494    pub src_key: String,
495    pub dst_key: String,
496    /// `true` when a rule wrote the edge (`DerivedEdgeAdded` in the WAL, or a
497    /// live provenance entry).
498    pub derived: bool,
499    /// The rule that derived the edge. `None` for a manual edge.
500    pub rule: Option<String>,
501}
502
503/// The derived edges a hypothetical property change would retract and derive.
504///
505/// Returned by [`GraphDb::what_if_set_prop`]. Both lists are sorted by
506/// `(edge_type, src_key, dst_key)` and every entry is rule-derived.
507#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
508pub struct WhatIf {
509    /// Derived edges that exist now and would be retracted.
510    pub lost: Vec<EdgeAt>,
511    /// Derived edges that do not exist now and would be derived.
512    pub gained: Vec<EdgeAt>,
513}
514
515/// An edge with mask-aware endpoint visibility.
516///
517/// Returned by [`GraphDb::node_edges_masked`] in [`crate::mask::MaskMode::Stub`]
518/// mode — hidden endpoints carry `*_restricted: true`.
519#[derive(Debug, Clone, PartialEq, Eq)]
520pub struct MaskedEdge {
521    pub edge_type: String,
522    pub src_key: String,
523    /// `true` when `src_key` is in the DB but hidden from the mask.
524    pub src_restricted: bool,
525    pub dst_key: String,
526    /// `true` when `dst_key` is in the DB but hidden from the mask.
527    pub dst_restricted: bool,
528    pub derived: bool,
529}
530
531/// Result of a mask-aware node lookup via [`GraphDb::node_info_masked`].
532///
533/// `None` from that method means the key does not exist (→ 404).
534/// `Some(Restricted)` is only produced when `mask.mode() == MaskMode::Stub`.
535#[derive(Debug, PartialEq)]
536pub enum MaskedNodeResult {
537    Visible(NodeInfo),
538    /// Node exists in the DB but is hidden from this mask.
539    Restricted,
540}
541
542/// One rule-owned edge between two nodes, with the rule name, edge type,
543/// direction (src_key → dst_key), and weight if the rule stores one.
544#[derive(Debug, Clone, PartialEq, Serialize)]
545pub struct Explanation {
546    pub rule: String,
547    pub edge_type: String,
548    pub src_key: String,
549    pub dst_key: String,
550    pub weight: Option<f64>,
551    pub predicate: PredicateSummary,
552    /// For a via-hop rule, the edge type the rule hops over to reach its
553    /// candidates. `None` for a plain two-node rule. A via-hop rule whose
554    /// `via_edge` is itself rule-derived is the chaining case: the hop edge
555    /// was written by another rule in the same commit.
556    #[serde(default)]
557    pub via_edge: Option<String>,
558}
559
560/// Report returned by [`GraphDb::backup_to`].
561#[derive(Debug, Clone)]
562pub struct BackupReport {
563    /// Filenames copied into the destination directory (sorted ascending).
564    pub files: Vec<String>,
565    /// Total bytes written across all copied files.
566    pub bytes: u64,
567    /// `true` when the destination opened cleanly and passed post-copy checks.
568    ///
569    /// For stores that have a `snapshot.bin` this means: all V8 section CRCs
570    /// matched **and** the destination opened without error.
571    ///
572    /// For WAL-only stores (no `snapshot.bin`) there is no snapshot to
573    /// CRC-check; `verified` is `true` when the destination opened and
574    /// replayed the WAL without error (record-level checksums in the WAL
575    /// provide the integrity signal, not section CRCs).
576    pub verified: bool,
577}
578
579/// One directed edge in export form, with optional rule attribution for derived edges.
580///
581/// Returned by [`GraphDb::all_edges_for_export`].
582///
583/// Does not derive `Eq`/`Ord`: `weight` is an `f64` and NaN breaks a total
584/// order. Callers that need a stable edge ordering already sort by
585/// `(edge_type, src, dst)` explicitly (see `all_edges_for_export`).
586#[derive(Debug, Clone, PartialEq, PartialOrd)]
587pub struct ExportEdge {
588    pub edge_type: String,
589    pub src: String,
590    pub dst: String,
591    pub derived: bool,
592    /// Rule name that created this edge, if derived. `None` for manual edges.
593    pub rule: Option<String>,
594    /// The creating rule's declared `weight_prop`, read off this edge, when
595    /// derived and numeric (`Int`/`Float`). `None` for manual edges, derived
596    /// edges whose rule declares no `weight_prop`, or a non-numeric value.
597    pub weight: Option<f64>,
598}
599
600/// One edge type's shape, as [`GraphDb::edge_type_census`] counts it.
601///
602/// Deliberately per *type* and not per edge: everything here is a summary a
603/// caller can print in one line, and none of it costs a record per edge.
604#[derive(Debug, Clone, PartialEq, Eq)]
605pub struct EdgeTypeCensus {
606    pub edge_type: String,
607    /// Directed edges of this type. Counted the way
608    /// [`GraphDb::edge_count`] counts: each edge once, from its source.
609    pub edges: u64,
610    /// Every label seen on a source of this type, sorted.
611    pub src_labels: Vec<String>,
612    /// Every label seen on a destination of this type, sorted.
613    pub dst_labels: Vec<String>,
614    /// The rules that declare this `edge_type`, sorted. Empty for a type
615    /// written by hand.
616    pub rules: Vec<String>,
617    /// `(src key, dst key)` of the first edge of this type in the store's own
618    /// id order — a real pair to quote in an example.
619    pub sample: Option<(String, String)>,
620}
621
622/// Construct the standard write-query result set (columns: created, properties_set, deleted).
623fn write_result_set() -> ResultSet {
624    ResultSet::new(vec![
625        "created".into(),
626        "properties_set".into(),
627        "deleted".into(),
628    ])
629}
630
631fn resolve_merge_set_value(op: &Operand, params: &BTreeMap<String, Value>) -> Result<Value> {
632    match op {
633        Operand::Lit(v) => Ok(v.clone()),
634        Operand::Param(name) => params
635            .get(name)
636            .cloned()
637            .ok_or_else(|| GraphError::QueryError {
638                detail: format!("missing parameter `{name}`"),
639            }),
640        _ => Err(GraphError::QueryError {
641            detail: "ON CREATE/ON MATCH SET value must be a literal or $parameter".into(),
642        }),
643    }
644}
645
646fn operand_node_vars(op: &Operand, out: &mut Vec<String>) {
647    match op {
648        Operand::Prop { var, .. } | Operand::Var(var) => {
649            if !out.contains(var) {
650                out.push(var.clone());
651            }
652        }
653        Operand::FuncCall { args, .. } => {
654            for arg in args {
655                operand_node_vars(arg, out);
656            }
657        }
658        Operand::BinArith { left, right, .. } => {
659            operand_node_vars(left, out);
660            operand_node_vars(right, out);
661        }
662        Operand::Case { branches, default } => {
663            // Branch conditions reference vars already bound (and mask-filtered)
664            // by the MATCH phase, so collecting from the value operands + ELSE
665            // is sufficient for RETURN-projection var discovery.
666            for (_, value) in branches {
667                operand_node_vars(value, out);
668            }
669            if let Some(d) = default {
670                operand_node_vars(d, out);
671            }
672        }
673        Operand::Index { base, index } => {
674            operand_node_vars(base, out);
675            operand_node_vars(index, out);
676        }
677        Operand::Lit(_) | Operand::Param(_) => {}
678    }
679}
680
681fn ret_node_vars(items: &[RetItem]) -> Vec<String> {
682    let mut out = Vec::new();
683    for item in items {
684        match &item.value {
685            RetVal::Var(v) | RetVal::Prop { var: v, .. } => {
686                if !out.contains(v) {
687                    out.push(v.clone());
688                }
689            }
690            RetVal::FuncCall { args, .. } => {
691                for arg in args {
692                    operand_node_vars(arg, &mut out);
693                }
694            }
695            RetVal::ScalarExpr(op) => operand_node_vars(op, &mut out),
696            RetVal::Agg { .. } => {}
697        }
698    }
699    out
700}
701
702fn add_var(out: &mut Vec<String>, v: &str) {
703    if !out.iter().any(|x| x == v) {
704        out.push(v.to_string());
705    }
706}
707
708fn pattern_node_vars(pats: &[Pattern]) -> Vec<String> {
709    let mut out = Vec::new();
710    for p in pats {
711        if let Some(v) = &p.start.var {
712            add_var(&mut out, v);
713        }
714        for (_, dest) in &p.chain {
715            if let Some(v) = &dest.var {
716                add_var(&mut out, v);
717            }
718        }
719    }
720    out
721}
722
723fn pattern_rel_vars(pats: &[Pattern]) -> Vec<String> {
724    let mut out = Vec::new();
725    for p in pats {
726        for (rel, _) in &p.chain {
727            if rel.hops.is_none() {
728                if let Some(v) = &rel.var {
729                    add_var(&mut out, v);
730                }
731            }
732        }
733    }
734    out
735}
736
737fn rel_type_alias(var: &str) -> String {
738    format!("__rt_{var}")
739}
740
741fn ret_column_name(item: &RetItem) -> String {
742    if let Some(alias) = &item.alias {
743        return alias.clone();
744    }
745    // The same naming rule the planner and the executor use, so a
746    // write-statement RETURN names its columns exactly as a read query does.
747    // An aggregate is not legal in a write-statement RETURN; it keeps the
748    // placeholder it always had.
749    ret_val_label(&item.value).unwrap_or_else(|| "<agg>".to_string())
750}
751
752fn eval_set_return_operand<F: Fs>(
753    db: &GraphDb<F>,
754    match_rs: &ResultSet,
755    row: usize,
756    rel_vars: &[String],
757    op: &Operand,
758    params: &BTreeMap<String, Value>,
759) -> Result<Option<Value>> {
760    match op {
761        Operand::Lit(v) => Ok(Some(v.clone())),
762        Operand::Param(name) => params.get(name).cloned().ok_or_else(|| GraphError::QueryError {
763            detail: format!("missing parameter `{name}`"),
764        }).map(Some),
765        Operand::Var(name) if rel_vars.iter().any(|r| r == name) => Err(GraphError::QueryError {
766            detail: format!(
767                "cannot return relationship variable '{name}' bare; return its properties ({name}.field) instead"
768            ),
769        }),
770        Operand::Var(name) => Ok(match_rs.get(row, name).cloned()),
771        Operand::Prop { var, field } => {
772            if rel_vars.iter().any(|r| r == var) {
773                return Ok(None);
774            }
775            let Some(Value::Str(key)) = match_rs.get(row, var) else {
776                return Ok(None);
777            };
778            Ok(db.get_prop(key, field))
779        }
780        Operand::FuncCall { name, args } => {
781            eval_set_return_func(db, match_rs, row, rel_vars, name, args, params)
782        }
783        Operand::BinArith { op, left, right } => {
784            let lv = eval_set_return_operand(db, match_rs, row, rel_vars, left, params)?;
785            let rv = eval_set_return_operand(db, match_rs, row, rel_vars, right, params)?;
786            eval_set_return_arith(op, lv, rv)
787        }
788        // CASE is supported in read-query RETURN; in a write-statement RETURN
789        // projection (CREATE/MERGE/SET … RETURN) it is not yet wired.
790        Operand::Case { .. } => Err(GraphError::QueryError {
791            detail: "CASE is not supported in a write-statement RETURN projection; \
792                     use a read query"
793                .into(),
794        }),
795        // Same as CASE: a list subscript is supported in a read-query RETURN
796        // but not yet in a write-statement RETURN projection.
797        Operand::Index { .. } => Err(GraphError::QueryError {
798            detail: "a list subscript is not supported in a write-statement RETURN \
799                     projection; use a read query"
800                .into(),
801        }),
802    }
803}
804
805fn eval_set_return_arith(
806    op: &ArithOp,
807    lv: Option<Value>,
808    rv: Option<Value>,
809) -> Result<Option<Value>> {
810    match (lv, rv) {
811        (None, _) | (_, None) => Ok(None),
812        (Some(Value::Int(a)), Some(Value::Int(b))) => {
813            let result = match op {
814                ArithOp::Sub => a.saturating_sub(b),
815                ArithOp::Mul => a.saturating_mul(b),
816                ArithOp::Add => a.saturating_add(b),
817                ArithOp::Div => {
818                    if b == 0 {
819                        return Err(GraphError::QueryError {
820                            detail: "division by zero".into(),
821                        });
822                    }
823                    a.checked_div(b).unwrap_or(i64::MAX)
824                }
825            };
826            Ok(Some(Value::Int(result)))
827        }
828        (Some(lv), Some(rv)) => {
829            let a = match &lv {
830                Value::Float(f) => *f,
831                Value::Int(i) => *i as f64,
832                _ => {
833                    return Err(GraphError::QueryError {
834                        detail: format!("arithmetic operand must be numeric, got {lv:?}"),
835                    })
836                }
837            };
838            let b = match &rv {
839                Value::Float(f) => *f,
840                Value::Int(i) => *i as f64,
841                _ => {
842                    return Err(GraphError::QueryError {
843                        detail: format!("arithmetic operand must be numeric, got {rv:?}"),
844                    })
845                }
846            };
847            let result = match op {
848                ArithOp::Sub => a - b,
849                ArithOp::Mul => a * b,
850                ArithOp::Add => a + b,
851                ArithOp::Div => {
852                    if b == 0.0 {
853                        return Err(GraphError::QueryError {
854                            detail: "division by zero".into(),
855                        });
856                    }
857                    a / b
858                }
859            };
860            Ok(Some(Value::Float(result)))
861        }
862    }
863}
864
865fn eval_set_return_func<F: Fs>(
866    db: &GraphDb<F>,
867    match_rs: &ResultSet,
868    row: usize,
869    rel_vars: &[String],
870    name: &str,
871    args: &[Operand],
872    params: &BTreeMap<String, Value>,
873) -> Result<Option<Value>> {
874    let norm = name.to_ascii_lowercase();
875    if norm == "type" {
876        if args.len() != 1 {
877            return Err(GraphError::QueryError {
878                detail: format!("type() requires exactly 1 argument, got {}", args.len()),
879            });
880        }
881        let Operand::Var(rel) = &args[0] else {
882            return Err(GraphError::QueryError {
883                detail: "type() argument must be a relationship variable (e.g. type(r))".into(),
884            });
885        };
886        return Ok(match_rs.get(row, &rel_type_alias(rel)).cloned());
887    }
888    if norm == "key" {
889        if args.len() != 1 {
890            return Err(GraphError::QueryError {
891                detail: format!("key() requires exactly 1 argument, got {}", args.len()),
892            });
893        }
894        let Operand::Var(var) = &args[0] else {
895            return Err(GraphError::QueryError {
896                detail: "key() argument must be a node variable (e.g. key(n))".into(),
897            });
898        };
899        if rel_vars.iter().any(|r| r == var) {
900            return Err(GraphError::QueryError {
901                detail: format!("key() argument `{var}` is a relationship, not a node"),
902            });
903        }
904        // MATCH rows bind node variables to their key string, so the column
905        // value *is* the key.
906        return Ok(match_rs.get(row, var).cloned());
907    }
908    let mut vals = Vec::with_capacity(args.len());
909    for arg in args {
910        vals.push(eval_set_return_operand(
911            db, match_rs, row, rel_vars, arg, params,
912        )?);
913    }
914    match norm.as_str() {
915        "tolower" => {
916            if vals.len() != 1 {
917                return Err(GraphError::QueryError {
918                    detail: format!("toLower() requires exactly 1 argument, got {}", vals.len()),
919                });
920            }
921            Ok(vals[0].clone().map(|val| match val {
922                Value::Str(s) => Value::Str(s.to_ascii_lowercase()),
923                other => other,
924            }))
925        }
926        "toupper" => {
927            if vals.len() != 1 {
928                return Err(GraphError::QueryError {
929                    detail: format!("toUpper() requires exactly 1 argument, got {}", vals.len()),
930                });
931            }
932            Ok(vals[0].clone().map(|val| match val {
933                Value::Str(s) => Value::Str(s.to_ascii_uppercase()),
934                other => other,
935            }))
936        }
937        "size" => match vals.first().cloned().flatten() {
938            None => Ok(None),
939            Some(Value::Str(s)) => Ok(Some(Value::Int(s.len() as i64))),
940            Some(Value::List(items)) => Ok(Some(Value::Int(items.len() as i64))),
941            Some(_) => Ok(None),
942        },
943        "coalesce" => Ok(vals.into_iter().flatten().next()),
944        "abs" => match vals.first().cloned().flatten() {
945            None => Ok(None),
946            Some(Value::Int(n)) => Ok(Some(Value::Int(n.saturating_abs()))),
947            Some(Value::Float(f)) => Ok(Some(Value::Float(f.abs()))),
948            Some(_) => Ok(None),
949        },
950        "round" => match vals.first().cloned().flatten() {
951            None => Ok(None),
952            Some(Value::Float(f)) => Ok(Some(Value::Float(f.round()))),
953            Some(Value::Int(n)) => Ok(Some(Value::Int(n))),
954            Some(_) => Ok(None),
955        },
956        "decay" => {
957            if vals.len() != 3 {
958                return Err(GraphError::QueryError {
959                    detail: format!("decay() requires exactly 3 arguments, got {}", vals.len()),
960                });
961            }
962            match (vals[0].clone(), vals[1].clone(), vals[2].clone()) {
963                (None, _, _) | (_, None, _) | (_, _, None) => Ok(None),
964                (Some(b), Some(a), Some(h)) => {
965                    let numeric = |v: Value| -> Result<f64> {
966                        match v {
967                            Value::Int(n) => Ok(n as f64),
968                            Value::Float(f) => Ok(f),
969                            other => Err(GraphError::QueryError {
970                                detail: format!(
971                                    "decay() requires numeric arguments, got {other:?}"
972                                ),
973                            }),
974                        }
975                    };
976                    let b = numeric(b)?;
977                    let a = numeric(a)?;
978                    let h = numeric(h)?;
979                    if h <= 0.0 {
980                        return Err(GraphError::QueryError {
981                            detail: "decay() requires halflife > 0".into(),
982                        });
983                    }
984                    Ok(Some(Value::Float(b * 0.5f64.powf(a / h))))
985                }
986            }
987        }
988        _ => Err(GraphError::QueryError {
989            detail: format!(
990                "unknown function `{name}`; supported: toLower, toUpper, size, coalesce, type, abs, round, decay, key"
991            ),
992        }),
993    }
994}
995
996fn eval_set_return_item<F: Fs>(
997    db: &GraphDb<F>,
998    match_rs: &ResultSet,
999    row: usize,
1000    rel_vars: &[String],
1001    item: &RetItem,
1002    params: &BTreeMap<String, Value>,
1003) -> Result<Option<Value>> {
1004    match &item.value {
1005        RetVal::Var(v) => eval_set_return_operand(
1006            db,
1007            match_rs,
1008            row,
1009            rel_vars,
1010            &Operand::Var(v.clone()),
1011            params,
1012        ),
1013        RetVal::Prop { var, field } => eval_set_return_operand(
1014            db,
1015            match_rs,
1016            row,
1017            rel_vars,
1018            &Operand::Prop {
1019                var: var.clone(),
1020                field: field.clone(),
1021            },
1022            params,
1023        ),
1024        RetVal::FuncCall { name, args } => {
1025            eval_set_return_func(db, match_rs, row, rel_vars, name, args, params)
1026        }
1027        RetVal::ScalarExpr(op) => eval_set_return_operand(db, match_rs, row, rel_vars, op, params),
1028        RetVal::Agg { .. } => Err(GraphError::QueryError {
1029            detail: "aggregates are not supported in MATCH … SET … RETURN".into(),
1030        }),
1031    }
1032}
1033
1034/// Project user RETURN from original MATCH rows after SET. No rematch.
1035fn project_set_return_rows<F: Fs>(
1036    db: &GraphDb<F>,
1037    rel_vars: &[String],
1038    match_rs: &ResultSet,
1039    returns: &[RetItem],
1040    params: &BTreeMap<String, Value>,
1041) -> Result<ResultSet> {
1042    let columns: Vec<String> = returns.iter().map(ret_column_name).collect();
1043    let mut out = ResultSet::new(columns);
1044    for row in 0..match_rs.len() {
1045        let mut cells = Vec::with_capacity(returns.len());
1046        for item in returns {
1047            cells.push(eval_set_return_item(
1048                db, match_rs, row, rel_vars, item, params,
1049            )?);
1050        }
1051        out.push_row(cells);
1052    }
1053    Ok(out)
1054}
1055
1056/// Single construction point for a `GraphMut` view over the split-borrowed graph fields.
1057/// Callers use `std::mem::take` on the engine before calling this, then restore it after.
1058/// Extract a `Vec<f64>` from a `Value::List` whose items are all numeric.
1059/// Returns `None` for non-list values or lists with non-numeric elements.
1060fn value_as_float_list(v: &Value) -> Option<Vec<f64>> {
1061    match v {
1062        Value::List(items) => items
1063            .iter()
1064            .map(|item| match item {
1065                Value::Float(f) => Some(*f),
1066                Value::Int(i) => Some(*i as f64),
1067                _ => None,
1068            })
1069            .collect(),
1070        _ => None,
1071    }
1072}
1073
1074fn make_graph_mut<'a>(
1075    ids: &'a IdMap,
1076    syms: &'a mut Interner,
1077    labels: &'a [u32],
1078    props: core_storage::v8::seam::ColumnsView<'a>,
1079    topo: &'a mut Topology,
1080    base: &'a Option<std::sync::Arc<core_storage::v8::MappedBase>>,
1081    edge_props: &'a mut EdgeProps,
1082) -> GraphMut<'a> {
1083    GraphMut {
1084        ids,
1085        syms,
1086        labels,
1087        props,
1088        topo,
1089        base_topo: base_csr(base),
1090        edge_props,
1091    }
1092}
1093
1094/// The archived CSR of an open V8 snapshot, for the rule engine's graph reads.
1095///
1096/// A store opened from a snapshot keeps its edges in the mapping and its
1097/// overlay empty, so a rule that reads the graph's shape has to see both.
1098fn base_csr(
1099    base: &Option<std::sync::Arc<core_storage::v8::MappedBase>>,
1100) -> Option<&core_storage::v8::layout::ArchivedCsr> {
1101    base.as_ref().map(|b| {
1102        b.topology()
1103            .expect("base topology section bounds validated at open")
1104    })
1105}
1106
1107/// Build a `ColumnsView` from the disjoint `props` overlay and optional V8 base.
1108///
1109/// Takes explicit field references rather than `&self` so the caller can hold
1110/// simultaneous mutable borrows of other fields (e.g. `syms`, `topo`).
1111fn build_props_view<'a>(
1112    props: &'a ColumnStore,
1113    base: &'a Option<std::sync::Arc<core_storage::v8::MappedBase>>,
1114) -> core_storage::v8::seam::ColumnsView<'a> {
1115    match base {
1116        None => core_storage::v8::seam::ColumnsView::owned(props),
1117        Some(b) => {
1118            let archived = b
1119                .columns()
1120                .expect("base columns section bounds validated at open");
1121            core_storage::v8::seam::ColumnsView::with_base_cached(props, archived, b.mixed_cache())
1122                .with_shared_strings(base_string_table(b))
1123        }
1124    }
1125}
1126
1127/// The base columns section paired with the string table that resolves its
1128/// string ids — what `ViewStore` needs to read a neighbour's string property
1129/// out of a V9 snapshot.
1130fn base_columns(
1131    base: &Option<std::sync::Arc<core_storage::v8::MappedBase>>,
1132) -> Option<core_storage::v8::seam::BaseColumns<'_>> {
1133    base.as_ref().map(|b| core_storage::v8::seam::BaseColumns {
1134        cols: b
1135            .columns()
1136            .expect("base columns section bounds validated at open"),
1137        strings: base_string_table(b),
1138    })
1139}
1140
1141/// The shared string table of a V9 base, or `None` for a pre-V9 one.
1142///
1143/// Every `ColumnsView` built over a base must carry it: without it a V9
1144/// snapshot's string columns, whose own tables are empty, read back as absent.
1145fn base_string_table(
1146    base: &core_storage::v8::MappedBase,
1147) -> Option<&core_storage::v8::layout::ArchivedStringTable> {
1148    base.string_table()
1149        .transpose()
1150        .expect("base strings section bounds validated at open")
1151}
1152
1153fn build_topo_view<'a>(
1154    overlay: &'a Topology,
1155    base: &'a Option<std::sync::Arc<core_storage::v8::MappedBase>>,
1156) -> core_storage::v8::seam::TopologyView<'a> {
1157    match base {
1158        None => core_storage::v8::seam::TopologyView::owned(overlay),
1159        Some(b) => {
1160            let archived_csr = b
1161                .topology()
1162                .expect("base topology section bounds validated at open");
1163            core_storage::v8::seam::TopologyView::with_base(overlay, archived_csr)
1164        }
1165    }
1166}
1167
1168/// When [`GraphDb`] calls `Fs::sync` after a WAL append.
1169///
1170/// Default is [`Strict`](FsyncPolicy::Strict): every `log_then_apply_with`
1171/// fsyncs (single `insert_node` / `set_prop`). Ingest and `write_batch`
1172/// emit one `WalRecord::Batch` and fsync once at that frame (Batched).
1173/// [`Relaxed`](FsyncPolicy::Relaxed) skips WAL sync; [`GraphDb::snapshot`]
1174/// is still durable via `write_atomic`. Crash-recovery DST stays Strict.
1175#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
1176pub enum FsyncPolicy {
1177    /// Every WAL commit calls `fs.sync` (today's behavior).
1178    #[default]
1179    Strict,
1180    /// Sync only at a `Batch` frame end. Single-op path stays Strict unless
1181    /// this policy is set on the database.
1182    Batched,
1183    /// Never call `fs.sync`. [`GraphDb::snapshot`] still syncs via `write_atomic`.
1184    Relaxed,
1185}
1186
1187/// A precondition for a compare-and-set batch write.
1188///
1189/// All preconditions in a [`GraphDb::write_batch_cas`] or
1190/// [`crate::SharedDb::submit_batch_cas`] call are checked atomically before
1191/// any operation in the batch is applied.  If any precondition fails, the
1192/// entire batch is rejected with [`GraphError::CasConflict`] and no WAL frame
1193/// is written.
1194///
1195/// # Touch definition
1196///
1197/// A node's last-change commit (`last_changed`) is updated when any of the
1198/// following state-changing WAL records touch it:
1199///
1200/// - `InsertNode` / `InsertNodeId` — the newly-inserted node.
1201/// - `SetProp` / `SetPropId` / `RemoveProp` — the property-bearing node.
1202/// - `InsertEdge` / `InsertEdgeId` / `DeleteEdge` — **both** src and dst
1203///   endpoints (an edge change touches both sides).
1204/// - `DeleteNode` — the node is tombstoned; `last_changed` returns `None`
1205///   for deleted keys so the pre-deletion entry is never observed.
1206///
1207/// History markers (`DerivedEdgeAdded` / `DerivedEdgeRetracted`) are
1208/// state no-ops.  The underlying mutation that triggered rule firing already
1209/// updated the relevant nodes' last-change entries.  Rule-management records
1210/// (`CreateRule`, `DeleteRule`, `RebuildRule`) and view/full-text declarations
1211/// do not touch any node's last-change.
1212#[derive(Debug, Clone, PartialEq, Eq)]
1213pub enum Precondition {
1214    /// The node's last-change commit must equal `expected`.
1215    ///
1216    /// Fails with [`GraphError::CasConflict`] when:
1217    /// - The node does not exist (`last_changed` returns `None`), or
1218    /// - The recorded commit seq does not match `expected`.
1219    NodeUnchangedSince { key: String, expected: u64 },
1220    /// The node must not exist (not inserted, or already deleted).
1221    ///
1222    /// Fails with [`GraphError::CasConflict`] (expected=`u64::MAX`,
1223    /// actual=`last_changed(key).unwrap_or(0)`) when the node is live.
1224    NodeAbsent { key: String },
1225}
1226
1227pub struct GraphDb<F: Fs> {
1228    fs: F,
1229    ids: IdMap,
1230    syms: Interner,
1231    topo: Topology,
1232    props: ColumnStore,
1233    labels: Vec<u32>, // node id -> label symbol
1234    edge_props: EdgeProps,
1235    engine: RuleEngine,
1236    view_store: ViewStore,
1237    /// Incremental inverted index for full-text-lite search.
1238    /// Rebuild-on-open: populated from WAL replay + rebuild_all at open end.
1239    fulltext: FulltextIndex,
1240    /// Opt-in equality index over scalar node properties.
1241    /// Rebuild-on-open: declarations replay from the WAL, postings rebuild at
1242    /// open end (mirrors `fulltext`).
1243    prop_index: PropertyIndex,
1244    event_sink: Option<Box<dyn Fn(MutationEvent) + Send + Sync>>,
1245    /// WAL fsync cadence. Default [`FsyncPolicy::Strict`].
1246    fsync: FsyncPolicy,
1247    /// Monotonically increasing per-commit counter.  A single `log_then_apply_with`
1248    /// call increments this once; all events emitted from that call share the same
1249    /// `commit_seq` value.
1250    commit_seq: u64,
1251    /// RBAC role definitions loaded from `roles.json` at open.
1252    ///
1253    /// `Some(roles)` — loaded successfully (may be empty when no roles are defined).
1254    /// `None` — `roles.json` was present but corrupt; `mask_for_role` returns
1255    /// `Err` for any request (fail-loud, never silently grant empty visibility).
1256    roles: Option<Vec<RoleDef>>,
1257    /// Memo for [`mask_for_role`](GraphDb::mask_for_role), keyed by
1258    /// `(role, commit_seq)` — a scoped reader between two writes resolves once.
1259    ///
1260    /// Shared by `Arc` with every [`ReaderSnapshot`](crate::reader::ReaderSnapshot)
1261    /// taken from this handle. Replaced (not cleared) whenever the role
1262    /// definitions change or the store is reloaded, which `commit_seq` does not
1263    /// record; see [`RoleMaskCache`](crate::mask::RoleMaskCache).
1264    role_masks: Arc<crate::mask::RoleMaskCache>,
1265    /// Live subscriptions.  Entries with a dead `Weak` are pruned on the next
1266    /// distribute_events call.
1267    subscriptions: Vec<SubEntry>,
1268    /// Live query subscriptions. Re-executed on every commit when non-empty.
1269    /// Dead `Weak` entries are pruned inside `distribute_events`.
1270    query_subscriptions: Vec<QuerySubEntry>,
1271    /// Queue capacity for new subscriptions created by this db.  Default is
1272    /// [`DEFAULT_SUB_CAPACITY`]; can be overridden via [`set_sub_capacity`]
1273    /// to test Lagged behaviour with small queues.
1274    sub_capacity: usize,
1275    /// True for as-of instances opened via [`GraphDb::open_at`].
1276    /// Every mutation method and `snapshot()` returns [`GraphError::ReadOnly`]
1277    /// when this flag is set.
1278    read_only: bool,
1279    /// Total WAL commit count at the time [`open_at`] was called.
1280    /// 0 for normal (non-as-of) instances.
1281    total_wal_commits: u64,
1282    /// Immutable mmap-backed base snapshot (V8).  When `Some`, `self.topo` is
1283    /// the WAL-replay overlay (empty at open time, populated by apply()) and
1284    /// reads go through a merged `TopologyView`.  `self.props` is always
1285    /// fully materialized (base + WAL replay) for HNSW/IVF and view compat.
1286    base: Option<Arc<core_storage::v8::MappedBase>>,
1287    // ── MVCC epoch reader state ───────────────────────────────────────────────
1288    /// Most-recent full overlay clone.  Initialized at end of `open_with` /
1289    /// `open_at_with`; refreshed every `FOLD_EVERY_K` commits.
1290    /// `None` only between struct creation and the first fold.
1291    fold_overlay: Option<Arc<crate::reader::FrozenOverlay>>,
1292    /// Per-commit deltas accumulated since the last fold.
1293    delta_tail: Vec<Arc<crate::reader::CommitDelta>>,
1294    /// How many commits have occurred since the last fold.
1295    commits_since_fold: usize,
1296    /// When true, `log_then_apply_with` buffers event notifications instead of
1297    /// firing them immediately.  Used by the group-commit drain thread to defer
1298    /// events until after the group fsync (R2: durability before notification).
1299    /// Cleared to false once the drain thread flushes or discards the buffer.
1300    defer_events: bool,
1301    /// Buffered events accumulated while `defer_events` is true.
1302    deferred_events: Vec<DeferredEvent>,
1303    /// Set to true by the group-commit drain thread when a group fsync fails
1304    /// after WAL truncation.  All subsequent mutation attempts return an IO
1305    /// error until the database is reopened.
1306    degraded: bool,
1307    /// Set to `true` after `ensure_v8_base_sections_loaded` has read provenance,
1308    /// HNSW, and IVF sections from the mmap base into the engine's retained
1309    /// fields.  `false` on all opens until first use; always `true` for non-V8
1310    /// opens (base is None, fast-path sets flag immediately).
1311    v8_sections_loaded: std::sync::atomic::AtomicBool,
1312    /// Serializes the one-time section population in `ensure_v8_base_sections_loaded`.
1313    v8_sections_mutex: std::sync::Mutex<()>,
1314    /// Per-node last-change commit sequence.  `last_change[node_id] = seq` means
1315    /// the node was last modified by commit `seq`.
1316    ///
1317    /// Loaded from V8 section 11 at open; updated on every state-changing commit
1318    /// and WAL replay frame.  V5-V7 stores start with an empty map; pre-WAL-horizon
1319    /// nodes return `None` from `last_changed` until they are next mutated.
1320    ///
1321    /// See [`Precondition`] for the full touch definition.
1322    last_change: HashMap<u32, u64>,
1323    /// WAL archive retention policy set by [`set_wal_archive_retention`].
1324    /// `None` = unlimited (keep all archives); `Some(N)` = keep N newest archives,
1325    /// pruning older ones at snapshot time.  0 is treated as unlimited.
1326    wal_archive_retention: Option<u32>,
1327    /// Global frame index of the first commit that is still reachable through
1328    /// surviving archives.  Persisted to `wal.floor` sidecar when pruning occurs.
1329    /// Default 0 = all history reachable.
1330    wal_horizon_floor: u64,
1331    /// True when the surviving archive chain forms a continuous WAL history
1332    /// starting from the store's first commit (the genesis chain).
1333    ///
1334    /// `open_at` may replay archive-resident commits from empty state only when
1335    /// this flag is true AND `wal_horizon_floor == 0`.  Cleared whenever:
1336    ///   - a WAL-truncating snapshot (`keep_wal=false`) is taken after archives
1337    ///     already exist (breaks the chain for subsequent archives), or
1338    ///   - any archive is pruned (floor advances past zero).
1339    ///
1340    /// Persisted via the `wal.genesis` marker file; loaded from it at open.
1341    archive_genesis_chain: bool,
1342    /// Transient write-authz context set by `write_batch_authz` /
1343    /// `query_write_authz` for the duration of ONE mutation call.
1344    /// Always `None` at rest.  Never serialized, never WAL-replayed.
1345    pending_write_authz: Option<WriteAuthz>,
1346    /// Slow-query threshold in milliseconds.  0 = disabled.
1347    /// Seeded from `MUSHROOMDB_SLOW_QUERY_MS` at open; override via
1348    /// [`GraphDb::set_slow_query_threshold_ms`] (tests must use the setter
1349    /// — env vars are process-global and race parallel test threads).
1350    slow_query_threshold_ms: u64,
1351    /// Ring buffer of recent slow queries (interior-mutable so `query(&self)`
1352    /// can record entries without requiring `&mut self`).
1353    slow_queries: std::sync::Mutex<SlowQueryLog>,
1354    /// Instant at which the database was opened (used by `/metrics` uptime).
1355    started_at: std::time::Instant,
1356    // ── Multi-process state (cross-process lock + WAL tailing) ────────────────
1357    /// Byte offset of the WAL prefix already applied to in-memory state.
1358    ///
1359    /// Advanced by exactly the encoded length of every frame this handle
1360    /// appends, and by the decoded byte count of every tail
1361    /// [`refresh`](GraphDb::refresh) absorbs. Rewound by
1362    /// [`set_wal_consumed`](GraphDb::set_wal_consumed) when the group-commit
1363    /// drain thread truncates a failed group. Compared against the WAL's
1364    /// on-disk length to decide staleness.
1365    wal_consumed: u64,
1366    /// Identity of the snapshot this handle's base state came from, as
1367    /// `(len, mtime_nanos)`. A different value means another process replaced
1368    /// the snapshot and the WAL no longer continues our state: refresh reloads.
1369    snapshot_ident: Option<(u64, u64)>,
1370    /// The options this handle was opened with. Replayed verbatim when
1371    /// `refresh` has to rebuild from disk.
1372    open_opts: OpenOptions,
1373    /// True when this handle holds the cross-process write lock for its whole
1374    /// lifetime (a plain read-write open). Per-write lock acquisition is a
1375    /// no-op on such a handle, and never releases the lock.
1376    holds_lifetime_lock: bool,
1377    /// True between a failed lock acquisition and the end of the write scope
1378    /// that failed. Makes every WAL-appending mutation in that scope return
1379    /// [`GraphError::Busy`] instead of writing.
1380    lock_denied: bool,
1381    /// True for an as-of view opened via [`GraphDb::open_at`]. Such a view is
1382    /// pinned to one commit, so it is never stale and never refreshes — later
1383    /// commits by any process are deliberately invisible to it.
1384    pinned: bool,
1385}
1386
1387/// One group of deferred event notifications, held until the group fsync
1388/// completes.  Replayed by [`GraphDb::flush_deferred_events`].
1389struct DeferredEvent {
1390    rec: core_storage::WalRecord,
1391    engine_deltas: Vec<EngineEdgeDelta>,
1392    seq: u64,
1393    ingest: Option<(String, usize)>,
1394}
1395
1396/// Options for [`GraphDb::open_with_options`].
1397#[derive(Clone, Copy, Debug)]
1398pub struct OpenOptions {
1399    /// Rewrite an old-format snapshot to the current VERSION after a
1400    /// successful load (default `true`). The old snapshot is kept as
1401    /// `snapshot.bin.bak` until the next clean open at the current version,
1402    /// at which point the `.bak` is deleted.
1403    ///
1404    /// Set to `false` to open a store without touching any on-disk files
1405    /// (useful for read-only inspection of a store at an older format).
1406    pub auto_migrate: bool,
1407
1408    /// Write the valid WAL prefix back over a torn tail on open (default
1409    /// `true`). Truncating a genuinely torn tail is correct crash recovery.
1410    ///
1411    /// Set to `false` for an unattended reader. The valid prefix is still
1412    /// decoded and replayed in memory, but nothing is written: a reader that
1413    /// opens while another process is mid-append would otherwise discard a
1414    /// frame that writer believes durable. `mushroomdb recall`, which runs on
1415    /// every prompt, passes `false` for exactly this reason.
1416    pub repair_wal: bool,
1417
1418    /// Open without ever writing to the store (default `false`).
1419    ///
1420    /// A read-only handle:
1421    /// - returns [`GraphError::ReadOnly`] from every mutation and from
1422    ///   `snapshot()`;
1423    /// - performs no disk write at open — no WAL repair write-back and no
1424    ///   auto-migration rewrite, whatever the other two flags say;
1425    /// - never takes the cross-process write lock, so it opens immediately even
1426    ///   while another process is writing, and never makes a writer wait.
1427    ///
1428    /// [`refresh`](GraphDb::refresh) and [`is_stale`](GraphDb::is_stale) work
1429    /// normally, so a read-only handle can follow another process's commits.
1430    pub read_only: bool,
1431}
1432
1433impl Default for OpenOptions {
1434    fn default() -> Self {
1435        Self {
1436            auto_migrate: true,
1437            repair_wal: true,
1438            read_only: false,
1439        }
1440    }
1441}
1442
1443/// How long a writer polls for the cross-process write lock before giving up
1444/// with [`GraphError::Busy`].
1445///
1446/// Long enough to ride out another process's commit (a batch apply plus one
1447/// fsync), short enough that a stuck peer surfaces as an error rather than a
1448/// hang.
1449pub const WRITE_LOCK_WAIT: std::time::Duration = std::time::Duration::from_secs(2);
1450
1451/// Interval between poll attempts while waiting for the cross-process lock.
1452pub(crate) const LOCK_POLL_INTERVAL: std::time::Duration = std::time::Duration::from_millis(10);
1453
1454/// Why `load_from_disk` is running, which decides whether it may repair.
1455#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1456enum LoadOrigin {
1457    /// A fresh open. Crash recovery is this handle's job: a torn WAL tail is
1458    /// the signature of a crash and truncating it is correct, and archives
1459    /// orphaned by an interrupted prune can be swept.
1460    Open,
1461    /// A reload driven by [`GraphDb::refresh`], because another process
1462    /// replaced the snapshot. Nothing here is crash recovery — the store is
1463    /// live and someone else is writing it — so this origin writes nothing.
1464    Reload,
1465}
1466
1467/// Authorization context carried by `write_batch_authz` / `query_write_authz`.
1468///
1469/// `None` at the call site = full authority (today's zero-cost behavior).
1470/// `Some(WriteAuthz)` = role-scoped: the decision table (plan §"authz decision
1471/// table") is evaluated per-op inside `commit_logged_batch` BEFORE any WAL
1472/// record is built.  A denial returns an error with no WAL frame written.
1473///
1474/// The mask is ALWAYS `Omit`-mode: role-token paths must never acknowledge
1475/// hidden-node existence to callers.
1476#[derive(Clone, Debug)]
1477pub struct WriteAuthz {
1478    pub role: String,
1479    pub scope: WriteScope,
1480    /// Resolved by `mask_for_role` under the same write guard as the mutation.
1481    /// Always `Omit`-mode — never `Stub`.
1482    pub mask: crate::mask::NodeMask,
1483}
1484
1485/// Write `bytes` to `snapshot.bin.bak` atomically with full fsync.
1486///
1487/// Uses [`RealFs::write_atomic`] which applies `F_FULLFSYNC` on macOS and
1488/// `sync_all` on other platforms, then renames the `.tmp` file into place and
1489/// syncs the directory entry. This is the only correct path for writing the
1490/// `.bak` — plain `std::fs::write + sync_all` misses both `F_FULLFSYNC` and
1491/// the directory sync.
1492pub fn write_snapshot_bak(dir: &std::path::Path, bytes: &[u8]) -> crate::Result<()> {
1493    use core_storage::fs::{FileId, Fs as _};
1494    RealFs::new(dir)
1495        .map_err(core_storage::GraphError::Io)?
1496        .write_atomic(FileId::SnapshotBak, bytes)
1497        .map_err(core_storage::GraphError::Io)
1498}
1499
1500/// Return the on-disk snapshot format version without decoding the full snapshot.
1501///
1502/// Reads only the 6-byte header (magic + version LE). Returns `None` when no
1503/// snapshot file exists (WAL-only store). Returns an error if the header is
1504/// malformed.
1505pub fn snapshot_version_at(dir: &std::path::Path) -> crate::Result<Option<u16>> {
1506    use std::io::Read as _;
1507    let path = dir.join("snapshot.bin");
1508    let mut header = [0u8; 6];
1509    let n = match std::fs::File::open(&path) {
1510        Ok(mut f) => f.read(&mut header).map_err(core_storage::GraphError::Io)?,
1511        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
1512        Err(e) => return Err(core_storage::GraphError::Io(e)),
1513    };
1514    core_storage::snapshot::peek_version(&header[..n])
1515}
1516
1517/// Options for [`GraphDb::snapshot_with`].
1518#[derive(Debug, Clone, Default)]
1519pub struct SnapshotOptions {
1520    /// When `true`, the WAL is preserved after the snapshot write.
1521    /// Pre-snapshot commits remain reachable via [`GraphDb::open_at`].
1522    /// When `false` (the default), the WAL is truncated to a minimal
1523    /// baseline so cold-start replay stays fast.
1524    pub keep_wal: bool,
1525    /// When `true`, the current WAL is renamed to `wal.<commit_seq>.archive`
1526    /// before a fresh WAL baseline is written (history-preserving snapshot).
1527    ///
1528    /// This is the feature opt-in: `false` (the default) leaves the existing
1529    /// truncation / keep-wal behaviour byte-identical.  `archive_wal` takes
1530    /// precedence over `keep_wal` when both are set.
1531    ///
1532    /// Archives can be scanned by [`GraphDb::node_history`],
1533    /// [`GraphDb::edge_history`], [`GraphDb::was_linked`], and
1534    /// [`GraphDb::open_at`], extending the reachable history horizon across
1535    /// snapshot boundaries.
1536    pub archive_wal: bool,
1537}
1538
1539/// Derive the scan-label sym for the commit-skip fast-path.
1540///
1541/// Walks `ops` to find the plan's leading scan op (`ScanLabel`, `IndexScan`,
1542/// or `IndexIntersect`) with a concrete label string, then interns it.
1543///
1544/// Returns `None` in all cases where skipping is unsafe:
1545/// - Any `Expand` op is present (edge traversal; edges change results regardless
1546///   of node labels).
1547/// - The leading scan has no label (`ScanLabel { label: None }` — full scan).
1548/// - No recognizable leading scan op is found.
1549///
1550/// This is the conservative v0.4.3 boundary. The caller stores the result in
1551/// [`QuerySubEntry::scan_label`] at subscribe time; `None` means always execute.
1552fn extract_scan_label(ops: &[PlanOp], syms: &mut Interner) -> Option<u32> {
1553    // Any Expand → must always re-execute (edges can change join results).
1554    if ops.iter().any(|op| matches!(op, PlanOp::Expand { .. })) {
1555        return None;
1556    }
1557    for op in ops {
1558        match op {
1559            PlanOp::ScanLabel {
1560                label: Some(label), ..
1561            } => return Some(syms.intern(label)),
1562            PlanOp::IndexScan {
1563                label: Some(label), ..
1564            } => return Some(syms.intern(label)),
1565            PlanOp::IndexIntersect {
1566                label: Some(label), ..
1567            } => return Some(syms.intern(label)),
1568            _ => {}
1569        }
1570    }
1571    None
1572}
1573
1574/// How an as-of read is restricted — the argument to
1575/// [`GraphDb::query_at_scoped`].
1576///
1577/// Every variant is resolved against the graph **as it was at the requested
1578/// commit**, not against the current graph.
1579#[derive(Debug, Clone, Copy)]
1580pub enum AsOfScope<'a> {
1581    /// Everything the named role may see. The role *definition* is the current
1582    /// one — `roles.json` is a sidecar and has no past version — but its
1583    /// `keys` and `labels` are resolved against the as-of graph.
1584    Role(&'a str),
1585    /// An explicit node-key allow-list. Keys that did not exist at that commit
1586    /// resolve to nothing.
1587    Keys(&'a [String]),
1588    /// A role intersected with a client-supplied allow-list. The intersection
1589    /// is the never-widen rule: a client mask can only narrow a role.
1590    RoleAndKeys(&'a str, &'a [String]),
1591}
1592
1593impl GraphDb<RealFs> {
1594    /// Open the database at `dir` with default options.
1595    ///
1596    /// Equivalent to `open_with_options(dir, OpenOptions::default())`.
1597    /// Old-format snapshots (V5, V6) are automatically migrated to the
1598    /// current version on a successful load (see [`OpenOptions::auto_migrate`]).
1599    pub fn open(dir: &std::path::Path) -> Result<Self> {
1600        Self::open_with_options(dir, OpenOptions::default())
1601    }
1602
1603    /// Open the database at `dir` with explicit options.
1604    ///
1605    /// When `opts.auto_migrate` is `true` (the default) and the on-disk
1606    /// snapshot is an older format version, this function:
1607    ///   1. Copies the current `snapshot.bin` to `snapshot.bin.bak` (atomic
1608    ///      + fsynced) before any modification.
1609    ///   2. Rewrites `snapshot.bin` at the current format version via
1610    ///      [`GraphDb::snapshot_with`] with `keep_wal: true` (WAL preserved).
1611    ///
1612    /// If migration fails the error is returned and the original files are
1613    /// intact (the `.bak` was written before the new snapshot was attempted).
1614    ///
1615    /// A clean open that finds the snapshot already at the current version
1616    /// deletes any leftover `.bak` file.
1617    ///
1618    /// WAL-only stores (no snapshot) are never auto-migrated on open.
1619    ///
1620    /// `opts.repair_wal` controls the other write this function can make; see
1621    /// [`OpenOptions::repair_wal`]. With both flags `false` the open touches
1622    /// no file on disk.
1623    pub fn open_with_options(dir: &std::path::Path, opts: OpenOptions) -> Result<Self> {
1624        Self::open_dir(dir, opts, true)
1625    }
1626
1627    /// Open without taking the cross-process write lock for the handle's
1628    /// lifetime.
1629    ///
1630    /// Only [`SharedDb`](crate::SharedDb) uses this: a long-lived server holds
1631    /// its handle open indefinitely, so it takes the lock per write instead of
1632    /// keeping every other process out of the store for as long as it runs.
1633    pub(crate) fn open_unlocked(dir: &std::path::Path) -> Result<Self> {
1634        Self::open_dir(dir, OpenOptions::default(), false)
1635    }
1636
1637    fn open_dir(dir: &std::path::Path, opts: OpenOptions, hold_lock: bool) -> Result<Self> {
1638        // Header-only peek — 6 bytes, no full decode.
1639        let snap_version = snapshot_version_at(dir)?;
1640
1641        // Full load: decode snapshot + replay WAL + rebuild indexes.
1642        let mut db = Self::open_generic(RealFs::new(dir)?, opts, hold_lock)?;
1643
1644        // A read-only handle writes nothing at open, so it never migrates —
1645        // the old-format snapshot is loaded and left exactly as it is.
1646        if opts.auto_migrate && !opts.read_only {
1647            match snap_version {
1648                Some(ver) if ver < core_storage::snapshot::VERSION => {
1649                    let _tm = std::time::Instant::now();
1650                    // Copy the original snapshot to .bak at OS level — no in-memory
1651                    // buffer required for a 2+ GiB file.
1652                    //
1653                    // Crash-safety: snapshot.bin remains intact (write_atomic inside
1654                    // snapshot_with uses a .tmp+rename) until the V8 write succeeds.
1655                    // A torn .bak on crash is acceptable because the original
1656                    // snapshot.bin is the authoritative source until after the rename.
1657                    std::fs::copy(dir.join("snapshot.bin"), dir.join("snapshot.bin.bak"))
1658                        .map_err(core_storage::GraphError::Io)?;
1659                    trace_migrate!("bak copy done", _tm);
1660                    // Rewrite snapshot at current version; keep WAL intact.
1661                    db.snapshot_with(SnapshotOptions {
1662                        keep_wal: true,
1663                        ..SnapshotOptions::default()
1664                    })?;
1665                    trace_migrate!("snapshot_with done", _tm);
1666                }
1667                Some(_) => {
1668                    // Already current version: remove any leftover .bak.
1669                    let bak = dir.join("snapshot.bin.bak");
1670                    if bak.exists() {
1671                        std::fs::remove_file(&bak).map_err(core_storage::GraphError::Io)?;
1672                    }
1673                }
1674                None => {
1675                    // WAL-only store — nothing to migrate on open.
1676                }
1677            }
1678        }
1679
1680        Ok(db)
1681    }
1682
1683    /// Open a read-only view of the database as it existed after `commit`.
1684    ///
1685    /// Commit indices are 0-based over the current WAL: commit 0 is the state
1686    /// after the first WAL frame, commit N-1 is the state after the N-th (most
1687    /// recent) frame.  Call [`GraphDb::open`] to read the full current state.
1688    ///
1689    /// **Replay base.** [`GraphDb::snapshot`] truncates the WAL when it runs,
1690    /// so as-of can only reach commits recorded in the current WAL (those
1691    /// written after the most recent snapshot, or all commits if no snapshot
1692    /// was ever taken).  Commit 0 in `open_at` always refers to the first
1693    /// frame in the WAL that exists on disk, not the first ever write to the
1694    /// database.  When the on-disk snapshot recorded that it truncated the
1695    /// WAL (V7, default `keep_wal: false`), it is loaded as the base state
1696    /// before frame replay, so the as-of view includes all pre-snapshot data.
1697    /// Snapshots written with `keep_wal: true` (and legacy V5/V6 snapshots)
1698    /// are ignored and replay is WAL-only, as before.
1699    ///
1700    /// **Read-only.** Every mutation method and `snapshot()` on the returned
1701    /// instance returns [`GraphError::ReadOnly`].  Queries, `explain()`, and
1702    /// `stats()` work normally.
1703    ///
1704    /// # Errors
1705    /// - [`GraphError::CommitOutOfRange`] if `commit >= wal_commit_count` (including
1706    ///   when the WAL is empty after a snapshot).
1707    pub fn open_at(dir: &std::path::Path, commit: u64) -> Result<Self> {
1708        Self::open_at_with(RealFs::new(dir)?, commit)
1709    }
1710
1711    /// Run a **read-only** Cypher query against the graph as it existed at
1712    /// `commit` — the "time-travel" / agent-replay query. Opens a temporal view
1713    /// of this store's directory at that commit and executes the read there.
1714    ///
1715    /// The current instance is unaffected. Write statements are rejected (the
1716    /// temporal view is read-only). `commit` is a 0-based WAL commit index;
1717    /// `commit == wal_commit_count` (or `open_at`'s range) yields the newest
1718    /// state. Prefer this over holding many historical instances open.
1719    ///
1720    /// # Errors
1721    /// - [`GraphError::CommitOutOfRange`] if `commit` is past the WAL horizon.
1722    /// - A query error for a malformed or write query.
1723    pub fn query_at(
1724        &self,
1725        commit: u64,
1726        cypher: &str,
1727        params: &std::collections::BTreeMap<String, Value>,
1728    ) -> Result<ResultSet> {
1729        let temporal = self.open_at_for_read(commit, cypher)?;
1730        temporal.query(cypher, params)
1731    }
1732
1733    /// Run a **read-only** Cypher query at `commit`, restricted by `scope`.
1734    ///
1735    /// The **graph** is as of `commit`; the **role definition** is as it is
1736    /// now, because `roles.json` is a sidecar and is never a WAL record — it
1737    /// has no past version to read. A role's `keys` and `labels` are resolved
1738    /// against the commit-`commit` graph, so a role that may see a label sees
1739    /// exactly the nodes that carried it then, and an explicit key that did
1740    /// not exist yet resolves to nothing.
1741    ///
1742    /// [`AsOfScope::RoleAndKeys`] intersects the two: a client allow-list can
1743    /// only narrow what a role may see, never widen it.
1744    ///
1745    /// Write statements are rejected, exactly as [`GraphDb::query_at`] rejects
1746    /// them.
1747    ///
1748    /// # Errors
1749    /// - [`GraphError::CommitOutOfRange`] if `commit` is outside the retained
1750    ///   range; the error carries that range.
1751    /// - [`GraphError::KeyNotFound`] with a `role:` prefix for an unknown role,
1752    ///   or [`GraphError::Corrupt`] when `roles.json` was corrupt at open.
1753    /// - A query error for a malformed or write query.
1754    pub fn query_at_scoped(
1755        &self,
1756        commit: u64,
1757        cypher: &str,
1758        params: &std::collections::BTreeMap<String, Value>,
1759        scope: AsOfScope<'_>,
1760    ) -> Result<ResultSet> {
1761        let temporal = self.open_at_for_read(commit, cypher)?;
1762        // One resolver answers "what may this role see" — `mask_for_role` — and
1763        // it runs against the temporal handle, so the answer is the as-of one.
1764        let mask = match scope {
1765            AsOfScope::Role(role) => temporal.mask_for_role(role)?,
1766            AsOfScope::Keys(keys) => {
1767                crate::mask::NodeMask::from_keys(&temporal, keys.iter().map(String::as_str))
1768            }
1769            AsOfScope::RoleAndKeys(role, keys) => {
1770                temporal
1771                    .mask_for_role(role)?
1772                    .intersect(&crate::mask::NodeMask::from_keys(
1773                        &temporal,
1774                        keys.iter().map(String::as_str),
1775                    ))
1776            }
1777        };
1778        temporal.query_masked(cypher, params, &mask)
1779    }
1780
1781    /// Open the temporal view for a time-travel read and refuse write Cypher.
1782    ///
1783    /// Shared by [`GraphDb::query_at`] and [`GraphDb::query_at_scoped`] so both
1784    /// resolve the commit and reject writes identically.
1785    fn open_at_for_read(&self, commit: u64, cypher: &str) -> Result<Self> {
1786        let dir = self.fs.dir().to_path_buf();
1787        let temporal = Self::open_at(&dir, commit)?;
1788        if is_write_tokens(&lex(cypher).map_err(|e| GraphError::QueryError {
1789            detail: format!("lex: {e}"),
1790        })?) {
1791            return Err(GraphError::QueryError {
1792                detail: "query_at is read-only: write statements are not permitted in a \
1793                         time-travel query"
1794                    .into(),
1795            });
1796        }
1797        Ok(temporal)
1798    }
1799}
1800
1801impl<F: Fs> GraphDb<F> {
1802    /// Open over an arbitrary [`Fs`], repairing a torn WAL tail as usual.
1803    pub fn open_with(fs: F) -> Result<Self> {
1804        Self::open_with_repair(fs, true)
1805    }
1806
1807    /// As [`GraphDb::open_with`], but `repair_wal: false` decodes the valid WAL
1808    /// prefix without writing the truncation back. See
1809    /// [`OpenOptions::repair_wal`].
1810    pub fn open_with_repair(fs: F, repair_wal: bool) -> Result<Self> {
1811        Self::open_generic(
1812            fs,
1813            OpenOptions {
1814                repair_wal,
1815                ..OpenOptions::default()
1816            },
1817            true,
1818        )
1819    }
1820
1821    /// Shared open path.
1822    ///
1823    /// `hold_lock` requests the cross-process write lock for the whole handle
1824    /// lifetime — the right behaviour for a plain read-write `GraphDb`, whose
1825    /// owner writes through it directly. [`SharedDb`](crate::SharedDb) passes
1826    /// `false` and takes the lock per write instead, so that a long-lived
1827    /// server does not keep every other process out of the store.
1828    ///
1829    /// A read-only open never takes the lock regardless of `hold_lock`.
1830    fn open_generic(fs: F, opts: OpenOptions, hold_lock: bool) -> Result<Self> {
1831        let mut db = Self::new_empty(fs, opts);
1832        db.read_only = opts.read_only;
1833        if hold_lock && !opts.read_only {
1834            if !db.poll_lock(WRITE_LOCK_WAIT)? {
1835                return Err(GraphError::Busy { holder: None });
1836            }
1837            db.holds_lifetime_lock = true;
1838        }
1839        db.load_from_disk(LoadOrigin::Open)?;
1840        Ok(db)
1841    }
1842
1843    /// A handle with no state loaded: every field at its empty value, the
1844    /// filesystem and options in place. Only [`load_from_disk`] makes it
1845    /// usable.
1846    fn new_empty(fs: F, opts: OpenOptions) -> Self {
1847        Self {
1848            fs,
1849            ids: IdMap::new(),
1850            syms: Interner::new(),
1851            topo: Topology::new(),
1852            props: ColumnStore::new(),
1853            labels: Vec::new(),
1854            edge_props: EdgeProps::new(),
1855            engine: RuleEngine::new(),
1856            view_store: ViewStore::new(),
1857            fulltext: FulltextIndex::new(),
1858            prop_index: PropertyIndex::new(),
1859            event_sink: None,
1860            fsync: FsyncPolicy::Strict,
1861            commit_seq: 0,
1862            roles: Some(vec![]),
1863            role_masks: Arc::new(crate::mask::RoleMaskCache::new()),
1864            subscriptions: Vec::new(),
1865            query_subscriptions: Vec::new(),
1866            sub_capacity: DEFAULT_SUB_CAPACITY,
1867            read_only: false,
1868            total_wal_commits: 0,
1869            base: None,
1870            fold_overlay: None,
1871            delta_tail: Vec::new(),
1872            commits_since_fold: 0,
1873            defer_events: false,
1874            deferred_events: Vec::new(),
1875            degraded: false,
1876            v8_sections_loaded: std::sync::atomic::AtomicBool::new(false),
1877            v8_sections_mutex: std::sync::Mutex::new(()),
1878            last_change: HashMap::new(),
1879            wal_archive_retention: None,
1880            wal_horizon_floor: 0,
1881            archive_genesis_chain: false,
1882            pending_write_authz: None,
1883            slow_query_threshold_ms: std::env::var("MUSHROOMDB_SLOW_QUERY_MS")
1884                .ok()
1885                .and_then(|v| v.parse().ok())
1886                .unwrap_or(100),
1887            slow_queries: std::sync::Mutex::new(SlowQueryLog {
1888                entries: std::collections::VecDeque::new(),
1889                total: 0,
1890            }),
1891            started_at: std::time::Instant::now(),
1892            wal_consumed: 0,
1893            snapshot_ident: None,
1894            open_opts: opts,
1895            holds_lifetime_lock: false,
1896            lock_denied: false,
1897            pinned: false,
1898        }
1899    }
1900
1901    /// Return every field describing stored graph state to its empty value,
1902    /// leaving this handle's own identity alone.
1903    ///
1904    /// Preserved on purpose: the filesystem, open options, lock ownership, the
1905    /// event sink and subscriptions, fsync policy, degraded flag, and the
1906    /// slow-query configuration and log. A caller that registered a sink or a
1907    /// subscription keeps it across a reload.
1908    fn reset_for_reload(&mut self) {
1909        self.ids = IdMap::new();
1910        self.syms = Interner::new();
1911        self.topo = Topology::new();
1912        self.props = ColumnStore::new();
1913        self.labels = Vec::new();
1914        self.edge_props = EdgeProps::new();
1915        self.engine = RuleEngine::new();
1916        self.view_store = ViewStore::new();
1917        self.fulltext = FulltextIndex::new();
1918        self.prop_index = PropertyIndex::new();
1919        self.commit_seq = 0;
1920        self.roles = Some(vec![]);
1921        // A fresh cache, not a cleared one: any reader snapshot still holding
1922        // the old `Arc` keeps it to itself, so nothing it memoised against the
1923        // pre-reload store can be read back through this handle.
1924        self.role_masks = Arc::new(crate::mask::RoleMaskCache::new());
1925        self.total_wal_commits = 0;
1926        self.base = None;
1927        self.fold_overlay = None;
1928        self.delta_tail = Vec::new();
1929        self.commits_since_fold = 0;
1930        self.deferred_events = Vec::new();
1931        self.v8_sections_loaded
1932            .store(false, std::sync::atomic::Ordering::Release);
1933        self.last_change = HashMap::new();
1934        self.wal_horizon_floor = 0;
1935        self.archive_genesis_chain = false;
1936        self.pending_write_authz = None;
1937        self.wal_consumed = 0;
1938        self.snapshot_ident = None;
1939    }
1940
1941    /// Load the snapshot base and replay the WAL into an empty handle — the
1942    /// whole of what opening a store does after the struct exists.
1943    ///
1944    /// Split out of the open path so that [`refresh`](GraphDb::refresh) can
1945    /// rebuild a handle in place, without ownership of `F`, when another
1946    /// process replaces the snapshot underneath it.
1947    ///
1948    /// `origin` decides whether the two repair writes this function can make
1949    /// are appropriate; see [`LoadOrigin`].
1950    fn load_from_disk(&mut self, origin: LoadOrigin) -> Result<usize> {
1951        // Both writes below are crash recovery, and only an open is entitled to
1952        // perform them. A read-only handle promises to touch nothing, and a
1953        // reload driven by `refresh` is looking at a store another process is
1954        // actively writing: what looks like a torn tail there is a peer
1955        // mid-append, and what looks like an orphaned archive may be one that
1956        // peer is about to reference.
1957        let may_repair = origin == LoadOrigin::Open && !self.open_opts.read_only;
1958        let repair_wal = self.open_opts.repair_wal && may_repair;
1959        let db = self;
1960        db.wal_horizon_floor = db.fs.read_horizon_floor()?;
1961        db.archive_genesis_chain = db.fs.has_genesis_marker();
1962        // Opening cleanup: remove orphaned archives — archives whose frames all
1963        // fall below the horizon floor.  Orphans arise when a crash interrupted
1964        // the retention-prune sequence after the floor was written but before
1965        // all surplus archives were deleted.  Safe to delete: floor already
1966        // accounts for their frames.
1967        if may_repair {
1968            db.cleanup_orphaned_archives()?;
1969        }
1970        let _t0 = std::time::Instant::now();
1971        // Peek 6 bytes to determine snapshot version without reading the full
1972        // file. For RealFs this is a true partial read (O(1)); for SimFs the
1973        // default impl reads all bytes and truncates (still correct).
1974        let snap_header = db.fs.read_prefix(FileId::Snapshot, 6)?;
1975        // V8 and V9 share the mmap-able container; V9 only adds section 12.
1976        let is_v8 = snap_header.len() >= 6
1977            && &snap_header[0..4] == b"GDB1"
1978            && matches!(
1979                u16::from_le_bytes([snap_header[4], snap_header[5]]),
1980                core_storage::snapshot::VERSION_8 | core_storage::snapshot::VERSION_9
1981            );
1982        if is_v8 {
1983            // V8: map the file zero-copy (RealFs) or read full bytes (SimFs).
1984            // No 2.4GB heap Vec is allocated on RealFs.
1985            let mapped = Arc::new(
1986                if let Some(snap_path) = db.fs.snapshot_path() {
1987                    core_storage::v8::MappedBase::map(&snap_path)
1988                } else {
1989                    let snap_bytes = db.fs.read(FileId::Snapshot)?;
1990                    core_storage::v8::MappedBase::from_bytes(snap_bytes)
1991                }
1992                .map_err(|e| GraphError::Corrupt {
1993                    detail: format!("v8: mmap open: {e:?}"),
1994                })?,
1995            );
1996            db.restore_v8_base(Arc::clone(&mapped))?;
1997            trace_open!("restore_v8_base", _t0);
1998            db.base = Some(mapped);
1999            trace_open!("base assigned", _t0);
2000        } else if !snap_header.is_empty() {
2001            // Legacy V5-V7: full read required for decode.
2002            let snap_bytes = db.fs.read(FileId::Snapshot)?;
2003            if let Some(state) = core_storage::snapshot::decode(&snap_bytes)? {
2004                db.restore_snapshot_state(state)?;
2005            }
2006        }
2007        // else: snap_header is empty = no snapshot file, fresh store.
2008        //
2009        // Seed commit_seq from the highest seq persisted in last_change so that
2010        // WAL-replay frames (which start at commit_seq+1) always exceed any seq
2011        // already stored in the snapshot.  Without this, a db with one snapshot
2012        // commit would save last_change["a"]=1, then on reopen the first WAL
2013        // frame would replay at seq=1 again — colliding and making WAL-tail
2014        // mutations indistinguishable from the snapshot baseline.
2015        //
2016        // Safety invariant (seq-recycling):
2017        //   Recycled seqs (those below the seeded baseline) were NEVER stored in
2018        //   last_change because they belonged to a previous db lifetime — a new
2019        //   db starts at commit_seq=0 with an empty last_change.  Therefore no
2020        //   CAS precondition can carry a recycled seq as its `expected` value
2021        //   and accidentally match a live node's last_change entry.
2022        //
2023        // `expected:0` on a deleted-then-reinserted node:
2024        //   After deletion, last_changed() returns None; callers that call
2025        //   last_changed() and then use NodeUnchangedSince get None.unwrap_or(0)
2026        //   = 0.  The reinserted node gets seq > 0, so a subsequent CAS with
2027        //   expected=0 correctly conflicts.  The only way to observe actual=0 in
2028        //   a CasConflict would be a caller that invented expected=0 without ever
2029        //   calling last_changed() — unreachable via the documented API contract.
2030        if let Some(&max_seq) = db.last_change.values().max() {
2031            db.commit_seq = db.commit_seq.max(max_seq);
2032        }
2033        let bytes = db.fs.read(FileId::Wal)?;
2034        let (records, valid_len) = decode_all(&bytes);
2035        // The valid prefix is replayed either way; `repair_wal` only decides
2036        // whether the truncation is written back. A reader that races a live
2037        // appender must not persist a truncation the writer never asked for.
2038        if valid_len < bytes.len() && repair_wal {
2039            db.fs.write_atomic(FileId::Wal, &bytes[..valid_len])?;
2040        }
2041        // WAL-present path: build indexes eagerly BEFORE replay so that the
2042        // first replayed record does not trigger the lazy-init guard (which
2043        // would call reindex_all_load_state on an empty graph, defeating the
2044        // point of restoring IVF/HNSW blobs from the snapshot).
2045        if !records.is_empty() {
2046            db.ensure_v8_base_sections_loaded();
2047            trace_open!("lazy sections loaded (WAL path)", _t0);
2048        }
2049        let replayed = db.apply_frames(records)?;
2050        // The cursor sits at the end of the valid prefix, not the end of the
2051        // file: a torn or still-being-written tail is unconsumed by definition
2052        // and stays visible to `is_stale` until it decodes.
2053        db.wal_consumed = valid_len as u64;
2054        db.snapshot_ident = db.fs.snapshot_ident().map_err(GraphError::Io)?;
2055        trace_open!("wal replay done", _t0);
2056        // Rebuild view values after WAL replay only when there is no V8 base.
2057        // With a V8 base, view values are correct in the snapshot and are updated
2058        // incrementally during WAL replay (on_edge_changed / on_prop_changed).
2059        // A full rebuild would read overlay-only props (empty after restore_v8_base)
2060        // and overwrite correct base values with wrong results (e.g. NeighborAgg
2061        // Sum reads no "score" in overlay → writes 0.0, shadowing the correct
2062        // base value).
2063        if db.base.is_none() {
2064            let topo_view = TopologyView::owned(&db.topo);
2065            db.view_store
2066                .rebuild_all(&mut db.props, &topo_view, &db.ids, &db.syms, &db.labels);
2067        }
2068        // Rebuild full-text index after WAL replay.  Corrects drift from
2069        // per-record incremental apply during replay.
2070        db.fulltext.rebuild_all(
2071            &db.ids,
2072            &db.labels,
2073            &db.syms,
2074            build_props_view(&db.props, &db.base),
2075        );
2076        db.prop_index.rebuild_all(
2077            &db.ids,
2078            &db.labels,
2079            &db.syms,
2080            build_props_view(&db.props, &db.base),
2081        );
2082        // Load roles sidecar. Missing file = no roles (Some(vec![])).
2083        // Corrupt/unparseable = poisoned (None); mask_for_role will fail-loud.
2084        db.roles = Self::load_roles_from_fs(&db.fs)?;
2085        // Capture the initial MVCC fold so reader() is ready immediately.
2086        db.fold_now();
2087        trace_open!("open_with complete", _t0);
2088        Ok(replayed)
2089    }
2090
2091    /// Apply decoded WAL frames to in-memory state, exactly as the open-path
2092    /// replay does — same `apply` calls, same per-frame delta drain, same
2093    /// commit-seq and last-change bookkeeping. Rules therefore fire and derived
2094    /// edges appear identically whether a frame arrives at open, from a local
2095    /// commit, or from another process by way of [`refresh`](GraphDb::refresh).
2096    ///
2097    /// Returns the number of frames applied.
2098    ///
2099    /// Deltas are drained and discarded per frame: replayed frames are already
2100    /// reflected on disk, so they are not news to a subscriber, and draining
2101    /// inside the loop keeps `pending_deltas` O(1) over a large WAL (I-2).
2102    fn apply_frames(&mut self, records: Vec<WalRecord>) -> Result<usize> {
2103        if records.is_empty() {
2104            return Ok(0);
2105        }
2106        // Materialize any state retained in the mmap base before the first
2107        // frame lands, so a replayed record cannot trip the lazy-init guard and
2108        // rebuild indexes from an empty graph. Both calls are idempotent.
2109        self.ensure_v8_base_sections_loaded();
2110        self.engine.consume_retained_state_eager(
2111            &self.ids,
2112            &self.syms,
2113            &self.labels,
2114            build_props_view(&self.props, &self.base),
2115        );
2116        let applied = records.len();
2117        for rec in records {
2118            self.apply(&rec)?;
2119            let _ = self.engine.drain_deltas();
2120            // Track commit_seq during replay so last_change entries are
2121            // consistent with the seqs assigned by log_then_apply_with on
2122            // subsequent live commits.  After N replayed frames, commit_seq=N;
2123            // live commits begin at N+1.
2124            self.commit_seq += 1;
2125            let replay_seq = self.commit_seq;
2126            self.update_last_change_from_rec(&rec, replay_seq);
2127        }
2128        // Enforce I-2: if the per-frame drain above is ever removed or skipped,
2129        // this assert catches the regression in debug builds immediately.
2130        debug_assert_eq!(
2131            self.engine.pending_delta_count(),
2132            0,
2133            "pending_deltas non-empty after replay — \
2134             per-frame drain must run inside the loop to keep memory O(1)"
2135        );
2136        // T2 note: the per-frame drain IS the suppression seam for replay.
2137        // Any future as-of replay path (Plan-15 T2) must drain here to feed
2138        // replaying subscribers; the mechanism is already in place.
2139        let _ = self.engine.drain_deltas(); // belt-and-braces no-op after loop drain
2140        Ok(applied)
2141    }
2142
2143    // ── Multi-process safety: cross-process write lock + WAL tailing ──────────
2144    //
2145    // mushroomdb is many-readers / one-writer across processes. Writers take an
2146    // advisory exclusive lock on the store's `LOCK` file; readers never do.
2147    // Every handle tracks how much of the WAL it has consumed, so it can pick
2148    // up another process's commits by decoding only the new tail rather than
2149    // reopening. See `docs/site/concurrency.md`.
2150
2151    /// Whether the store on disk has moved ahead of (or out from under) this
2152    /// handle's in-memory state.
2153    ///
2154    /// True when the WAL's length differs from this handle's cursor — another
2155    /// process committed, or is mid-append — or when the snapshot file's
2156    /// identity changed. Costs two metadata lookups and reads no file contents,
2157    /// so it is cheap enough for a read path to call.
2158    ///
2159    /// Always false for an as-of view from [`GraphDb::open_at`]: such a view is
2160    /// pinned to one commit and later commits are deliberately invisible to it.
2161    pub fn is_stale(&self) -> Result<bool> {
2162        if self.pinned {
2163            return Ok(false);
2164        }
2165        if self.fs.wal_len().map_err(GraphError::Io)? != self.wal_consumed {
2166            return Ok(true);
2167        }
2168        Ok(self.fs.snapshot_ident().map_err(GraphError::Io)? != self.snapshot_ident)
2169    }
2170
2171    /// Bring this handle up to date with every commit other processes have made,
2172    /// and return how many frames were applied.
2173    ///
2174    /// The WAL tail is decoded from this handle's cursor and applied through the
2175    /// same path the open replay uses, so rules fire and derived edges appear
2176    /// exactly as they would on a fresh open. Interners, id maps and indexes
2177    /// stay valid for the same reason.
2178    ///
2179    /// A frame another process is still writing is left alone: a trailing
2180    /// partial frame is a wait, not a corruption, and the handle stays stale
2181    /// until that frame is complete. Nothing is written to disk, so a read-only
2182    /// handle can refresh freely.
2183    ///
2184    /// When the snapshot file's identity changed, or the WAL is shorter than
2185    /// this handle's cursor, the WAL no longer continues our state — another
2186    /// process snapshotted or archived. The handle is then rebuilt from disk
2187    /// with the options it was opened with, and the return value is the number
2188    /// of frames in the new WAL.
2189    ///
2190    /// Returns 0 for an as-of view, which never follows later commits.
2191    ///
2192    /// # Errors
2193    ///
2194    /// An error here leaves the handle **degraded**: it got partway through
2195    /// applying the tail, or partway through a reload, so its in-memory state
2196    /// no longer matches any point on disk. Further mutations are refused and
2197    /// the handle must be reopened. Nothing on disk was damaged — the store
2198    /// itself is fine, and a fresh open recovers it.
2199    pub fn refresh(&mut self) -> Result<u64> {
2200        if self.pinned {
2201            return Ok(0);
2202        }
2203        let disk_ident = self.fs.snapshot_ident().map_err(GraphError::Io)?;
2204        let wal_len = self.fs.wal_len().map_err(GraphError::Io)?;
2205        if disk_ident != self.snapshot_ident || wal_len < self.wal_consumed {
2206            // The WAL no longer continues our state: rebuild from disk. State
2207            // is cleared first, so a failed load leaves an empty handle — mark
2208            // it degraded rather than let a caller read an empty graph as if
2209            // it were the store's contents.
2210            self.reset_for_reload();
2211            return match self.load_from_disk(LoadOrigin::Reload) {
2212                Ok(frames) => Ok(frames as u64),
2213                Err(e) => {
2214                    self.degraded = true;
2215                    Err(e)
2216                }
2217            };
2218        }
2219        if wal_len == self.wal_consumed {
2220            return Ok(0);
2221        }
2222        let tail = self
2223            .fs
2224            .read_range(FileId::Wal, self.wal_consumed)
2225            .map_err(GraphError::Io)?;
2226        let (records, valid_len) = decode_all(&tail);
2227        let applied = match self.apply_frames(records) {
2228            Ok(n) => n,
2229            Err(e) => {
2230                // Some frames landed and some did not, and the cursor cannot
2231                // say how many. Advancing it would skip the rest; leaving it
2232                // would replay what already applied. Neither is recoverable in
2233                // place, so refuse further writes and require a reopen.
2234                self.degraded = true;
2235                return Err(e);
2236            }
2237        };
2238        // Advance by the bytes actually decoded, never by the file length: an
2239        // incomplete trailing frame stays unconsumed for the next refresh.
2240        self.wal_consumed += valid_len as u64;
2241        if applied > 0 {
2242            // Peer commits must reach `reader()` snapshots taken from here on.
2243            // A full fold is what open does; refresh does not build per-commit
2244            // deltas, so there is nothing cheaper that stays correct.
2245            self.fold_now();
2246        }
2247        Ok(applied as u64)
2248    }
2249
2250    /// Byte offset of the WAL prefix this handle has applied.
2251    ///
2252    /// Exposed for tests that assert the cursor tracks appended bytes exactly.
2253    #[doc(hidden)]
2254    pub fn wal_consumed(&self) -> u64 {
2255        self.wal_consumed
2256    }
2257
2258    /// Rewind the WAL cursor after the group-commit drain thread truncated a
2259    /// failed group off the tail, so the cursor still describes the file.
2260    pub(crate) fn set_wal_consumed(&mut self, len: u64) {
2261        self.wal_consumed = len;
2262    }
2263
2264    /// One non-blocking attempt at the cross-process write lock.
2265    ///
2266    /// Takes `&self` so a caller can poll for the lock *before* it acquires the
2267    /// in-process write guard. That ordering is what keeps a busy peer in
2268    /// another process from stalling this process's readers.
2269    ///
2270    /// A handle that owns the lock for its lifetime always succeeds.
2271    pub(crate) fn try_cross_process_lock(&self) -> Result<bool> {
2272        if self.holds_lifetime_lock {
2273            return Ok(true);
2274        }
2275        self.fs.try_lock_exclusive().map_err(GraphError::Io)
2276    }
2277
2278    /// Poll for the cross-process write lock until `wait` elapses.
2279    ///
2280    /// One attempt is always made, so a zero wait is a single try. Returns
2281    /// `false` when the lock is still held elsewhere at the deadline; nothing
2282    /// has been written and retrying later is safe.
2283    ///
2284    /// Only the plain-`GraphDb` open path uses this, where the caller owns the
2285    /// handle outright. [`SharedDb`](crate::SharedDb) polls
2286    /// [`try_cross_process_lock`](GraphDb::try_cross_process_lock) itself so
2287    /// that it holds no in-process guard while it waits.
2288    fn poll_lock(&self, wait: std::time::Duration) -> Result<bool> {
2289        let deadline = std::time::Instant::now() + wait;
2290        loop {
2291            if self.try_cross_process_lock()? {
2292                return Ok(true);
2293            }
2294            let now = std::time::Instant::now();
2295            if now >= deadline {
2296                return Ok(false);
2297            }
2298            std::thread::sleep(LOCK_POLL_INTERVAL.min(deadline.saturating_duration_since(now)));
2299        }
2300    }
2301
2302    /// Open a cross-process write scope, given the outcome of an already-made
2303    /// lock attempt.
2304    ///
2305    /// The caller polls for the lock first — outside any in-process guard — and
2306    /// passes what it got. On success this refreshes, so the writes about to
2307    /// happen land on top of every other process's commits. On failure the
2308    /// handle refuses WAL-appending mutations and `snapshot()` with
2309    /// [`GraphError::Busy`] until [`end_write_lock`](GraphDb::end_write_lock)
2310    /// closes the scope, so a caller holding a guard cannot write behind
2311    /// another process's back.
2312    ///
2313    /// A handle that already owns the lock for its lifetime skips the refresh:
2314    /// no other process can have written, so there is nothing to pick up.
2315    pub(crate) fn enter_write_scope(&mut self, acquired: bool) -> Result<()> {
2316        self.lock_denied = !acquired;
2317        if !acquired || self.holds_lifetime_lock {
2318            return Ok(());
2319        }
2320        if let Err(e) = self.refresh() {
2321            // Do not hold a lock we cannot use: release it and let the caller
2322            // see the underlying failure.
2323            let _ = self.fs.unlock();
2324            self.lock_denied = true;
2325            return Err(e);
2326        }
2327        Ok(())
2328    }
2329
2330    /// Close a cross-process write scope opened by
2331    /// [`enter_write_scope`](GraphDb::enter_write_scope): release the lock and
2332    /// clear the Busy latch. Safe to call when the lock was never taken.
2333    pub(crate) fn end_write_lock(&mut self) {
2334        self.lock_denied = false;
2335        if !self.holds_lifetime_lock {
2336            // Releasing a lock we do not hold is a no-op; a failure to release
2337            // is reported by the OS closing the descriptor at handle drop.
2338            let _ = self.fs.unlock();
2339        }
2340    }
2341
2342    /// As-of replay for [`GraphDb::open_at`]: snapshot base (only when the
2343    /// snapshot truncated the WAL) plus the first `commit + 1` WAL frames;
2344    /// see [`GraphDb::open_at`] for the semantics.  The per-frame drain
2345    /// mirrors `open_with` exactly so pending_delta_count is 0 on exit.
2346    /// Restore all persisted state from a decoded snapshot. Shared by
2347    /// `open_with` and (when the snapshot truncated the WAL) `open_at_with`.
2348    fn restore_snapshot_state(
2349        &mut self,
2350        state: core_storage::snapshot::SnapshotState,
2351    ) -> Result<()> {
2352        self.ids = state.ids;
2353        self.syms = state.syms;
2354        self.topo = state.topo;
2355        self.props = state.props;
2356        self.labels = state.labels;
2357        self.edge_props = state.edge_props;
2358        // Cross-section label integrity for V5/V7 snapshots: same invariants as
2359        // restore_v8_base.  A crafted bincode snapshot with a short `labels` vec,
2360        // out-of-range sym ids, or a sentinel label on a live node would otherwise
2361        // open successfully and panic later in `NodeRef::label()` or
2362        // `neighborhood_masked()`.  Catching it here turns those into typed
2363        // `GraphError::Corrupt` at open time.
2364        {
2365            let ids_len = self.ids.len();
2366            if self.labels.len() != ids_len {
2367                return Err(GraphError::Corrupt {
2368                    detail: format!(
2369                        "snapshot: labels vec has {} entries but id table has {} total slots",
2370                        self.labels.len(),
2371                        ids_len,
2372                    ),
2373                });
2374            }
2375            let syms_len = self.syms.len() as u32;
2376            for (i, &sym) in self.labels.iter().enumerate() {
2377                let is_tombstoned = self.ids.is_tombstoned(i as u32);
2378                if sym == u32::MAX {
2379                    if !is_tombstoned {
2380                        return Err(GraphError::Corrupt {
2381                            detail: format!(
2382                                "snapshot: live node at id slot {i} has sentinel label (u32::MAX)"
2383                            ),
2384                        });
2385                    }
2386                } else if sym >= syms_len {
2387                    return Err(GraphError::Corrupt {
2388                        detail: format!(
2389                            "snapshot: label at id slot {i} references sym {sym} \
2390                             which is out of interner range ({syms_len})"
2391                        ),
2392                    });
2393                }
2394            }
2395        }
2396        let defs: Vec<RuleDef> = state
2397            .rule_defs
2398            .iter()
2399            .map(|b| {
2400                decode_rule_def(b).map_err(|e| GraphError::Corrupt {
2401                    detail: format!("snapshot rule_def deserialize: {e}"),
2402                })
2403            })
2404            .collect::<Result<Vec<_>>>()?;
2405        self.engine =
2406            RuleEngine::from_persist(defs, state.provenance, state.rule_tripped, state.rule_fires);
2407        // Candidate indexes are rebuilt lazily on the first mutation (see
2408        // RuleEngine::on_node_changed).  HNSW blobs and IVF centroids from the
2409        // snapshot are retained without deserializing so that:
2410        //   - clean-open (empty WAL): indexes stay empty; blobs load on first
2411        //     ANN query via ensure_hnsw_loaded, or on first mutation via the
2412        //     lazy-init guard which calls reindex_all_load_state (the scan
2413        //     skips the HNSW build for every side the blob supplies).
2414        //   - WAL-present: open_with calls consume_retained_state_eager before
2415        //     replay so HNSW/IVF are live before any record fires the hooks.
2416        let ivf_bytes = if state.ivf_state.is_empty() {
2417            Vec::new()
2418        } else {
2419            bincode::serialize(&state.ivf_state).expect("IVF state serialize cannot fail")
2420        };
2421        // Store blobs without eagerly deserializing them.
2422        self.engine
2423            .store_snapshot_state(state.hnsw_state, ivf_bytes);
2424        // Restore view defs from snapshot (V5).
2425        // The ColumnStore already contains view values from the snapshot;
2426        // use restore_view (no collision check, no backfill) so the store
2427        // is aware of the definitions.  rebuild_all runs after WAL replay.
2428        for def_bytes in &state.view_defs {
2429            let def: ViewDef =
2430                bincode::deserialize(def_bytes).map_err(|e| GraphError::Corrupt {
2431                    detail: format!("snapshot view_def deserialize: {e}"),
2432                })?;
2433            self.view_store
2434                .restore_view(def)
2435                .map_err(|e| GraphError::Corrupt {
2436                    detail: format!("snapshot view restore: {e}"),
2437                })?;
2438        }
2439        Ok(())
2440    }
2441
2442    /// Restore all persisted state from a V8 `MappedBase` snapshot, **except**
2443    /// topology (`self.topo` stays empty and serves as the WAL-replay overlay).
2444    ///
2445    /// `self.props` IS fully materialised from the base so that HNSW/IVF blob
2446    /// deserialization and view rebuild have access to all column data.
2447    fn restore_v8_base(&mut self, mapped: Arc<core_storage::v8::MappedBase>) -> Result<()> {
2448        self.ids = archived_to_idmap(mapped.ids().map_err(|e| GraphError::Corrupt {
2449            detail: format!("v8: ids section: {e:?}"),
2450        })?);
2451        self.syms = archived_to_interner(mapped.syms().map_err(|e| GraphError::Corrupt {
2452            detail: format!("v8: syms section: {e:?}"),
2453        })?);
2454
2455        // C1: self.props is left as an empty overlay. Column reads go through
2456        // props_view() (ColumnsView::with_base), which consults the archived base
2457        // section zero-copy. This avoids the O(columns) heap copy at every open.
2458
2459        // self.topo deliberately left as Topology::new() — overlay path.
2460
2461        let meta = decode_meta(mapped.meta_bytes().map_err(|e| GraphError::Corrupt {
2462            detail: format!("v8: meta section: {e:?}"),
2463        })?)
2464        .map_err(|e| GraphError::Corrupt {
2465            detail: format!("v8: meta decode: {e:?}"),
2466        })?;
2467        self.labels = meta.labels;
2468        // Cross-section label integrity: labels must cover every id slot (live
2469        // and tombstoned), every non-sentinel sym must be within the interner's
2470        // bound, and no live (non-tombstoned) node may carry the u32::MAX
2471        // sentinel label.  Without this check, a crafted snapshot where the META
2472        // section (small, CRC-validated) holds a short `labels` vec, out-of-range
2473        // sym ids, or a sentinel label on a live node, would open successfully
2474        // and then panic in `NodeRef::label()`, `neighborhood_masked()`, and
2475        // related read paths.  Catching the inconsistency here converts those
2476        // panics into typed `GraphError::Corrupt` at open time.
2477        {
2478            let ids_len = self.ids.len();
2479            if self.labels.len() != ids_len {
2480                return Err(GraphError::Corrupt {
2481                    detail: format!(
2482                        "v8: labels section has {} entries but id table has {} total slots",
2483                        self.labels.len(),
2484                        ids_len,
2485                    ),
2486                });
2487            }
2488            let syms_len = self.syms.len() as u32;
2489            for (i, &sym) in self.labels.iter().enumerate() {
2490                let is_tombstoned = self.ids.is_tombstoned(i as u32);
2491                if sym == u32::MAX {
2492                    // Sentinel is only valid for tombstoned slots.
2493                    if !is_tombstoned {
2494                        return Err(GraphError::Corrupt {
2495                            detail: format!(
2496                                "v8: live node at id slot {i} has sentinel label (u32::MAX)"
2497                            ),
2498                        });
2499                    }
2500                } else if sym >= syms_len {
2501                    return Err(GraphError::Corrupt {
2502                        detail: format!(
2503                            "v8: label at id slot {i} references sym {sym} \
2504                             which is out of interner range ({syms_len})"
2505                        ),
2506                    });
2507                }
2508            }
2509        }
2510        // C3: self.edge_props stays as an empty overlay.  Reads go through
2511        // edge_props_view() which consults the mmap'd base section zero-copy
2512        // via EdgePropsView::with_base.  No heap decode at open time.
2513
2514        // Restore rule engine.
2515        let (rule_def_bytes, rule_tripped, rule_fires) =
2516            archived_rules_meta_to_owned(mapped.rules_meta_section().map_err(|e| {
2517                GraphError::Corrupt {
2518                    detail: format!("v8: rules_meta section: {e:?}"),
2519                }
2520            })?);
2521        let defs: Vec<RuleDef> = rule_def_bytes
2522            .iter()
2523            .map(|b| {
2524                decode_rule_def(b).map_err(|e| GraphError::Corrupt {
2525                    detail: format!("v8: rule_def deserialize: {e}"),
2526                })
2527            })
2528            .collect::<Result<Vec<_>>>()?;
2529        self.engine = RuleEngine::from_persist(defs, BTreeMap::new(), rule_tripped, rule_fires);
2530        // C4+C5: provenance, HNSW, and IVF sections are NOT read here.
2531        // `ensure_v8_base_sections_loaded` reads them on first use from
2532        // `self.base` (set by the caller immediately after this returns).
2533        // A clean open touches only: header + IDS + SYMS + META + RULES_META.
2534
2535        // Restore view definitions.
2536        let view_defs =
2537            archived_views_to_owned(mapped.views_section().map_err(|e| GraphError::Corrupt {
2538                detail: format!("v8: views section: {e:?}"),
2539            })?);
2540        for def_bytes in &view_defs {
2541            let def: ViewDef =
2542                bincode::deserialize(def_bytes).map_err(|e| GraphError::Corrupt {
2543                    detail: format!("v8: view_def deserialize: {e}"),
2544                })?;
2545            self.view_store
2546                .restore_view(def)
2547                .map_err(|e| GraphError::Corrupt {
2548                    detail: format!("v8: view restore: {e}"),
2549                })?;
2550        }
2551        // Load the last-change map from section 11 (small section; load eagerly).
2552        // Pre-Task-3 snapshots lack this section; `last_change_bytes` returns &[]
2553        // in that case and `decode_last_change_bytes` returns an empty map.
2554        let last_change_raw = mapped
2555            .last_change_bytes()
2556            .map_err(|e| GraphError::Corrupt {
2557                detail: format!("v8: last_change section: {e:?}"),
2558            })?;
2559        self.last_change = decode_last_change_bytes(last_change_raw);
2560
2561        // Validate that all deferred sections (provenance, HNSW, IVF) fit within
2562        // the file.  Pure bounds check — no bytes read, no page faults triggered.
2563        // Catches truncated snapshots at open time before the lazy deferred reads.
2564        mapped.validate_section_bounds().map_err(|e| match e {
2565            GraphError::Corrupt { detail } => GraphError::Corrupt {
2566                detail: format!("v8: section bounds: {detail}"),
2567            },
2568            other => other,
2569        })?;
2570        Ok(())
2571    }
2572
2573    /// Read provenance, HNSW, and IVF sections from the mmap base into the
2574    /// engine's retained fields on first call.  Subsequent calls are a no-op
2575    /// (AtomicBool fast-path).
2576    ///
2577    /// Must be called before any code path that reads or mutates engine
2578    /// provenance, HNSW, or IVF state:
2579    /// - WAL replay (before `consume_retained_state_eager`)
2580    /// - First mutation (`log_then_apply_with`)
2581    /// - Read-only paths (`stats`, `explain`, `node_edges`)
2582    /// - Snapshot (`snapshot_with`)
2583    ///
2584    /// No-op for fresh stores and V5-V7 opens (`self.base` is `None`).
2585    fn ensure_v8_base_sections_loaded(&self) {
2586        use std::sync::atomic::Ordering;
2587        if self.v8_sections_loaded.load(Ordering::Acquire) {
2588            return;
2589        }
2590        let _guard = self
2591            .v8_sections_mutex
2592            .lock()
2593            .expect("v8 sections mutex poisoned");
2594        if self.v8_sections_loaded.load(Ordering::Acquire) {
2595            return; // another caller populated while we waited
2596        }
2597        let _t = std::time::Instant::now();
2598        if let Some(base) = &self.base {
2599            // Provenance: raw rkyv bytes; CRC validated inside section_bytes.
2600            // Bounds are already validated at open time (restore_v8_base →
2601            // validate_section_bounds) — unreachable post-validate_section_bounds;
2602            // unwrap_or_default is a safety belt against impossible errors.
2603            let prov_bytes = base
2604                .provenance_raw_bytes()
2605                .map(|b| b.to_vec())
2606                .unwrap_or_default();
2607            self.engine.store_provenance_bytes(prov_bytes);
2608            // HNSW: decode rkyv blobs into owned map.
2609            let hnsw_state = base
2610                .hnsw_section()
2611                .map(archived_hnsw_to_owned)
2612                .unwrap_or_default();
2613            // IVF: raw bincode bytes; deserialized on first mutation/query.
2614            let ivf_bytes = base.ivf_bytes().map(|b| b.to_vec()).unwrap_or_default();
2615            self.engine.store_snapshot_state(hnsw_state, ivf_bytes);
2616        }
2617        self.v8_sections_loaded.store(true, Ordering::Release);
2618        if std::env::var("MUSHROOMDB_TRACE_OPEN").is_ok() {
2619            eprintln!(
2620                "[MUSHROOMDB_TRACE_OPEN] ensure_v8_base_sections_loaded: {:>9.3?}",
2621                _t.elapsed()
2622            );
2623        }
2624    }
2625
2626    /// Return a `TopologyView` that merges the mmap'd base (when present) with
2627    /// the in-memory WAL overlay.  Used by all read paths in db.rs that need
2628    /// the full merged topology without going through `self.view()`.
2629    fn topo_view(&self) -> TopologyView<'_> {
2630        match self.base {
2631            None => TopologyView::owned(&self.topo),
2632            Some(ref base) => {
2633                // SAFETY: base lives as long as self; section bounds validated at open.
2634                // topology() uses access_unchecked; all field reads are bounds-checked in seam.rs.
2635                let archived = base
2636                    .topology()
2637                    .expect("base topology section bounds validated at open");
2638                TopologyView::with_base(&self.topo, archived)
2639            }
2640        }
2641    }
2642
2643    /// Return a `ColumnsView` that merges the mmap'd base columns (when a V8
2644    /// snapshot is open) with the in-memory WAL overlay.  Reads consult the
2645    /// overlay first, then fall through to the archived base section zero-copy.
2646    fn props_view(&self) -> core_storage::v8::seam::ColumnsView<'_> {
2647        match self.base {
2648            None => core_storage::v8::seam::ColumnsView::owned(&self.props),
2649            Some(ref base) => {
2650                // columns() uses access_unchecked; field reads are bounds-checked in seam.rs.
2651                let archived = base
2652                    .columns()
2653                    .expect("base columns section bounds validated at open");
2654                core_storage::v8::seam::ColumnsView::with_base_cached(
2655                    &self.props,
2656                    archived,
2657                    base.mixed_cache(),
2658                )
2659                .with_shared_strings(base_string_table(base))
2660            }
2661        }
2662    }
2663
2664    /// Return an `EdgePropsView` that merges the mmap'd base edge-props section
2665    /// (when a V8 snapshot is open) with the in-memory WAL overlay.
2666    ///
2667    /// Reads consult the overlay first (for post-snapshot mutations), then fall
2668    /// through to the archived base section zero-copy.  Tombstones in the
2669    /// overlay mask deleted-from-base entries.
2670    fn edge_props_view(&self) -> EdgePropsView<'_> {
2671        match self.base {
2672            None => EdgePropsView::owned(&self.edge_props),
2673            Some(ref base) => {
2674                // edge_props_section() uses access_unchecked; field reads bounds-checked in seam.rs.
2675                let archived = base
2676                    .edge_props_section()
2677                    .expect("base edge_props section bounds validated at open");
2678                EdgePropsView::with_base(&self.edge_props, archived)
2679            }
2680        }
2681    }
2682
2683    fn open_at_with(fs: F, commit: u64) -> Result<Self> {
2684        // An as-of view never writes and is pinned to one commit: it takes no
2685        // cross-process lock and does not follow later commits.
2686        let mut db = Self::new_empty(
2687            fs,
2688            OpenOptions {
2689                repair_wal: false,
2690                auto_migrate: false,
2691                read_only: true,
2692            },
2693        );
2694        db.pinned = true; // read_only is set after replay, but pinning is immediate
2695        db.wal_horizon_floor = db.fs.read_horizon_floor()?;
2696        db.archive_genesis_chain = db.fs.has_genesis_marker();
2697        // Same orphaned-archive cleanup as open_with: floor was written first
2698        // during pruning, so a crash may have left stale archives below floor.
2699        db.cleanup_orphaned_archives()?;
2700        // Collect archive frames (oldest-first) and live WAL frames.
2701        // Archives represent pre-snapshot history; the snapshot captures the
2702        // cumulative state at the time of archiving.  Crash-window guarantee:
2703        //   A: crash before rename → WAL intact, no archive. Reopen: normal.
2704        //   B: crash after rename, before new WAL → archive present, WAL
2705        //      absent. Reopen: snapshot loaded (full state), no WAL replay.
2706        //   C: crash after new baseline WAL written → normal post-archive.
2707        let archive_ns = db.fs.list_archives()?;
2708        let mut archive_frames_all: Vec<WalRecord> = Vec::new();
2709        for n in &archive_ns {
2710            let arc_bytes = db.fs.read_archive(*n)?;
2711            let (arc_frames, _) = decode_all(&arc_bytes);
2712            archive_frames_all.extend(arc_frames);
2713        }
2714        let total_archive_frames = archive_frames_all.len() as u64;
2715
2716        let live_bytes = db.fs.read(FileId::Wal)?;
2717        let (live_records, _valid_len) = decode_all(&live_bytes);
2718        let total_surviving = total_archive_frames + live_records.len() as u64;
2719        // Global total including any pruned history below the horizon floor.
2720        let total = db.wal_horizon_floor + total_surviving;
2721
2722        // Horizon and range check.
2723        if commit < db.wal_horizon_floor {
2724            return Err(GraphError::CommitOutOfRange {
2725                commit,
2726                total,
2727                floor: db.wal_horizon_floor,
2728            });
2729        }
2730        if commit >= total {
2731            return Err(GraphError::CommitOutOfRange {
2732                commit,
2733                total,
2734                floor: db.wal_horizon_floor,
2735            });
2736        }
2737
2738        // Local index into surviving frames (0 = first frame of oldest archive).
2739        let local = commit - db.wal_horizon_floor;
2740
2741        if local < total_archive_frames {
2742            // Target commit is in an archive.  Correct replay from empty state
2743            // is only possible when the archive chain is an uninterrupted
2744            // genesis chain (first archive taken from a fresh store, no prior
2745            // WAL truncation) and no archives have been pruned (floor == 0).
2746            //
2747            // If either condition is violated the prefix needed to reconstruct
2748            // the requested state is gone; refuse rather than return wrong data.
2749            if db.wal_horizon_floor > 0 || !db.archive_genesis_chain {
2750                return Err(GraphError::CommitOutOfRange {
2751                    commit,
2752                    total,
2753                    floor: db.wal_horizon_floor,
2754                });
2755            }
2756            // Replay all archive frames up to and including the target commit
2757            // from an empty database state.  Archives must be replayed in order
2758            // so that dense-id intern tables are built up correctly.
2759            for rec in archive_frames_all.into_iter().take((local + 1) as usize) {
2760                db.apply(&rec)?;
2761                let _ = db.engine.drain_deltas();
2762            }
2763        } else {
2764            // Target commit is in the live WAL: load snapshot as base, then
2765            // replay the needed live WAL prefix.
2766            //
2767            // Base state: a truncating snapshot (wal_truncated=true) compacts
2768            // all pre-truncation / pre-archive commits.  Dense-id records in
2769            // the live WAL reference ids/interns that the snapshot provides.
2770            // Peek 6 bytes (same pattern as open_with).
2771            let snap_header = db.fs.read_prefix(FileId::Snapshot, 6)?;
2772            let is_v8 = snap_header.len() >= 6
2773                && &snap_header[0..4] == b"GDB1"
2774                && matches!(
2775                    u16::from_le_bytes([snap_header[4], snap_header[5]]),
2776                    core_storage::snapshot::VERSION_8 | core_storage::snapshot::VERSION_9
2777                );
2778            if is_v8 {
2779                let state = if let Some(snap_path) = db.fs.snapshot_path() {
2780                    let mapped = core_storage::v8::MappedBase::map(&snap_path).map_err(|e| {
2781                        GraphError::Corrupt {
2782                            detail: format!("v8: open_at mmap: {e:?}"),
2783                        }
2784                    })?;
2785                    core_storage::snapshot::decode_v8_from_mapped(&mapped)?
2786                } else {
2787                    let snap_bytes = db.fs.read(FileId::Snapshot)?;
2788                    core_storage::snapshot::decode(&snap_bytes)?
2789                };
2790                if let Some(state) = state {
2791                    if state.wal_truncated {
2792                        db.restore_snapshot_state(state)?;
2793                    }
2794                }
2795            } else if !snap_header.is_empty() {
2796                let snap_bytes = db.fs.read(FileId::Snapshot)?;
2797                if let Some(state) = core_storage::snapshot::decode(&snap_bytes)? {
2798                    if state.wal_truncated {
2799                        db.restore_snapshot_state(state)?;
2800                    }
2801                }
2802            }
2803            // else: snap_header empty = no snapshot file.
2804            let live_local = local - total_archive_frames;
2805            for rec in live_records.into_iter().take((live_local + 1) as usize) {
2806                db.apply(&rec)?;
2807                let _ = db.engine.drain_deltas();
2808            }
2809        }
2810        // Pin: pending_delta_count must be 0 after as-of replay, mirroring T1's
2811        // post-loop assert in open_with.
2812        debug_assert_eq!(
2813            db.engine.pending_delta_count(),
2814            0,
2815            "pending_deltas non-empty after open_at replay — \
2816             per-frame drain must run inside the loop to keep memory O(1)"
2817        );
2818        let _ = db.engine.drain_deltas(); // belt-and-braces no-op
2819                                          // Rebuild view values after WAL replay so derived-edge-driven views
2820                                          // reflect the as-of state.  open_at always uses the legacy path (no V8
2821                                          // base), so topo_view is always owned.
2822        {
2823            let topo_view = TopologyView::owned(&db.topo);
2824            db.view_store
2825                .rebuild_all(&mut db.props, &topo_view, &db.ids, &db.syms, &db.labels);
2826        }
2827        // Rebuild full-text index for as-of view (mirrors open_with pattern).
2828        db.fulltext.rebuild_all(
2829            &db.ids,
2830            &db.labels,
2831            &db.syms,
2832            build_props_view(&db.props, &db.base),
2833        );
2834        db.prop_index.rebuild_all(
2835            &db.ids,
2836            &db.labels,
2837            &db.syms,
2838            build_props_view(&db.props, &db.base),
2839        );
2840        // Load roles sidecar (current roles, not point-in-time).
2841        db.roles = Self::load_roles_from_fs(&db.fs)?;
2842        db.read_only = true;
2843        db.total_wal_commits = total;
2844        // Capture initial fold so reader() is immediately usable.
2845        db.fold_now();
2846        Ok(db)
2847    }
2848
2849    /// Whether this instance is a read-only as-of view.
2850    pub fn is_read_only(&self) -> bool {
2851        self.read_only
2852    }
2853
2854    // ── MVCC epoch reader ─────────────────────────────────────────────────────
2855
2856    /// Clone the current overlay state into a new `FrozenOverlay` and reset
2857    /// the delta tail. Called automatically every `FOLD_EVERY_K` commits and at
2858    /// the end of `open_with` / `open_at_with` to prime the reader.
2859    fn fold_now(&mut self) {
2860        let frozen = crate::reader::FrozenOverlay {
2861            ids: self.ids.clone(),
2862            syms: self.syms.clone(),
2863            topo: self.topo.clone(),
2864            props: self.props.clone(),
2865            labels: self.labels.clone(),
2866            edge_props: self.edge_props.clone(),
2867            roles: self.roles.clone(),
2868            fulltext: self.fulltext.clone(),
2869        };
2870        self.fold_overlay = Some(Arc::new(frozen));
2871        self.delta_tail.clear();
2872        self.commits_since_fold = 0;
2873    }
2874
2875    /// Capture a lock-free reader snapshot of the current db state.
2876    ///
2877    /// The read lock is held only for the duration of this call (to clone a
2878    /// handful of `Arc` handles). Subsequent query operations run without any
2879    /// lock.
2880    pub fn reader(&self) -> crate::reader::ReaderSnapshot {
2881        crate::reader::ReaderSnapshot::new(
2882            self.fold_overlay
2883                .clone()
2884                .expect("fold_overlay is always Some after open_with; call reader() after open"),
2885            self.base.clone(),
2886            self.delta_tail.clone(),
2887            // The snapshot's effective state is exactly this handle's state at
2888            // this commit, so it shares the memo and its version key.
2889            self.commit_seq,
2890            Arc::clone(&self.role_masks),
2891        )
2892    }
2893
2894    /// Total number of WAL commits at the time [`open_at`] was called.
2895    /// Returns 0 for normal (non-as-of) instances.
2896    pub fn total_wal_commits(&self) -> u64 {
2897        self.total_wal_commits
2898    }
2899
2900    /// Apply a record to in-memory state. Used by both live writes and replay,
2901    /// so replay is definitionally identical to the original execution.
2902    fn apply(&mut self, rec: &WalRecord) -> Result<()> {
2903        match rec {
2904            WalRecord::InsertNode { label, key, props } => {
2905                let id = self.ids.try_insert(key)?;
2906                let sym = self.syms.intern(label);
2907                if self.labels.len() <= id as usize {
2908                    // gap slots are sentinels, never valid label symbols
2909                    self.labels.resize(id as usize + 1, u32::MAX);
2910                }
2911                self.labels[id as usize] = sym;
2912                for (field, value) in props {
2913                    self.props.set(id, field, value.clone());
2914                }
2915                // Initialize view values for the new node before the engine runs so
2916                // delta-based increments start from a known zero baseline.
2917                self.view_store
2918                    .init_node_views(id, &mut self.props, &self.syms, &self.labels);
2919                // Fire rules for the newly inserted node.
2920                let cursor = self.engine.pending_delta_count();
2921                let mut eng = std::mem::take(&mut self.engine);
2922                {
2923                    let mut gm = make_graph_mut(
2924                        &self.ids,
2925                        &mut self.syms,
2926                        &self.labels,
2927                        build_props_view(&self.props, &self.base),
2928                        &mut self.topo,
2929                        &self.base,
2930                        &mut self.edge_props,
2931                    );
2932                    eng.on_node_changed(id, None, &mut gm);
2933                }
2934                self.engine = eng;
2935                // Process derived-edge deltas for view maintenance.
2936                // Fast path: skip the O(delta_count) allocation when no views exist.
2937                if !self.view_store.is_empty() {
2938                    #[cfg(test)]
2939                    DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
2940                    let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
2941                    for d in &new_deltas {
2942                        self.view_store.on_edge_changed(
2943                            d.etype_sym,
2944                            d.src_id,
2945                            d.dst_id,
2946                            d.fired,
2947                            &mut self.props,
2948                            &build_topo_view(&self.topo, &self.base),
2949                            &self.ids,
2950                            &self.syms,
2951                            &self.labels,
2952                            base_columns(&self.base),
2953                        );
2954                    }
2955                }
2956                // Full-text index maintenance: index enabled fields for this label.
2957                if self.fulltext.has_label(label) {
2958                    for (field, value) in props {
2959                        if self.fulltext.is_enabled(label, field) {
2960                            self.fulltext.add_tokens(id, field, value);
2961                        }
2962                    }
2963                }
2964                // Property (equality) index maintenance.
2965                if self.prop_index.has_label(label) {
2966                    for (field, value) in props {
2967                        self.prop_index.set(label, field, id, value);
2968                    }
2969                }
2970            }
2971            WalRecord::InsertEdge {
2972                edge_type,
2973                src_key,
2974                dst_key,
2975            } => {
2976                let src = self.ids.get(src_key).ok_or_else(|| GraphError::Corrupt {
2977                    detail: format!("wal replay references unknown key {src_key}"),
2978                })?;
2979                let dst = self.ids.get(dst_key).ok_or_else(|| GraphError::Corrupt {
2980                    detail: format!("wal replay references unknown key {dst_key}"),
2981                })?;
2982                let etype = self.syms.intern(edge_type);
2983                // Skip if the edge is already visible in the merged base+overlay
2984                // view.  This keeps WAL replay idempotent when the WAL contains
2985                // pre-snapshot records that are already encoded in a V8 base
2986                // (keep_wal=true opens and crash-before-truncation scenarios).
2987                if self.base.is_some()
2988                    && self
2989                        .topo_view()
2990                        .neighbors(etype, Direction::Out, src)
2991                        .contains(&dst)
2992                {
2993                    return Ok(());
2994                }
2995                self.topo.add_edge(etype, src, dst);
2996                // View maintenance for manual edge insert.
2997                self.view_store.on_edge_changed(
2998                    etype,
2999                    src,
3000                    dst,
3001                    true,
3002                    &mut self.props,
3003                    &build_topo_view(&self.topo, &self.base),
3004                    &self.ids,
3005                    &self.syms,
3006                    &self.labels,
3007                    base_columns(&self.base),
3008                );
3009                // Rule engine: via-hop rules must update when user edges change.
3010                let cursor = self.engine.pending_delta_count();
3011                let mut eng = std::mem::take(&mut self.engine);
3012                {
3013                    let mut gm = make_graph_mut(
3014                        &self.ids,
3015                        &mut self.syms,
3016                        &self.labels,
3017                        build_props_view(&self.props, &self.base),
3018                        &mut self.topo,
3019                        &self.base,
3020                        &mut self.edge_props,
3021                    );
3022                    eng.on_edge_changed(edge_type, src, dst, &mut gm);
3023                }
3024                self.engine = eng;
3025                if !self.view_store.is_empty() {
3026                    let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
3027                    for d in &new_deltas {
3028                        self.view_store.on_edge_changed(
3029                            d.etype_sym,
3030                            d.src_id,
3031                            d.dst_id,
3032                            d.fired,
3033                            &mut self.props,
3034                            &build_topo_view(&self.topo, &self.base),
3035                            &self.ids,
3036                            &self.syms,
3037                            &self.labels,
3038                            base_columns(&self.base),
3039                        );
3040                    }
3041                }
3042            }
3043            WalRecord::SetProp { key, field, value } => {
3044                let id = self.ids.get(key).ok_or_else(|| GraphError::Corrupt {
3045                    detail: format!("wal replay references unknown key {key}"),
3046                })?;
3047                let old_value = build_props_view(&self.props, &self.base)
3048                    .get(id, field)
3049                    .map(|vr| vr.into_value());
3050                self.props.set(id, field, value.clone());
3051                // Fire rules for the changed field.
3052                let cursor = self.engine.pending_delta_count();
3053                let mut eng = std::mem::take(&mut self.engine);
3054                {
3055                    let mut gm = make_graph_mut(
3056                        &self.ids,
3057                        &mut self.syms,
3058                        &self.labels,
3059                        build_props_view(&self.props, &self.base),
3060                        &mut self.topo,
3061                        &self.base,
3062                        &mut self.edge_props,
3063                    );
3064                    eng.on_node_changed(id, Some((field, old_value)), &mut gm);
3065                }
3066                self.engine = eng;
3067                // Derived-edge deltas → view updates.
3068                if !self.view_store.is_empty() {
3069                    #[cfg(test)]
3070                    DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
3071                    let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
3072                    for d in &new_deltas {
3073                        self.view_store.on_edge_changed(
3074                            d.etype_sym,
3075                            d.src_id,
3076                            d.dst_id,
3077                            d.fired,
3078                            &mut self.props,
3079                            &build_topo_view(&self.topo, &self.base),
3080                            &self.ids,
3081                            &self.syms,
3082                            &self.labels,
3083                            base_columns(&self.base),
3084                        );
3085                    }
3086                }
3087                // Neighbor-aggregate views that read `field` must also update.
3088                self.view_store.on_prop_changed(
3089                    id,
3090                    field,
3091                    &mut self.props,
3092                    &build_topo_view(&self.topo, &self.base),
3093                    &self.ids,
3094                    &self.syms,
3095                    &self.labels,
3096                    base_columns(&self.base),
3097                );
3098                // Full-text index maintenance: update tokens for this field if indexed.
3099                if self.fulltext.field_indexed(field) {
3100                    let label_opt = self.labels.get(id as usize).and_then(|&sym| {
3101                        if sym == u32::MAX {
3102                            None
3103                        } else {
3104                            self.syms.resolve(sym)
3105                        }
3106                    });
3107                    if let Some(label) = label_opt {
3108                        if self.fulltext.is_enabled(label, field) {
3109                            self.fulltext.remove_node_field(id, field);
3110                            self.fulltext.add_tokens(id, field, value);
3111                        }
3112                    }
3113                }
3114                // Property (equality) index maintenance: re-key this node's value.
3115                if self.prop_index.field_indexed(field) {
3116                    let label_opt = self.labels.get(id as usize).and_then(|&sym| {
3117                        if sym == u32::MAX {
3118                            None
3119                        } else {
3120                            self.syms.resolve(sym)
3121                        }
3122                    });
3123                    if let Some(label) = label_opt {
3124                        self.prop_index.set(label, field, id, value);
3125                    }
3126                }
3127            }
3128            WalRecord::Intern { id, text } => {
3129                if let Some(existing) = self.syms.get(text) {
3130                    if existing != *id {
3131                        return Err(GraphError::Corrupt {
3132                            detail: format!(
3133                                "wal intern mismatch for {text:?}: have {existing}, record {id}"
3134                            ),
3135                        });
3136                    }
3137                } else {
3138                    let got = self.syms.intern(text);
3139                    if got != *id {
3140                        return Err(GraphError::Corrupt {
3141                            detail: format!(
3142                                "wal intern assigned {got} for {text:?}, record wanted {id}"
3143                            ),
3144                        });
3145                    }
3146                }
3147            }
3148            WalRecord::InsertNodeId { label, key, props } => {
3149                let id = self.ids.try_insert(key)?;
3150                if self.labels.len() <= id as usize {
3151                    self.labels.resize(id as usize + 1, u32::MAX);
3152                }
3153                self.labels[id as usize] = *label;
3154                let label_str = self
3155                    .syms
3156                    .resolve(*label)
3157                    .ok_or_else(|| GraphError::Corrupt {
3158                        detail: format!("wal InsertNodeId unknown label intern {label}"),
3159                    })?
3160                    .to_string();
3161                for (field_sym, value) in props {
3162                    let field =
3163                        self.syms
3164                            .resolve(*field_sym)
3165                            .ok_or_else(|| GraphError::Corrupt {
3166                                detail: format!(
3167                                    "wal InsertNodeId unknown field intern {field_sym}"
3168                                ),
3169                            })?;
3170                    self.props.set(id, field, value.clone());
3171                }
3172                self.view_store
3173                    .init_node_views(id, &mut self.props, &self.syms, &self.labels);
3174                let cursor = self.engine.pending_delta_count();
3175                let mut eng = std::mem::take(&mut self.engine);
3176                {
3177                    let mut gm = make_graph_mut(
3178                        &self.ids,
3179                        &mut self.syms,
3180                        &self.labels,
3181                        build_props_view(&self.props, &self.base),
3182                        &mut self.topo,
3183                        &self.base,
3184                        &mut self.edge_props,
3185                    );
3186                    eng.on_node_changed(id, None, &mut gm);
3187                }
3188                self.engine = eng;
3189                if !self.view_store.is_empty() {
3190                    #[cfg(test)]
3191                    DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
3192                    let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
3193                    for d in &new_deltas {
3194                        self.view_store.on_edge_changed(
3195                            d.etype_sym,
3196                            d.src_id,
3197                            d.dst_id,
3198                            d.fired,
3199                            &mut self.props,
3200                            &build_topo_view(&self.topo, &self.base),
3201                            &self.ids,
3202                            &self.syms,
3203                            &self.labels,
3204                            base_columns(&self.base),
3205                        );
3206                    }
3207                }
3208                if self.fulltext.has_label(&label_str) {
3209                    for (field_sym, value) in props {
3210                        let Some(field) = self.syms.resolve(*field_sym) else {
3211                            continue;
3212                        };
3213                        if self.fulltext.is_enabled(&label_str, field) {
3214                            self.fulltext.add_tokens(id, field, value);
3215                        }
3216                    }
3217                }
3218                if self.prop_index.has_label(&label_str) {
3219                    for (field_sym, value) in props {
3220                        let Some(field) = self.syms.resolve(*field_sym) else {
3221                            continue;
3222                        };
3223                        self.prop_index.set(&label_str, field, id, value);
3224                    }
3225                }
3226            }
3227            WalRecord::InsertEdgeId { etype, src, dst } => {
3228                // Replay-over-snapshot: dense ids in the pre-snapshot WAL may
3229                // already be tombstoned. Skip rather than attaching edges to
3230                // dead ids (DeleteNode keys the live re-insert, not the old id).
3231                if self.ids.is_tombstoned(*src)
3232                    || self.ids.is_tombstoned(*dst)
3233                    || self.ids.key_of(*src).is_none()
3234                    || self.ids.key_of(*dst).is_none()
3235                {
3236                    return Ok(());
3237                }
3238                // Skip if already visible in the merged view (same idempotency
3239                // guard as InsertEdge above: prevents double-counting when
3240                // pre-snapshot WAL records are replayed over a V8 base).
3241                if self.base.is_some()
3242                    && self
3243                        .topo_view()
3244                        .neighbors(*etype, Direction::Out, *src)
3245                        .contains(dst)
3246                {
3247                    return Ok(());
3248                }
3249                self.topo.add_edge(*etype, *src, *dst);
3250                self.view_store.on_edge_changed(
3251                    *etype,
3252                    *src,
3253                    *dst,
3254                    true,
3255                    &mut self.props,
3256                    &build_topo_view(&self.topo, &self.base),
3257                    &self.ids,
3258                    &self.syms,
3259                    &self.labels,
3260                    base_columns(&self.base),
3261                );
3262                // Rule engine: via-hop rules fire when user via-edges are inserted.
3263                // Resolve etype back to string so on_edge_changed can match rules by name.
3264                if let Some(etype_str) = self.syms.resolve(*etype).map(|s| s.to_string()) {
3265                    let cursor = self.engine.pending_delta_count();
3266                    let mut eng = std::mem::take(&mut self.engine);
3267                    {
3268                        let mut gm = make_graph_mut(
3269                            &self.ids,
3270                            &mut self.syms,
3271                            &self.labels,
3272                            build_props_view(&self.props, &self.base),
3273                            &mut self.topo,
3274                            &self.base,
3275                            &mut self.edge_props,
3276                        );
3277                        eng.on_edge_changed(&etype_str, *src, *dst, &mut gm);
3278                    }
3279                    self.engine = eng;
3280                    if !self.view_store.is_empty() {
3281                        let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
3282                        for d in &new_deltas {
3283                            self.view_store.on_edge_changed(
3284                                d.etype_sym,
3285                                d.src_id,
3286                                d.dst_id,
3287                                d.fired,
3288                                &mut self.props,
3289                                &build_topo_view(&self.topo, &self.base),
3290                                &self.ids,
3291                                &self.syms,
3292                                &self.labels,
3293                                base_columns(&self.base),
3294                            );
3295                        }
3296                    }
3297                }
3298            }
3299            WalRecord::SetPropId { id, field, value } => {
3300                if self.ids.is_tombstoned(*id) || self.ids.key_of(*id).is_none() {
3301                    return Ok(());
3302                }
3303                let field_str = self
3304                    .syms
3305                    .resolve(*field)
3306                    .ok_or_else(|| GraphError::Corrupt {
3307                        detail: format!("wal SetPropId unknown field intern {field}"),
3308                    })?
3309                    .to_string();
3310                let old_value = build_props_view(&self.props, &self.base)
3311                    .get(*id, &field_str)
3312                    .map(|vr| vr.into_value());
3313                self.props.set(*id, &field_str, value.clone());
3314                let cursor = self.engine.pending_delta_count();
3315                let mut eng = std::mem::take(&mut self.engine);
3316                {
3317                    let mut gm = make_graph_mut(
3318                        &self.ids,
3319                        &mut self.syms,
3320                        &self.labels,
3321                        build_props_view(&self.props, &self.base),
3322                        &mut self.topo,
3323                        &self.base,
3324                        &mut self.edge_props,
3325                    );
3326                    eng.on_node_changed(*id, Some((field_str.as_str(), old_value)), &mut gm);
3327                }
3328                self.engine = eng;
3329                if !self.view_store.is_empty() {
3330                    #[cfg(test)]
3331                    DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
3332                    let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
3333                    for d in &new_deltas {
3334                        self.view_store.on_edge_changed(
3335                            d.etype_sym,
3336                            d.src_id,
3337                            d.dst_id,
3338                            d.fired,
3339                            &mut self.props,
3340                            &build_topo_view(&self.topo, &self.base),
3341                            &self.ids,
3342                            &self.syms,
3343                            &self.labels,
3344                            base_columns(&self.base),
3345                        );
3346                    }
3347                }
3348                self.view_store.on_prop_changed(
3349                    *id,
3350                    &field_str,
3351                    &mut self.props,
3352                    &build_topo_view(&self.topo, &self.base),
3353                    &self.ids,
3354                    &self.syms,
3355                    &self.labels,
3356                    base_columns(&self.base),
3357                );
3358                if self.fulltext.field_indexed(&field_str) {
3359                    let label_opt = self.labels.get(*id as usize).and_then(|&sym| {
3360                        if sym == u32::MAX {
3361                            None
3362                        } else {
3363                            self.syms.resolve(sym)
3364                        }
3365                    });
3366                    if let Some(label) = label_opt {
3367                        if self.fulltext.is_enabled(label, &field_str) {
3368                            self.fulltext.remove_node_field(*id, &field_str);
3369                            self.fulltext.add_tokens(*id, &field_str, value);
3370                        }
3371                    }
3372                }
3373                if self.prop_index.field_indexed(&field_str) {
3374                    let label_opt = self.labels.get(*id as usize).and_then(|&sym| {
3375                        if sym == u32::MAX {
3376                            None
3377                        } else {
3378                            self.syms.resolve(sym)
3379                        }
3380                    });
3381                    if let Some(label) = label_opt {
3382                        self.prop_index.set(label, &field_str, *id, value);
3383                    }
3384                }
3385            }
3386            WalRecord::CreateRule { def_bytes } => {
3387                let def: RuleDef = decode_rule_def(def_bytes).map_err(|e| GraphError::Corrupt {
3388                    detail: format!("CreateRule def_bytes deserialize failed: {e}"),
3389                })?;
3390                // Replay-over-snapshot idempotency: the rule was captured in the snapshot
3391                // so the engine already has it; silently skip to avoid a spurious
3392                // RuleInvalid error in the crash window between snapshot write and WAL
3393                // truncation.
3394                if self.engine.rules().any(|r| r.name == def.name) {
3395                    return Ok(());
3396                }
3397                let cursor = self.engine.pending_delta_count();
3398                let mut eng = std::mem::take(&mut self.engine);
3399                let result = {
3400                    let mut gm = make_graph_mut(
3401                        &self.ids,
3402                        &mut self.syms,
3403                        &self.labels,
3404                        build_props_view(&self.props, &self.base),
3405                        &mut self.topo,
3406                        &self.base,
3407                        &mut self.edge_props,
3408                    );
3409                    eng.create_rule(def, &mut gm)
3410                };
3411                self.engine = eng;
3412                result.map_err(|e| GraphError::RuleInvalid { detail: e })?;
3413                // Derived-edge fires from backfill → view updates.
3414                // Fast path: skip O(edge_count) allocation when no views exist.
3415                if !self.view_store.is_empty() {
3416                    #[cfg(test)]
3417                    DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
3418                    let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
3419                    for d in &new_deltas {
3420                        self.view_store.on_edge_changed(
3421                            d.etype_sym,
3422                            d.src_id,
3423                            d.dst_id,
3424                            d.fired,
3425                            &mut self.props,
3426                            &build_topo_view(&self.topo, &self.base),
3427                            &self.ids,
3428                            &self.syms,
3429                            &self.labels,
3430                            base_columns(&self.base),
3431                        );
3432                    }
3433                }
3434            }
3435            WalRecord::DeleteRule { name } => {
3436                // Replay-over-snapshot idempotency: the snapshot already captured the
3437                // post-delete state so the rule is absent; silently skip to avoid a
3438                // spurious RuleNotFound error in the crash window between snapshot write
3439                // and WAL truncation.
3440                if !self.engine.rules().any(|r| r.name == *name) {
3441                    return Ok(());
3442                }
3443                let cursor = self.engine.pending_delta_count();
3444                let mut eng = std::mem::take(&mut self.engine);
3445                let result = {
3446                    let mut gm = make_graph_mut(
3447                        &self.ids,
3448                        &mut self.syms,
3449                        &self.labels,
3450                        build_props_view(&self.props, &self.base),
3451                        &mut self.topo,
3452                        &self.base,
3453                        &mut self.edge_props,
3454                    );
3455                    eng.delete_rule(name, &mut gm)
3456                };
3457                self.engine = eng;
3458                result.map_err(|_| GraphError::RuleNotFound { name: name.clone() })?;
3459                // Derived-edge retractions → view updates.
3460                if !self.view_store.is_empty() {
3461                    #[cfg(test)]
3462                    DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
3463                    let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
3464                    for d in &new_deltas {
3465                        self.view_store.on_edge_changed(
3466                            d.etype_sym,
3467                            d.src_id,
3468                            d.dst_id,
3469                            d.fired,
3470                            &mut self.props,
3471                            &build_topo_view(&self.topo, &self.base),
3472                            &self.ids,
3473                            &self.syms,
3474                            &self.labels,
3475                            base_columns(&self.base),
3476                        );
3477                    }
3478                }
3479            }
3480            WalRecord::RemoveProp { key, field } => {
3481                // Recovery-safe: unknown key or already-absent field is a
3482                // clean no-op. Crash-window replay over a snapshot that
3483                // already applied this record must not Err.
3484                let Some(id) = self.ids.get(key) else {
3485                    return Ok(());
3486                };
3487                // Read old value through the seam for rule retraction.
3488                let old = build_props_view(&self.props, &self.base)
3489                    .get(id, field)
3490                    .map(|vr| vr.into_value());
3491                self.props.remove(id, field);
3492                // If the base still supplies the value after the overlay removal,
3493                // record a tombstone so ColumnsView::get does not resurrect it.
3494                // This covers both the base-only case AND the both-resident case:
3495                //   base-only (in_overlay=false): old prop was only in base, remove
3496                //     is a no-op on overlay, base still visible → tombstone needed.
3497                //   both-resident (in_overlay=true): overlay had v2, base has v1;
3498                //     removing overlay uncovers v1 → tombstone needed.
3499                // Idempotent on double-replay: second pass sees the tombstone →
3500                // get() returns None → condition is false → no duplicate tombstone.
3501                if build_props_view(&self.props, &self.base)
3502                    .get(id, field)
3503                    .is_some()
3504                {
3505                    self.props.record_prop_tombstone(id, field);
3506                }
3507                let cursor = self.engine.pending_delta_count();
3508                let mut eng = std::mem::take(&mut self.engine);
3509                {
3510                    let mut gm = make_graph_mut(
3511                        &self.ids,
3512                        &mut self.syms,
3513                        &self.labels,
3514                        build_props_view(&self.props, &self.base),
3515                        &mut self.topo,
3516                        &self.base,
3517                        &mut self.edge_props,
3518                    );
3519                    eng.on_node_changed(id, Some((field, old)), &mut gm);
3520                }
3521                self.engine = eng;
3522                // Derived-edge deltas → view updates.
3523                if !self.view_store.is_empty() {
3524                    #[cfg(test)]
3525                    DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
3526                    let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
3527                    for d in &new_deltas {
3528                        self.view_store.on_edge_changed(
3529                            d.etype_sym,
3530                            d.src_id,
3531                            d.dst_id,
3532                            d.fired,
3533                            &mut self.props,
3534                            &build_topo_view(&self.topo, &self.base),
3535                            &self.ids,
3536                            &self.syms,
3537                            &self.labels,
3538                            base_columns(&self.base),
3539                        );
3540                    }
3541                }
3542                // Neighbor-aggregate views that read `field` must also update.
3543                self.view_store.on_prop_changed(
3544                    id,
3545                    field,
3546                    &mut self.props,
3547                    &build_topo_view(&self.topo, &self.base),
3548                    &self.ids,
3549                    &self.syms,
3550                    &self.labels,
3551                    base_columns(&self.base),
3552                );
3553                // Full-text index maintenance: remove tokens for this field.
3554                if self.fulltext.field_indexed(field) {
3555                    self.fulltext.remove_node_field(id, field);
3556                }
3557                // Property (equality) index maintenance: drop this node's entry.
3558                if self.prop_index.field_indexed(field) {
3559                    if let Some(label) = self.labels.get(id as usize).and_then(|&sym| {
3560                        (sym != u32::MAX).then(|| self.syms.resolve(sym)).flatten()
3561                    }) {
3562                        self.prop_index.remove_node(label, field, id);
3563                    }
3564                }
3565            }
3566            WalRecord::DeleteEdge {
3567                edge_type,
3568                src_key,
3569                dst_key,
3570            } => {
3571                // Recovery-safe: unknown keys, unknown etype, or already-
3572                // absent edge is a clean no-op (remove_edge returns false).
3573                let Some(src) = self.ids.get(src_key) else {
3574                    return Ok(());
3575                };
3576                let Some(dst) = self.ids.get(dst_key) else {
3577                    return Ok(());
3578                };
3579                let Some(etype) = self.syms.get(edge_type) else {
3580                    return Ok(());
3581                };
3582                // I3: phantom-tombstone guard.  When a V8 base is present, a
3583                // DeleteEdge WAL record for an edge that was already absorbed into
3584                // the new base (i.e. neither in overlay nor in base) must be skipped.
3585                // Without this guard, remove_edge records a tombstone for an edge
3586                // that no longer exists, incorrectly understating edge_count.
3587                if self.base.is_some()
3588                    && !self
3589                        .topo_view()
3590                        .neighbors(etype, core_storage::topology::Direction::Out, src)
3591                        .contains(&dst)
3592                {
3593                    return Ok(());
3594                }
3595                self.topo.remove_edge(etype, src, dst);
3596                self.edge_props.remove_edge(etype, src, dst);
3597                // View maintenance for manual edge delete (topo already updated above).
3598                self.view_store.on_edge_changed(
3599                    etype,
3600                    src,
3601                    dst,
3602                    false,
3603                    &mut self.props,
3604                    &build_topo_view(&self.topo, &self.base),
3605                    &self.ids,
3606                    &self.syms,
3607                    &self.labels,
3608                    base_columns(&self.base),
3609                );
3610                // Rule engine: via-hop rules must retract when user via-edges are deleted.
3611                let cursor = self.engine.pending_delta_count();
3612                let mut eng = std::mem::take(&mut self.engine);
3613                {
3614                    let mut gm = make_graph_mut(
3615                        &self.ids,
3616                        &mut self.syms,
3617                        &self.labels,
3618                        build_props_view(&self.props, &self.base),
3619                        &mut self.topo,
3620                        &self.base,
3621                        &mut self.edge_props,
3622                    );
3623                    eng.on_edge_changed(edge_type, src, dst, &mut gm);
3624                }
3625                self.engine = eng;
3626                if !self.view_store.is_empty() {
3627                    let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
3628                    for d in &new_deltas {
3629                        self.view_store.on_edge_changed(
3630                            d.etype_sym,
3631                            d.src_id,
3632                            d.dst_id,
3633                            d.fired,
3634                            &mut self.props,
3635                            &build_topo_view(&self.topo, &self.base),
3636                            &self.ids,
3637                            &self.syms,
3638                            &self.labels,
3639                            base_columns(&self.base),
3640                        );
3641                    }
3642                }
3643            }
3644            WalRecord::DeleteNode { key } => {
3645                // Recovery-safe: already-tombstoned / unknown key is a clean
3646                // no-op. Crash-window replay over a snapshot that already
3647                // applied this record cannot recover the retired id from the
3648                // key (`IdMap::get` is None), so every subsequent step is
3649                // skipped. Each step is independently idempotent if invoked
3650                // twice on a still-live id: retraction is a no-op on empty
3651                // provenance, `remove_edge` returns false, `remove_all` is a
3652                // no-op, `ids.delete` returns None, label sentinel is sticky.
3653                let Some(n) = self.ids.get(key) else {
3654                    return Ok(());
3655                };
3656
3657                // (1) Retract derived edges + de-index while props/labels live.
3658                let cursor = self.engine.pending_delta_count();
3659                let mut eng = std::mem::take(&mut self.engine);
3660                {
3661                    let mut gm = make_graph_mut(
3662                        &self.ids,
3663                        &mut self.syms,
3664                        &self.labels,
3665                        build_props_view(&self.props, &self.base),
3666                        &mut self.topo,
3667                        &self.base,
3668                        &mut self.edge_props,
3669                    );
3670                    eng.on_node_removed(n, &mut gm);
3671                }
3672                self.engine = eng;
3673                // Derived-edge retractions → view updates for neighbors.
3674                if !self.view_store.is_empty() {
3675                    #[cfg(test)]
3676                    DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
3677                    let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
3678                    for d in &new_deltas {
3679                        self.view_store.on_edge_changed(
3680                            d.etype_sym,
3681                            d.src_id,
3682                            d.dst_id,
3683                            d.fired,
3684                            &mut self.props,
3685                            &build_topo_view(&self.topo, &self.base),
3686                            &self.ids,
3687                            &self.syms,
3688                            &self.labels,
3689                            base_columns(&self.base),
3690                        );
3691                    }
3692                }
3693
3694                // (2) Sweep ALL remaining edges incident to n, both directions,
3695                // every etype. This cascade is intentionally mask-independent:
3696                // topology integrity requires removing every edge touching the
3697                // deleted node regardless of the caller's visibility scope.
3698                // (The mask limits which nodes a role's read phase can return;
3699                // the WAL delete always executes with full storage authority.)
3700                // Collect then remove so neighbor slices stay valid during
3701                // iteration. Remove from topo first, then call view maintenance
3702                // so Avg/Min/Max recompute sees the correct (reduced) neighbor set.
3703                let etypes: Vec<u32> = self.topo.etypes().collect();
3704                let mut doomed = Vec::new();
3705                for et in &etypes {
3706                    for &dst in self.topo.neighbors(*et, Direction::Out, n).as_ref() {
3707                        doomed.push((*et, n, dst));
3708                    }
3709                    for &src in self.topo.neighbors(*et, Direction::In, n).as_ref() {
3710                        doomed.push((*et, src, n));
3711                    }
3712                }
3713                for (et, s, d) in doomed {
3714                    self.topo.remove_edge(et, s, d);
3715                    self.edge_props.remove_edge(et, s, d);
3716                    // View maintenance: n's own view values will be cleared by
3717                    // remove_all below; only update surviving neighbors.
3718                    self.view_store.on_edge_changed(
3719                        et,
3720                        s,
3721                        d,
3722                        false,
3723                        &mut self.props,
3724                        &build_topo_view(&self.topo, &self.base),
3725                        &self.ids,
3726                        &self.syms,
3727                        &self.labels,
3728                        base_columns(&self.base),
3729                    );
3730                }
3731
3732                // (3) Drop every remaining prop (`ColumnStore::remove_all`).
3733                self.props.remove_all(n);
3734                // Full-text index maintenance: remove all tokens for this node.
3735                self.fulltext.remove_node(n);
3736                // Property (equality) index maintenance: drop all entries for n.
3737                self.prop_index.remove_node_all(n);
3738
3739                // (4) Retire the dense id and stamp the label sentinel.
3740                self.ids.delete(key);
3741                if let Some(slot) = self.labels.get_mut(n as usize) {
3742                    *slot = u32::MAX;
3743                }
3744            }
3745            WalRecord::Batch(inner) => {
3746                // Apply each inner record in order through the same apply path.
3747                // Inner records are validated free of nested Batch by encode_record.
3748                for rec in inner {
3749                    self.apply(rec)?;
3750                }
3751            }
3752            WalRecord::RebuildRule { name } => {
3753                // Replay-over-snapshot idempotency: the snapshot may already
3754                // reflect a later delete_rule, so the rule is absent; skip.
3755                if !self.engine.rules().any(|r| r.name == *name) {
3756                    return Ok(());
3757                }
3758                let cursor = self.engine.pending_delta_count();
3759                let mut eng = std::mem::take(&mut self.engine);
3760                let result = {
3761                    let mut gm = make_graph_mut(
3762                        &self.ids,
3763                        &mut self.syms,
3764                        &self.labels,
3765                        build_props_view(&self.props, &self.base),
3766                        &mut self.topo,
3767                        &self.base,
3768                        &mut self.edge_props,
3769                    );
3770                    eng.rebuild(name, &mut gm)
3771                };
3772                self.engine = eng;
3773                result.map_err(|_| GraphError::RuleNotFound { name: name.clone() })?;
3774                // Derived-edge delta changes → view updates.
3775                if !self.view_store.is_empty() {
3776                    #[cfg(test)]
3777                    DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
3778                    let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
3779                    for d in &new_deltas {
3780                        self.view_store.on_edge_changed(
3781                            d.etype_sym,
3782                            d.src_id,
3783                            d.dst_id,
3784                            d.fired,
3785                            &mut self.props,
3786                            &build_topo_view(&self.topo, &self.base),
3787                            &self.ids,
3788                            &self.syms,
3789                            &self.labels,
3790                            base_columns(&self.base),
3791                        );
3792                    }
3793                }
3794            }
3795            WalRecord::CreateView { def_bytes } => {
3796                let def: ViewDef =
3797                    bincode::deserialize(def_bytes).map_err(|e| GraphError::Corrupt {
3798                        detail: format!("CreateView def_bytes deserialize failed: {e}"),
3799                    })?;
3800                // Replay-over-snapshot idempotency: view already present → skip.
3801                if self.view_store.has_view(&def.name) {
3802                    return Ok(());
3803                }
3804                self.view_store
3805                    .create_view(
3806                        def,
3807                        &mut self.props,
3808                        &build_topo_view(&self.topo, &self.base),
3809                        &self.ids,
3810                        &self.syms,
3811                        &self.labels,
3812                    )
3813                    .map_err(|e| GraphError::RuleInvalid { detail: e })?;
3814            }
3815            WalRecord::DeleteView { name } => {
3816                // Replay-over-snapshot idempotency: view already absent → skip.
3817                if !self.view_store.has_view(name) {
3818                    return Ok(());
3819                }
3820                self.view_store
3821                    .delete_view(name, &mut self.props, &self.ids, &self.labels, &self.syms)
3822                    .map_err(|_| GraphError::RuleNotFound { name: name.clone() })?;
3823            }
3824            WalRecord::EnableFulltext { label, field } => {
3825                // Replay-over-snapshot idempotency: already enabled → skip.
3826                if self.fulltext.is_enabled(label, field) {
3827                    return Ok(());
3828                }
3829                self.fulltext.enable(label, field);
3830                // Backfill: index all live nodes of this label that have the field.
3831                let n = self.ids.len() as u32;
3832                for id in 0..n {
3833                    let Some(&sym) = self.labels.get(id as usize) else {
3834                        continue;
3835                    };
3836                    if sym == u32::MAX {
3837                        continue; // tombstoned
3838                    }
3839                    let Some(lbl) = self.syms.resolve(sym) else {
3840                        continue;
3841                    };
3842                    if lbl != label {
3843                        continue;
3844                    }
3845                    if let Some(value) = build_props_view(&self.props, &self.base)
3846                        .get(id, field)
3847                        .map(|vr| vr.into_value())
3848                    {
3849                        self.fulltext.add_tokens(id, field, &value);
3850                    }
3851                }
3852            }
3853            WalRecord::DisableFulltext { label, field } => {
3854                // Replay-over-snapshot idempotency: already disabled → skip.
3855                if !self.fulltext.is_enabled(label, field) {
3856                    return Ok(());
3857                }
3858                // If another label still indexes this field, the postings column
3859                // is kept — but it must not contain node_ids from the now-disabled
3860                // label.  Remove them before calling disable() so the field_indexed
3861                // guard inside disable() sees the correct post-removal state.
3862                if self.fulltext.field_indexed_by_other(label, field) {
3863                    if let Some(label_sym) = self.syms.get(label) {
3864                        for (node_id, &lsym) in self.labels.iter().enumerate() {
3865                            if lsym == label_sym {
3866                                self.fulltext.remove_node_field(node_id as u32, field);
3867                            }
3868                        }
3869                    }
3870                }
3871                self.fulltext.disable(label, field);
3872            }
3873            WalRecord::EnableIndex { label, field } => {
3874                // Replay-over-snapshot idempotency: already enabled → skip.
3875                if self.prop_index.is_enabled(label, field) {
3876                    return Ok(());
3877                }
3878                self.prop_index.enable(label, field);
3879                // Backfill: index all live nodes of this label that have the field.
3880                let n = self.ids.len() as u32;
3881                for id in 0..n {
3882                    let Some(&sym) = self.labels.get(id as usize) else {
3883                        continue;
3884                    };
3885                    if sym == u32::MAX {
3886                        continue; // tombstoned
3887                    }
3888                    let Some(lbl) = self.syms.resolve(sym) else {
3889                        continue;
3890                    };
3891                    if lbl != label {
3892                        continue;
3893                    }
3894                    if let Some(value) = build_props_view(&self.props, &self.base)
3895                        .get(id, field)
3896                        .map(|vr| vr.into_value())
3897                    {
3898                        self.prop_index.set(label, field, id, &value);
3899                    }
3900                }
3901            }
3902            WalRecord::DisableIndex { label, field } => {
3903                self.prop_index.disable(label, field);
3904            }
3905            // History markers carry no replay state — rules re-derive edges
3906            // deterministically on open/replay. Skip unconditionally.
3907            WalRecord::DerivedEdgeAdded { .. } | WalRecord::DerivedEdgeRetracted { .. } => {}
3908            // ── rename_node ──────────────────────────────────────────────────
3909            WalRecord::RenameNode { old_key, new_key } => {
3910                // Recovery-safe: if old_key is already gone (key was renamed
3911                // by a snapshot or a prior replay frame), skip cleanly.
3912                if self.ids.get(old_key).is_none() {
3913                    return Ok(());
3914                }
3915                // The rename only updates the key-table; the dense id, all
3916                // topo edges, props, labels, and rule state are id-indexed and
3917                // require no change.
3918                self.ids
3919                    .rename(old_key, new_key)
3920                    .map_err(|e| GraphError::Corrupt {
3921                        detail: format!("wal replay RenameNode {old_key}→{new_key}: {e}"),
3922                    })?;
3923            }
3924        }
3925        Ok(())
3926    }
3927
3928    /// Intern `s` in `syms` and emit a WAL `Intern` record so `*Id` records
3929    /// replay on WAL-only `open_at` (no snapshot intern table). Apply is
3930    /// idempotent when the string is already bound. Always emit: after
3931    /// `snapshot()` the WAL is truncated and live intern is not on disk.
3932    fn intern_wal(&mut self, s: &str) -> (u32, WalRecord) {
3933        let id = if let Some(id) = self.syms.get(s) {
3934            id
3935        } else {
3936            self.syms.intern(s)
3937        };
3938        (
3939            id,
3940            WalRecord::Intern {
3941                id,
3942                text: s.to_string(),
3943            },
3944        )
3945    }
3946
3947    /// Rewrite user-facing records into dense-id records. On `Err`, no live
3948    /// state is left mutated: speculative interns made while building the
3949    /// output are rolled back, so a later successful mutation cannot log an
3950    /// `Intern` record whose id replay would never reproduce.
3951    fn rewrite_wal_dense(&mut self, recs: Vec<WalRecord>) -> Result<Vec<WalRecord>> {
3952        let syms_checkpoint = self.syms.len();
3953        let result = self.rewrite_wal_dense_inner(recs);
3954        if result.is_err() {
3955            self.syms.truncate(syms_checkpoint);
3956        }
3957        result
3958    }
3959
3960    fn rewrite_wal_dense_inner(&mut self, recs: Vec<WalRecord>) -> Result<Vec<WalRecord>> {
3961        let mut out = Vec::with_capacity(recs.len());
3962        // Node ids allocated by later apply(InsertNodeId) in this same batch.
3963        let mut pending: std::collections::HashMap<String, u32> = std::collections::HashMap::new();
3964        let mut interned = std::collections::HashSet::<u32>::new();
3965        let mut next = u32::try_from(self.ids.len()).map_err(|_| GraphError::Corrupt {
3966            detail: "id space exhausted".into(),
3967        })?;
3968        let lookup = |ids: &IdMap,
3969                      pending: &std::collections::HashMap<String, u32>,
3970                      key: &str|
3971         -> Option<u32> { ids.get(key).or_else(|| pending.get(key).copied()) };
3972        for rec in recs {
3973            match rec {
3974                WalRecord::InsertNode { label, key, props } => {
3975                    let (label_id, intern) = self.intern_wal(&label);
3976                    if interned.insert(label_id) {
3977                        out.push(intern);
3978                    }
3979                    let mut props_id = Vec::with_capacity(props.len());
3980                    for (field, value) in props {
3981                        let (field_id, intern) = self.intern_wal(&field);
3982                        if interned.insert(field_id) {
3983                            out.push(intern);
3984                        }
3985                        props_id.push((field_id, value));
3986                    }
3987                    if lookup(&self.ids, &pending, &key).is_none() {
3988                        pending.insert(key.clone(), next);
3989                        next = next.checked_add(1).ok_or_else(|| GraphError::Corrupt {
3990                            detail: "id space exhausted".into(),
3991                        })?;
3992                    }
3993                    out.push(WalRecord::InsertNodeId {
3994                        label: label_id,
3995                        key,
3996                        props: props_id,
3997                    });
3998                }
3999                WalRecord::SetProp { key, field, value } => {
4000                    let id =
4001                        lookup(&self.ids, &pending, &key).ok_or_else(|| GraphError::Corrupt {
4002                            detail: format!("dense WAL rewrite missing key {key}"),
4003                        })?;
4004                    let (field_id, intern) = self.intern_wal(&field);
4005                    if interned.insert(field_id) {
4006                        out.push(intern);
4007                    }
4008                    out.push(WalRecord::SetPropId {
4009                        id,
4010                        field: field_id,
4011                        value,
4012                    });
4013                }
4014                WalRecord::InsertEdge {
4015                    edge_type,
4016                    src_key,
4017                    dst_key,
4018                } => {
4019                    let (etype, intern) = self.intern_wal(&edge_type);
4020                    if interned.insert(etype) {
4021                        out.push(intern);
4022                    }
4023                    let src = lookup(&self.ids, &pending, &src_key).ok_or_else(|| {
4024                        GraphError::Corrupt {
4025                            detail: format!("dense WAL rewrite missing src {src_key}"),
4026                        }
4027                    })?;
4028                    let dst = lookup(&self.ids, &pending, &dst_key).ok_or_else(|| {
4029                        GraphError::Corrupt {
4030                            detail: format!("dense WAL rewrite missing dst {dst_key}"),
4031                        }
4032                    })?;
4033                    out.push(WalRecord::InsertEdgeId { etype, src, dst });
4034                }
4035                WalRecord::RenameNode {
4036                    ref old_key,
4037                    ref new_key,
4038                } => {
4039                    // Track the rename in `pending` so subsequent InsertEdge /
4040                    // SetProp records in this batch can resolve the new key.
4041                    let id = lookup(&self.ids, &pending, old_key).ok_or_else(|| {
4042                        GraphError::Corrupt {
4043                            detail: format!(
4044                                "dense WAL rewrite: RenameNode old key {old_key} not found"
4045                            ),
4046                        }
4047                    })?;
4048                    pending.remove(old_key.as_str());
4049                    pending.insert(new_key.clone(), id);
4050                    out.push(rec);
4051                }
4052                // # Symbol-order invariant (load-bearing)
4053                //
4054                // Write-time and replay-time symbol assignment must agree: every
4055                // symbol in a `Batch` frame has to receive the same dense id when
4056                // the frame's records are replayed in order as it received when
4057                // the frame was written.
4058                //
4059                // A rule's backfill interns its `edge_type` lazily
4060                // (`core_rules::engine`, every `g.syms.intern(&def.edge_type)`
4061                // site), and that backfill runs from `apply` — during the
4062                // `CreateRule` record itself, and again from any later
4063                // `InsertNodeId` in the same frame that makes the rule fire. At
4064                // write time the whole batch is rewritten before any of it is
4065                // applied, so a later `InsertEdge` in the same batch would win the
4066                // lower id for its edge type; on replay the rule's lazy intern
4067                // gets there first and steals it, and the `Intern` record fails at
4068                // the `wal intern assigned …` check in `apply`.
4069                //
4070                // Pre-interning the rule's `edge_type` here, and emitting its
4071                // `Intern` record ahead of the `CreateRule` record, makes both
4072                // orders identical. `weight_prop` needs no pre-intern:
4073                // `EdgeProps::set` keys props by `String`, never through the
4074                // interner. `via_edge` needs none either: via-hop rules resolve it
4075                // with `syms.get` and skip when it is absent.
4076                //
4077                // `RebuildRule` and `DeleteRule` need no such handling here:
4078                // `RebuildRule` has no `BatchOp` variant, so it never appears
4079                // inside a `Batch` today — it is only ever issued as its own
4080                // standalone commit (`rebuild_rule`, or the auto-rebuild path
4081                // that logs it as a second commit after the triggering op).
4082                // `DeleteRule` does have a `BatchOp` variant and can appear
4083                // inside a `Batch`, but it carries only a rule `name` — no
4084                // `edge_type` or other symbol that needs pre-interning — so
4085                // only `CreateRule` needs this arm.
4086                WalRecord::CreateRule { ref def_bytes } => {
4087                    let def = decode_rule_def(def_bytes).map_err(|e| GraphError::Corrupt {
4088                        detail: format!("CreateRule def_bytes deserialize failed: {e}"),
4089                    })?;
4090                    let (etype, intern) = self.intern_wal(&def.edge_type);
4091                    if interned.insert(etype) {
4092                        out.push(intern);
4093                    }
4094                    out.push(rec);
4095                }
4096                other => out.push(other),
4097            }
4098        }
4099        Ok(out)
4100    }
4101
4102    fn log_dense(&mut self, recs: Vec<WalRecord>) -> Result<()> {
4103        let recs = self.rewrite_wal_dense(recs)?;
4104        match recs.len() {
4105            0 => Ok(()),
4106            1 => self.log_then_apply(recs.into_iter().next().unwrap()),
4107            _ => self.log_then_apply(WalRecord::Batch(recs)),
4108        }
4109    }
4110
4111    /// Durable write, then notify the event sink. Replay (`apply` during
4112    /// `open`) never enters this function, so it is the replay-silent seam.
4113    fn log_then_apply(&mut self, rec: WalRecord) -> Result<()> {
4114        self.log_then_apply_with(rec, None, self.fsync)
4115    }
4116
4117    /// Whether this frame must fsync under `policy`.
4118    ///
4119    /// Batched contract: user-visible batches (>1 mutation) fsync; single
4120    /// mutations do not. The dense rewrite wraps a single mutation in a
4121    /// `Batch([Intern.., <one *Id record>])`, so `Intern` records are excluded
4122    /// from the count — removing that filter would make every single-op write
4123    /// fsync under Batched (or, if the threshold were raised instead, skip a
4124    /// needed fsync for real two-op batches).
4125    fn wal_needs_sync(policy: FsyncPolicy, rec: &WalRecord) -> bool {
4126        match policy {
4127            FsyncPolicy::Relaxed => false,
4128            FsyncPolicy::Strict => true,
4129            FsyncPolicy::Batched => match rec {
4130                // Intern + one mutation is the single-op rewrite, not a user batch.
4131                WalRecord::Batch(inner) => {
4132                    inner
4133                        .iter()
4134                        .filter(|r| !matches!(r, WalRecord::Intern { .. }))
4135                        .count()
4136                        > 1
4137                }
4138                _ => false,
4139            },
4140        }
4141    }
4142
4143    /// # Apply-infallibility invariant (load-bearing)
4144    ///
4145    /// The ordering is: WAL append → fsync → apply. If `apply` returned `Err`
4146    /// for a `Batch` frame after a successful WAL write, the WAL would contain
4147    /// the full frame while in-memory state would reflect only the ops before
4148    /// the failure. On reopen, WAL replay would then apply the entire batch —
4149    /// diverging permanently from what the pre-crash process had in memory.
4150    ///
4151    /// For `Batch` frames this situation cannot arise because:
4152    /// - All validation runs via `commit_logged_batch`/`MutPreview` **before**
4153    ///   the WAL write. `MutPreview` uses the same `&mut self` that apply will
4154    ///   use, with no concurrent mutation between validation exit and apply entry.
4155    /// - Every `apply` arm for a validated op is either infallible by construction
4156    ///   (`InsertNode`, `RemoveProp`, `DeleteEdge`, `DeleteNode`), has idempotency
4157    ///   guards that return `Ok(())` (`CreateRule`, `DeleteRule`), or is
4158    ///   guaranteed-present by validation (`InsertEdge`/`SetProp` key lookups).
4159    /// - `on_node_changed` and `on_node_removed` return `()` — never `Err`.
4160    ///
4161    /// A `debug_assert!` below fires in debug builds if `apply` ever returns
4162    /// `Err` for a `Batch` frame, making any future regression immediately visible
4163    /// in tests rather than silently diverging crash-recovery behaviour.
4164    fn log_then_apply_with(
4165        &mut self,
4166        rec: WalRecord,
4167        ingest: Option<(String, usize)>,
4168        policy: FsyncPolicy,
4169    ) -> Result<()> {
4170        // Read-only guard: as-of instances must never write the WAL.
4171        if self.read_only {
4172            return Err(GraphError::ReadOnly);
4173        }
4174        // Degraded guard: fsync failure left WAL truncated, or a refresh failed
4175        // partway; in-memory state is ahead of (or out of step with) the
4176        // on-disk WAL, so further mutations would deepen the divergence.
4177        // Reopen the database to recover.  Checked before the lock guard: this
4178        // is the more serious condition and the more useful error.
4179        if self.degraded {
4180            return Err(GraphError::Io(std::io::Error::other(
4181                "database degraded after group-commit fsync failure; reopen required",
4182            )));
4183        }
4184        // Cross-process guard: this write scope asked for the store's write
4185        // lock and did not get it. Writing anyway would append frames on top of
4186        // a WAL another process is extending, so refuse instead.
4187        if self.lock_denied {
4188            return Err(GraphError::Busy { holder: None });
4189        }
4190        // Ensure retained provenance bytes are decoded into the live mutable
4191        // fields before any mutation touches self.engine.provenance.  This is a
4192        // no-op if provenance was never stored (fresh store) or has already been
4193        // consumed (subsequent mutations).  WAL replay calls apply() directly
4194        // and is covered by consume_retained_state_eager before replay.
4195        self.ensure_v8_base_sections_loaded();
4196        self.engine.ensure_provenance_loaded_mut();
4197        // Invariant (I-1): no stale deltas may enter from a previous apply.
4198        // If any engine method ever accumulates deltas before erroring, they would
4199        // contaminate the *next* commit's event stream. This assert fires in debug
4200        // builds, making any future regression visible at the earliest point.
4201        debug_assert_eq!(
4202            self.engine.pending_delta_count(),
4203            0,
4204            "stale engine deltas at log_then_apply_with entry — \
4205             a previous apply arm may have accumulated deltas before erroring; \
4206             the caller must drain_deltas() on any error path before returning"
4207        );
4208        let frame = encode_record(&rec);
4209        self.fs.append(FileId::Wal, &frame)?;
4210        // The cursor advances by exactly the bytes appended: these frames are
4211        // ours and already applied, so a later refresh must not replay them.
4212        self.wal_consumed += frame.len() as u64;
4213        if Self::wal_needs_sync(policy, &rec) {
4214            self.fs.sync(FileId::Wal)?;
4215        }
4216        // Marker writing always needs the engine deltas, but the engine only
4217        // accumulates them when emit_deltas is true (normally gated on subscribers
4218        // or views being present).  Enable emission for this apply if it is
4219        // currently off, then restore the original state unconditionally via an
4220        // RAII guard — this prevents a panic in apply() from leaking the flag.
4221        // The same guard resets the engine's transient chaining state. A panic
4222        // unwinding out of a rule hook would otherwise leave `chain_depth`
4223        // non-zero, which makes every later `begin_chain` decide chaining is
4224        // already running and silently switch it off for good.
4225        struct RestoreEmitDeltas(*mut RuleEngine, bool);
4226        impl Drop for RestoreEmitDeltas {
4227            fn drop(&mut self) {
4228                // SAFETY: pointer into self (GraphDb); guard is dropped within
4229                // this frame before log_then_apply_with returns.
4230                unsafe {
4231                    (*self.0).set_emit_deltas(self.1);
4232                    (*self.0).reset_chain_state();
4233                }
4234            }
4235        }
4236        let original_emit = self.engine.emit_deltas();
4237        if !original_emit {
4238            self.engine.set_emit_deltas(true);
4239        }
4240        // SAFETY: raw pointer into self; guard dropped within this frame.
4241        let _emit_guard = RestoreEmitDeltas(&mut self.engine as *mut _, original_emit);
4242
4243        let apply_result = self.apply(&rec);
4244        // For Batch frames, post-validation apply must be infallible (see above).
4245        // A debug_assert here catches any future change that makes apply fallible
4246        // before the caller notices via silent WAL/memory divergence.
4247        if matches!(&rec, WalRecord::Batch(_)) {
4248            debug_assert!(
4249                apply_result.is_ok(),
4250                "Batch apply returned Err after successful WAL write — \
4251                 the validate-then-apply invariant has been violated; \
4252                 see log_then_apply_with invariant doc"
4253            );
4254        }
4255        if apply_result.is_err() {
4256            // Discard any partial deltas accumulated by the failed apply.
4257            // They must not ride the next commit's event stream (I-1).
4258            // _emit_guard restores emit_deltas on drop automatically.
4259            let _ = self.engine.drain_deltas();
4260            let _ = self.engine.take_rebuild_needed();
4261            apply_result?;
4262        }
4263        self.commit_seq += 1;
4264        let seq = self.commit_seq;
4265        // Update per-node last-change map for the committed record.
4266        // Must happen after commit_seq is incremented so the seq is correct.
4267        self.update_last_change_from_rec(&rec, seq);
4268        // Drain engine deltas and distribute to subscribers before the existing
4269        // MutationEvent sink fires — both happen post-fsync, post-apply.
4270        // _emit_guard restores emit_deltas after this line when it drops.
4271        let engine_deltas = self.engine.drain_deltas();
4272
4273        // Append history-marker WAL records for any derived-edge changes so
4274        // that `edge_history` and `was_linked` can surface rule-attributed
4275        // events. Markers are STATE NO-OPS during replay; they are written
4276        // without an additional fsync (the triggering commit's sync already
4277        // happened; the next commit's sync covers these lazily).
4278        if !engine_deltas.is_empty() {
4279            let markers: Vec<WalRecord> = engine_deltas
4280                .iter()
4281                .map(|d| {
4282                    if d.fired {
4283                        WalRecord::DerivedEdgeAdded {
4284                            rule: d.rule.clone(),
4285                            edge_type: d.edge_type.clone(),
4286                            src_key: d.src_key.clone(),
4287                            dst_key: d.dst_key.clone(),
4288                        }
4289                    } else {
4290                        WalRecord::DerivedEdgeRetracted {
4291                            rule: d.rule.clone(),
4292                            edge_type: d.edge_type.clone(),
4293                            src_key: d.src_key.clone(),
4294                            dst_key: d.dst_key.clone(),
4295                        }
4296                    }
4297                })
4298                .collect();
4299            let marker_frame = if markers.len() == 1 {
4300                markers.into_iter().next().unwrap()
4301            } else {
4302                WalRecord::Batch(markers)
4303            };
4304            // Ignore append errors: markers are best-effort history
4305            // annotations. Losing them does not affect state correctness.
4306            // The cursor only advances when the bytes actually landed.
4307            let marker_bytes = encode_record(&marker_frame);
4308            if self.fs.append(FileId::Wal, &marker_bytes).is_ok() {
4309                self.wal_consumed += marker_bytes.len() as u64;
4310            }
4311        }
4312
4313        // Record MVCC CommitDelta for the epoch reader.  The WAL record is
4314        // stored as-is (including any nested Batch / Intern records); the
4315        // ReaderSnapshot's apply_one function handles all variants.
4316        {
4317            let derived_inserts = engine_deltas
4318                .iter()
4319                .filter(|d| d.fired)
4320                .map(|d| (d.etype_sym, d.src_id, d.dst_id))
4321                .collect();
4322            let derived_deletes = engine_deltas
4323                .iter()
4324                .filter(|d| !d.fired)
4325                .map(|d| (d.etype_sym, d.src_id, d.dst_id))
4326                .collect();
4327            let delta = Arc::new(crate::reader::CommitDelta {
4328                records: vec![rec.clone()],
4329                derived_inserts,
4330                derived_deletes,
4331            });
4332            self.delta_tail.push(delta);
4333            self.commits_since_fold += 1;
4334            if self.commits_since_fold >= crate::reader::FOLD_EVERY_K {
4335                self.fold_now();
4336            }
4337        }
4338
4339        if self.defer_events {
4340            // Group-commit drain thread: hold events until after the group
4341            // fsync so subscribers only observe durable data (R2).
4342            self.deferred_events.push(DeferredEvent {
4343                rec: rec.clone(),
4344                engine_deltas,
4345                seq,
4346                ingest,
4347            });
4348        } else {
4349            self.distribute_events(&rec, &engine_deltas, seq);
4350            self.emit_committed(&rec, ingest);
4351        }
4352        // Drift is only known after apply, so auto-rebuild cannot join the
4353        // triggering op's WAL frame. Issue RebuildRule as a second commit.
4354        // Skip when `rec` is itself RebuildRule: rebuild resets drift, so a
4355        // retrigger loop is impossible if the fit succeeded, but we still
4356        // drain the flag so a leftover cannot re-enter.
4357        let rebuilds = self.engine.take_rebuild_needed();
4358        if !matches!(&rec, WalRecord::RebuildRule { .. }) {
4359            let mut failed = Vec::new();
4360            for name in rebuilds {
4361                if self.engine.rules().any(|r| r.name == name) {
4362                    // User op is already durable. A failed second commit must
4363                    // not surface as the caller's error.
4364                    if let Err(e) =
4365                        self.log_then_apply(WalRecord::RebuildRule { name: name.clone() })
4366                    {
4367                        eprintln!(
4368                            "auto-rebuild of rule {name:?} failed after durable user commit: {e}"
4369                        );
4370                        failed.push(name);
4371                    }
4372                }
4373            }
4374            for name in failed {
4375                self.engine.queue_rebuild_needed(name);
4376            }
4377        }
4378        Ok(())
4379    }
4380
4381    /// Install a post-commit hook. Replaces any previous sink.
4382    ///
4383    /// The sink runs inside `log_then_apply` after a successful
4384    /// durable commit, while the caller still holds `&mut self`. When this
4385    /// database is behind a [`crate::SharedDb`], that means the **write
4386    /// guard is held**. The sink must never call `read` / `write` (or any
4387    /// other method) on the same `SharedDb` — the `RwLock` is not
4388    /// re-entrant and doing so deadlocks. The sink is `Send + Sync`;
4389    /// `std::sync::mpsc::Sender` is not `Sync` and will not type-check.
4390    /// Intended examples: `std::sync::mpsc::SyncSender`,
4391    /// `tokio::sync::mpsc::Sender`, `tokio::sync::broadcast::Sender`
4392    /// (non-blocking `send`), or `Arc<Mutex<Vec<MutationEvent>>>`.
4393    pub fn set_event_sink(&mut self, sink: Box<dyn Fn(MutationEvent) + Send + Sync>) {
4394        self.event_sink = Some(sink);
4395    }
4396
4397    /// Whether a post-commit event sink is currently installed.
4398    pub fn has_event_sink(&self) -> bool {
4399        self.event_sink.is_some()
4400    }
4401
4402    /// Set WAL fsync cadence. Default [`FsyncPolicy::Strict`].
4403    pub fn set_fsync_policy(&mut self, p: FsyncPolicy) {
4404        self.fsync = p;
4405    }
4406
4407    /// Return the current WAL fsync cadence.
4408    pub fn fsync_policy(&self) -> FsyncPolicy {
4409        self.fsync
4410    }
4411
4412    // ── Group-commit event deferral ───────────────────────────────────────────
4413
4414    /// Enable or disable deferred event mode.
4415    ///
4416    /// When `true`, event notifications (subscription `DbEvent`s and legacy
4417    /// `MutationEvent` sink calls) are buffered rather than fired immediately.
4418    /// Call [`flush_deferred_events`] after the group fsync to deliver them,
4419    /// or [`discard_deferred_events`] if the fsync failed and the group must
4420    /// be treated as lost.
4421    pub fn set_deferred_events_mode(&mut self, defer: bool) {
4422        self.defer_events = defer;
4423    }
4424
4425    /// Fire all buffered events accumulated since [`set_deferred_events_mode`]
4426    /// was set to true.  Clears the buffer.
4427    ///
4428    /// Called by the drain thread AFTER a successful group fsync, so
4429    /// subscribers observe only data that is durably on disk.
4430    pub fn flush_deferred_events(&mut self) {
4431        let events = std::mem::take(&mut self.deferred_events);
4432        for de in events {
4433            self.distribute_events(&de.rec, &de.engine_deltas, de.seq);
4434            self.emit_committed(&de.rec, de.ingest);
4435        }
4436    }
4437
4438    /// Discard all buffered events without firing them.
4439    ///
4440    /// Called by the drain thread when a group fsync fails: the WAL has been
4441    /// truncated back to the pre-group offset, so the committed-but-unsynced
4442    /// ops must not be observable to subscribers.
4443    pub fn discard_deferred_events(&mut self) {
4444        self.deferred_events.clear();
4445    }
4446
4447    // ── Degraded state ────────────────────────────────────────────────────────
4448
4449    /// Mark this database as degraded.
4450    ///
4451    /// Called by the group-commit drain thread after a group fsync failure and
4452    /// WAL truncation: the in-memory state is now ahead of the on-disk WAL, so
4453    /// further mutations would deepen the divergence.  All subsequent calls to
4454    /// [`log_then_apply_with`] return `Err` until the database is reopened.
4455    pub fn set_degraded(&mut self) {
4456        self.degraded = true;
4457    }
4458
4459    fn emit(&self, ev: MutationEvent) {
4460        if let Some(sink) = &self.event_sink {
4461            sink(ev);
4462        }
4463    }
4464
4465    fn emit_committed(&self, rec: &WalRecord, ingest: Option<(String, usize)>) {
4466        match rec {
4467            WalRecord::Batch(inner) => {
4468                for r in inner {
4469                    if let Some(ev) = event_from_record(r, &self.syms, &self.ids) {
4470                        self.emit(ev);
4471                    }
4472                }
4473                match ingest {
4474                    Some((label, inserted)) => {
4475                        self.emit(MutationEvent::Ingested { label, inserted })
4476                    }
4477                    None => {
4478                        let ops = inner
4479                            .iter()
4480                            .filter(|r| !matches!(r, WalRecord::Intern { .. }))
4481                            .count();
4482                        if ops > 1 {
4483                            self.emit(MutationEvent::BatchApplied { ops });
4484                        }
4485                    }
4486                }
4487            }
4488            other => {
4489                if let Some(ev) = event_from_record(other, &self.syms, &self.ids) {
4490                    self.emit(ev);
4491                }
4492            }
4493        }
4494    }
4495
4496    // -----------------------------------------------------------------------
4497    // Subscription API
4498    // -----------------------------------------------------------------------
4499
4500    /// Distribute post-commit events to all live subscribers.
4501    ///
4502    /// Build a row-key → row-data map from a [`ResultSet`].
4503    ///
4504    /// Each row is serialized to JSON to form its key; a debug fallback is used
4505    /// if serialization fails. Used by both the initial-seed path in
4506    /// [`Self::subscribe_query`] and the per-commit diff path in
4507    /// [`Self::distribute_events`] to keep the two in sync.
4508    fn result_to_row_map(
4509        result: &core_query::ResultSet,
4510    ) -> std::collections::HashMap<String, Vec<Option<Value>>> {
4511        (0..result.len())
4512            .map(|i| {
4513                let row = result.row(i).to_vec();
4514                let key = serde_json::to_string(&row).unwrap_or_else(|_| format!("{row:?}"));
4515                (key, row)
4516            })
4517            .collect()
4518    }
4519
4520    /// Collect the set of label syms touched by a WAL record.
4521    ///
4522    /// Returns `Some(set)` when every record in this commit can be attributed to
4523    /// a known label sym. Returns `None` when the commit must not be skipped:
4524    /// edge records, unresolvable key→label lookups, or any record type not in
4525    /// the explicit handled set.
4526    ///
4527    /// Handled record types and their actions:
4528    /// - `InsertNode`   → look up label in interner (fails → None)
4529    /// - `InsertNodeId` → label sym is carried directly
4530    /// - `SetProp`      → resolve key→id→label (fails → None)
4531    /// - `DeleteNode`   → resolve key→id→label (fails → None)
4532    /// - `Batch`        → recurse into every inner record
4533    /// - `InsertEdge`, `DeleteEdge`, `InsertEdgeId` → always None (edge records)
4534    /// - everything else → None (conservative)
4535    fn commit_touched_labels(
4536        rec: &WalRecord,
4537        syms: &Interner,
4538        ids: &IdMap,
4539        labels: &[u32],
4540    ) -> Option<BTreeSet<u32>> {
4541        let mut out = BTreeSet::new();
4542        if Self::collect_touched_labels(rec, syms, ids, labels, &mut out) {
4543            Some(out)
4544        } else {
4545            None
4546        }
4547    }
4548
4549    fn collect_touched_labels(
4550        rec: &WalRecord,
4551        syms: &Interner,
4552        ids: &IdMap,
4553        labels: &[u32],
4554        out: &mut BTreeSet<u32>,
4555    ) -> bool {
4556        match rec {
4557            // String-key insert: the dense rewrite converts this to
4558            // [Intern, InsertNodeId], so this arm fires only for legacy WAL
4559            // records written before the dense path was added.
4560            WalRecord::InsertNode { label, .. } => {
4561                if let Some(sym) = syms.get(label) {
4562                    out.insert(sym);
4563                    true
4564                } else {
4565                    false
4566                }
4567            }
4568            // Dense-id insert (produced by rewrite_wal_dense for every
4569            // insert_node call in the current codebase).
4570            WalRecord::InsertNodeId { label, .. } => {
4571                out.insert(*label);
4572                true
4573            }
4574            // String-key prop set: dense path converts to [Intern, SetPropId].
4575            WalRecord::SetProp { key, .. } => {
4576                if let Some(sym) = Self::resolve_key_label_sym(key, ids, labels) {
4577                    out.insert(sym);
4578                    true
4579                } else {
4580                    false
4581                }
4582            }
4583            // Dense-id prop set (produced by rewrite_wal_dense for set_prop).
4584            WalRecord::SetPropId { id, .. } => {
4585                if let Some(sym) = labels.get(*id as usize).copied().filter(|&s| s != u32::MAX) {
4586                    out.insert(sym);
4587                    true
4588                } else {
4589                    false
4590                }
4591            }
4592            WalRecord::DeleteNode { key } => {
4593                if let Some(sym) = Self::resolve_key_label_sym(key, ids, labels) {
4594                    out.insert(sym);
4595                    true
4596                } else {
4597                    false
4598                }
4599            }
4600            WalRecord::Batch(inner) => inner
4601                .iter()
4602                .all(|r| Self::collect_touched_labels(r, syms, ids, labels, out)),
4603            // Intern is a pure metadata record — it does not touch any node's
4604            // label and is safe to skip for the label-skip predicate.
4605            WalRecord::Intern { .. } => true,
4606            // Edge records: always re-execute (edges can change join results).
4607            WalRecord::InsertEdge { .. }
4608            | WalRecord::DeleteEdge { .. }
4609            | WalRecord::InsertEdgeId { .. } => false,
4610            _ => false,
4611        }
4612    }
4613
4614    /// Resolve a node key to its label sym via the dense id table.
4615    /// Returns `None` if the key is unknown or the label is a tombstone sentinel.
4616    fn resolve_key_label_sym(key: &str, ids: &IdMap, labels: &[u32]) -> Option<u32> {
4617        let id = ids.get(key)?;
4618        let sym = labels.get(id as usize).copied()?;
4619        (sym != u32::MAX).then_some(sym)
4620    }
4621
4622    /// Distribute post-commit events to all live subscribers.
4623    ///
4624    /// Called from `log_then_apply_with` after apply + fsync, before the
4625    /// legacy MutationEvent sink. Prunes dead `Weak` entries in-place.
4626    ///
4627    /// Query subscriptions (subscribe_query) re-execute their plan on every
4628    /// call and diff the result against the previous run. Zero overhead when
4629    /// no query subscriptions are active.
4630    fn distribute_events(&mut self, rec: &WalRecord, engine_deltas: &[EngineEdgeDelta], seq: u64) {
4631        if self.subscriptions.is_empty() && self.query_subscriptions.is_empty() {
4632            return;
4633        }
4634
4635        if !self.subscriptions.is_empty() {
4636            // Build write events from the WAL record.
4637            let write_events: Vec<DbEvent> =
4638                Self::write_events_from_record(rec, seq, &self.syms, &self.ids);
4639
4640            // Build edge events from engine deltas.  Weight is looked up from
4641            // edge_props at distribution time (after apply), so it's always fresh.
4642            let edge_events: Vec<DbEvent> = engine_deltas
4643                .iter()
4644                .map(|d| {
4645                    if d.fired {
4646                        // The score lives under the rule's declared weight_prop,
4647                        // which is not always the literal "weight".
4648                        let prop = self
4649                            .engine
4650                            .rules()
4651                            .find(|r| r.name == d.rule)
4652                            .and_then(|r| r.weight_prop.as_deref());
4653                        let weight = prop.and_then(|p| {
4654                            self.edge_props
4655                                .get(d.etype_sym, d.src_id, d.dst_id, p)
4656                                .and_then(|v| {
4657                                    if let core_storage::Value::Float(f) = v {
4658                                        Some(*f)
4659                                    } else {
4660                                        None
4661                                    }
4662                                })
4663                        });
4664                        DbEvent::EdgeFired {
4665                            rule: d.rule.clone(),
4666                            src_key: d.src_key.clone(),
4667                            dst_key: d.dst_key.clone(),
4668                            edge_type: d.edge_type.clone(),
4669                            weight,
4670                            commit_seq: seq,
4671                        }
4672                    } else {
4673                        DbEvent::EdgeRetracted {
4674                            rule: d.rule.clone(),
4675                            src_key: d.src_key.clone(),
4676                            dst_key: d.dst_key.clone(),
4677                            edge_type: d.edge_type.clone(),
4678                            commit_seq: seq,
4679                        }
4680                    }
4681                })
4682                .collect();
4683
4684            // Prune dead entries; push matching events to live ones.
4685            self.subscriptions.retain(|entry| {
4686                let Some(inner) = entry.inner.upgrade() else {
4687                    return false;
4688                };
4689                for ev in &write_events {
4690                    if event_matches(ev, &entry.filter) {
4691                        inner.push(ev.clone());
4692                    }
4693                }
4694                for ev in &edge_events {
4695                    if event_matches(ev, &entry.filter) {
4696                        inner.push(ev.clone());
4697                    }
4698                }
4699                true
4700            });
4701
4702            // Turn off delta accumulation if all subscribers dropped and no views remain.
4703            if self.subscriptions.is_empty() && self.view_store.is_empty() {
4704                self.engine.set_emit_deltas(false);
4705            }
4706        }
4707
4708        // Query subscriptions: full re-run per commit, then diff rows.
4709        // IMPORTANT: full re-execution on every commit — use LIMIT to bound cost.
4710        // Differential evaluation is roadmap / Phase 5.
4711        if !self.query_subscriptions.is_empty() {
4712            // Take the list out so we can call self.view() without borrow conflict.
4713            let mut query_subs = std::mem::take(&mut self.query_subscriptions);
4714            let empty_params = BTreeMap::new();
4715            query_subs.retain_mut(|entry| {
4716                let Some(inner) = entry.inner.upgrade() else {
4717                    return false; // subscriber dropped — prune
4718                };
4719                // Label-skip: if the plan has a known scan label and this commit
4720                // can be proven to touch only different labels (and no rule-derived
4721                // edge deltas fired), the result set cannot have changed — skip.
4722                if let Some(scan_sym) = entry.scan_label {
4723                    if engine_deltas.is_empty() {
4724                        let touched =
4725                            Self::commit_touched_labels(rec, &self.syms, &self.ids, &self.labels);
4726                        if touched.map(|t| !t.contains(&scan_sym)).unwrap_or(false) {
4727                            return true; // safe to skip — result set unchanged
4728                        }
4729                    }
4730                }
4731                QUERY_SUB_EXECS_TL.with(|c| c.set(c.get() + 1));
4732                let result = match execute(&self.view(), &entry.ops, &Params(&empty_params)) {
4733                    Ok(r) => r,
4734                    Err(e) => {
4735                        // Keep the subscription alive; skip the diff for this commit.
4736                        // Re-run errors are transient (e.g., planner change) and
4737                        // self-heal when the next commit succeeds.
4738                        eprintln!("[mushroomdb] subscribe_query re-run failed: {e}");
4739                        return true;
4740                    }
4741                };
4742                // Build new row map: serialized-key → row data.
4743                let new_row_map = Self::result_to_row_map(&result);
4744                // Removed rows: in prev but not in new.
4745                for (key, row) in &entry.prev_row_map {
4746                    if !new_row_map.contains_key(key) {
4747                        inner.push(DbEvent::QueryRowRemoved {
4748                            columns: entry.columns.clone(),
4749                            row: row.clone(),
4750                        });
4751                    }
4752                }
4753                // Added rows: in new but not in prev.
4754                for (key, row) in &new_row_map {
4755                    if !entry.prev_row_map.contains_key(key) {
4756                        inner.push(DbEvent::QueryRowAdded {
4757                            columns: entry.columns.clone(),
4758                            row: row.clone(),
4759                        });
4760                    }
4761                }
4762                entry.prev_row_map = new_row_map;
4763                true
4764            });
4765            self.query_subscriptions = query_subs;
4766        }
4767    }
4768
4769    /// Returns `true` if any live subscriber or view definition requires delta
4770    /// accumulation. Used to set `engine.emit_deltas` on subscribe/view DDL.
4771    fn needs_emit_deltas(&self) -> bool {
4772        !self.view_store.is_empty()
4773            || self
4774                .subscriptions
4775                .iter()
4776                .any(|e| e.inner.upgrade().is_some())
4777    }
4778
4779    /// Convert a WAL record into `DbEvent` write events with the given seq.
4780    fn write_events_from_record(
4781        rec: &WalRecord,
4782        seq: u64,
4783        intern: &Interner,
4784        ids: &IdMap,
4785    ) -> Vec<DbEvent> {
4786        match rec {
4787            WalRecord::InsertNode { label, key, .. } => vec![DbEvent::NodeInserted {
4788                label: label.clone(),
4789                key: key.clone(),
4790                commit_seq: seq,
4791            }],
4792            // *Id arms run after a successful apply, so resolution can only
4793            // fail on a programming error. Skip the event rather than emit a
4794            // fabricated "" that clients can't tell from a real empty value
4795            // (mirrors event_from_record returning None).
4796            WalRecord::InsertNodeId { label, key, .. } => intern
4797                .resolve(*label)
4798                .map(|label| DbEvent::NodeInserted {
4799                    label: label.to_string(),
4800                    key: key.clone(),
4801                    commit_seq: seq,
4802                })
4803                .into_iter()
4804                .collect(),
4805            WalRecord::SetProp { key, field, .. } => vec![DbEvent::PropSet {
4806                key: key.clone(),
4807                field: field.clone(),
4808                commit_seq: seq,
4809            }],
4810            WalRecord::SetPropId { id, field, .. } => ids
4811                .key_of(*id)
4812                .zip(intern.resolve(*field))
4813                .map(|(key, field)| DbEvent::PropSet {
4814                    key: key.to_string(),
4815                    field: field.to_string(),
4816                    commit_seq: seq,
4817                })
4818                .into_iter()
4819                .collect(),
4820            WalRecord::RemoveProp { key, field } => vec![DbEvent::PropRemoved {
4821                key: key.clone(),
4822                field: field.clone(),
4823                commit_seq: seq,
4824            }],
4825            WalRecord::InsertEdge {
4826                edge_type,
4827                src_key,
4828                dst_key,
4829            } => vec![DbEvent::EdgeInserted {
4830                edge_type: edge_type.clone(),
4831                src: src_key.clone(),
4832                dst: dst_key.clone(),
4833                commit_seq: seq,
4834            }],
4835            WalRecord::InsertEdgeId { etype, src, dst } => (|| {
4836                Some(DbEvent::EdgeInserted {
4837                    edge_type: intern.resolve(*etype)?.to_string(),
4838                    src: ids.key_of(*src)?.to_string(),
4839                    dst: ids.key_of(*dst)?.to_string(),
4840                    commit_seq: seq,
4841                })
4842            })()
4843            .into_iter()
4844            .collect(),
4845            WalRecord::DeleteEdge {
4846                edge_type,
4847                src_key,
4848                dst_key,
4849            } => vec![DbEvent::EdgeDeleted {
4850                edge_type: edge_type.clone(),
4851                src: src_key.clone(),
4852                dst: dst_key.clone(),
4853                commit_seq: seq,
4854            }],
4855            WalRecord::DeleteNode { key } => vec![DbEvent::NodeDeleted {
4856                key: key.clone(),
4857                commit_seq: seq,
4858            }],
4859            WalRecord::Batch(inner) => inner
4860                .iter()
4861                .flat_map(|r| Self::write_events_from_record(r, seq, intern, ids))
4862                .collect(),
4863            WalRecord::CreateRule { .. }
4864            | WalRecord::DeleteRule { .. }
4865            | WalRecord::RebuildRule { .. }
4866            | WalRecord::CreateView { .. }
4867            | WalRecord::DeleteView { .. }
4868            | WalRecord::EnableFulltext { .. }
4869            | WalRecord::DisableFulltext { .. }
4870            | WalRecord::EnableIndex { .. }
4871            | WalRecord::DisableIndex { .. }
4872            | WalRecord::Intern { .. }
4873            // History markers produce no DbEvent — the engine delta already
4874            // fired the EdgeFired/EdgeRetracted subscription events.
4875            | WalRecord::DerivedEdgeAdded { .. }
4876            | WalRecord::DerivedEdgeRetracted { .. }
4877            | WalRecord::RenameNode { .. } => vec![],
4878        }
4879    }
4880
4881    /// Subscribe to edge-fire and edge-retract events for one named rule.
4882    ///
4883    /// Returns `Err(GraphError::RuleNotFound)` if `rule_name` is not
4884    /// currently registered. Dropping the returned [`Subscription`] handle
4885    /// unregisters the subscriber — no further events are queued, no
4886    /// resources leak.
4887    pub fn subscribe_rule(&mut self, rule_name: &str) -> core_storage::Result<Subscription> {
4888        if self.read_only {
4889            return Err(core_storage::GraphError::ReadOnly);
4890        }
4891        if !self.engine.rules().any(|r| r.name == rule_name) {
4892            return Err(core_storage::GraphError::RuleNotFound {
4893                name: rule_name.to_string(),
4894            });
4895        }
4896        let inner = SubInner::new(self.sub_capacity());
4897        self.subscriptions.push(SubEntry {
4898            filter: SubFilter::Rule(rule_name.to_string()),
4899            inner: std::sync::Arc::downgrade(&inner),
4900        });
4901        self.engine.set_emit_deltas(true);
4902        Ok(Subscription(inner))
4903    }
4904
4905    /// Subscribe to edge-fire and edge-retract events for **all** rules.
4906    ///
4907    /// Returns `Err(GraphError::ReadOnly)` if called on an as-of instance —
4908    /// as-of instances never commit, so `distribute_events` never runs and the
4909    /// subscription would never deliver events.
4910    pub fn subscribe_all_rules(&mut self) -> core_storage::Result<Subscription> {
4911        if self.read_only {
4912            return Err(core_storage::GraphError::ReadOnly);
4913        }
4914        let inner = SubInner::new(self.sub_capacity());
4915        self.subscriptions.push(SubEntry {
4916            filter: SubFilter::AllRules,
4917            inner: std::sync::Arc::downgrade(&inner),
4918        });
4919        self.engine.set_emit_deltas(true);
4920        Ok(Subscription(inner))
4921    }
4922
4923    /// Subscribe to write events: node insert/delete, prop set/remove.
4924    ///
4925    /// Does not include edge-fire / edge-retract (rule-derived edge events).
4926    ///
4927    /// Returns `Err(GraphError::ReadOnly)` if called on an as-of instance —
4928    /// as-of instances never commit, so `distribute_events` never runs and the
4929    /// subscription would never deliver events.
4930    pub fn subscribe_writes(&mut self) -> core_storage::Result<Subscription> {
4931        if self.read_only {
4932            return Err(core_storage::GraphError::ReadOnly);
4933        }
4934        let inner = SubInner::new(self.sub_capacity());
4935        self.subscriptions.push(SubEntry {
4936            filter: SubFilter::Writes,
4937            inner: std::sync::Arc::downgrade(&inner),
4938        });
4939        self.engine.set_emit_deltas(true);
4940        Ok(Subscription(inner))
4941    }
4942
4943    /// Subscribe to incremental Cypher query results.
4944    ///
4945    /// Parses and plans `cypher`; rejects the query if the plan is not in the
4946    /// allowlisted subset (see [`core_query::cypher::is_subscribable`]):
4947    ///   - `MATCH (n:Label) WHERE … RETURN … [LIMIT n]`
4948    ///   - `MATCH (a)-[r:TYPE]->(b) RETURN … [LIMIT n]`  (exactly one hop)
4949    ///
4950    /// SKIP is not supported — it shifts the result window on every commit,
4951    /// causing spurious Added/Removed churn for rows whose data never changed.
4952    /// Multi-hop Expand chains are not supported; each additional MATCH clause
4953    /// widens scope beyond the documented single-scan / single-hop subset.
4954    ///
4955    /// After each successful commit, the plan is **fully re-executed** and the
4956    /// result is diffed against the previous run. Added rows produce
4957    /// [`DbEvent::QueryRowAdded`]; removed rows produce
4958    /// [`DbEvent::QueryRowRemoved`].
4959    ///
4960    /// **Full re-run per commit; use LIMIT to bound execution cost.**
4961    /// The existing 1 M intermediate-row cap applies. Differential evaluation
4962    /// is roadmap / Phase 5.
4963    ///
4964    /// Returns `Err(GraphError::ReadOnly)` if called on an as-of instance —
4965    /// as-of instances never commit, so `distribute_events` never runs and the
4966    /// subscription would never deliver events.
4967    ///
4968    /// Returns `Err(GraphError::QueryError)` if the query fails to parse, plan,
4969    /// or if the plan shape is not in the allowlist.
4970    pub fn subscribe_query(&mut self, cypher: &str) -> Result<Subscription> {
4971        if self.read_only {
4972            return Err(GraphError::ReadOnly);
4973        }
4974        let tokens = lex(cypher).map_err(|e| GraphError::QueryError {
4975            detail: format!("lex: {e}"),
4976        })?;
4977        let ast = parse(&tokens).map_err(|e| GraphError::QueryError {
4978            detail: format!("parse: {e}"),
4979        })?;
4980        let ops = plan(&ast).map_err(|e| GraphError::QueryError {
4981            detail: format!("plan: {e}"),
4982        })?;
4983        if !is_subscribable(&ops) {
4984            return Err(GraphError::QueryError {
4985                detail: "subscribe_query only supports allowlisted plan shapes: \
4986                         MATCH (n:Label) WHERE … RETURN … [LIMIT n] or \
4987                         MATCH (a)-[r:TYPE]->(b) RETURN … [LIMIT n] (exactly one hop). \
4988                         Not supported: multi-hop Expand chains, SKIP (creates \
4989                         unstable offset windows), ORDER BY, DISTINCT, aggregates, \
4990                         variable-length paths, OPTIONAL MATCH, WITH, UNWIND. \
4991                         Use LIMIT to bound re-execution cost."
4992                    .to_string(),
4993            });
4994        }
4995        // Execute once to capture initial state (initial rows are not emitted as
4996        // events — the subscriber learns the baseline via the first query call).
4997        let empty_params = BTreeMap::new();
4998        let initial = execute(&self.view(), &ops, &Params(&empty_params)).map_err(|e| {
4999            GraphError::QueryError {
5000                detail: format!("execute: {e}"),
5001            }
5002        })?;
5003        let columns = initial.columns().to_vec();
5004        let prev_row_map = Self::result_to_row_map(&initial);
5005        let inner = SubInner::new(self.sub_capacity());
5006        // Derive the scan-label sym for the commit-skip fast-path.  Any Expand op
5007        // or unrecognized leading scan → None (always re-execute).
5008        let scan_label = extract_scan_label(&ops, &mut self.syms);
5009        self.query_subscriptions.push(QuerySubEntry {
5010            ops,
5011            columns,
5012            prev_row_map,
5013            inner: std::sync::Arc::downgrade(&inner),
5014            scan_label,
5015        });
5016        Ok(Subscription(inner))
5017    }
5018
5019    /// Queue capacity used for new subscriptions.
5020    fn sub_capacity(&self) -> usize {
5021        self.sub_capacity
5022    }
5023
5024    /// Override per-subscriber queue capacity for subsequently created
5025    /// subscriptions on this db instance.
5026    ///
5027    /// Default is [`DEFAULT_SUB_CAPACITY`] (65,536 events). Use a smaller
5028    /// value in tests to exercise the [`DbEvent::Lagged`] path without
5029    /// generating tens of thousands of events.
5030    ///
5031    /// This is a test-support escape hatch. Calling it in production reduces
5032    /// subscriber reliability (more Lagged events). It is hidden from rustdoc
5033    /// to discourage accidental production use.
5034    #[doc(hidden)]
5035    pub fn set_sub_capacity(&mut self, capacity: usize) {
5036        self.sub_capacity = capacity;
5037    }
5038
5039    // -----------------------------------------------------------------------
5040
5041    /// Start an atomic batch.
5042    ///
5043    /// The returned [`BatchBuilder`] borrows `self` mutably until
5044    /// [`BatchBuilder::commit`]. Builder methods queue ops only — no
5045    /// validation, no WAL I/O. `commit` validates every queued op against
5046    /// live state plus preceding ops in this batch (duplicate key inside
5047    /// the batch is `Err`; an edge between two nodes created earlier in
5048    /// the batch is valid; `delete_node` then insert of the same key is a
5049    /// fresh identity). Validation never mutates the database. Any failure
5050    /// leaves WAL bytes and in-memory state identical to before `commit`.
5051    /// On success, one `WalRecord::Batch` frame is appended (one fsync)
5052    /// and each inner record is applied in order so rules fire per record.
5053    /// An empty batch, or a batch of only no-ops, writes zero WAL bytes.
5054    ///
5055    /// **Rule-window limitation:** batch validation cannot see edges that a
5056    /// rule created earlier in the *same* batch will derive at apply time, so
5057    /// a `delete_edge` / `insert_edge` in that window is silently no-oped
5058    /// where sequential calls would return `Err(RuleOwned)`. State integrity
5059    /// is unaffected (idempotent apply, provenance intact). Create rules in
5060    /// their own batch, or sequentially, when later ops may touch derived
5061    /// edges.
5062    pub fn batch(&mut self) -> BatchBuilder<'_, F> {
5063        BatchBuilder {
5064            db: self,
5065            ops: Vec::new(),
5066        }
5067    }
5068
5069    /// Closure-style atomic write batch.
5070    ///
5071    /// Equivalent to calling [`GraphDb::batch`], invoking `build` to queue ops,
5072    /// then committing. All ops queued inside `build` are validated in order and
5073    /// committed as a single `WalRecord::Batch` frame (one fsync). Rules fire
5074    /// once per inner record, in order, after commit — semantically identical to
5075    /// sequential single-op writes.
5076    ///
5077    /// **Error semantics — validate-then-apply.** `build` queues ops without
5078    /// touching the database. [`BatchBuilder::commit`] validates every op against
5079    /// live state plus earlier ops in this batch before writing anything. If op N
5080    /// fails validation (duplicate key, unknown key, rule-owned edge, …) the
5081    /// entire batch is rejected: no WAL bytes are written and no in-memory state
5082    /// changes. The database is identical to its state before `write_batch` was
5083    /// called.
5084    ///
5085    /// **Atomicity is crash-level, NOT isolation-level.** On replay after a crash,
5086    /// a partial (torn) `Batch` frame applies NONE of its ops — the frame is
5087    /// either fully applied or not at all. However, while applying a committed
5088    /// batch, concurrent readers may observe intermediate states as ops are applied
5089    /// sequentially in memory. There is no interactive transaction isolation in v1.
5090    /// This is documented as "crash-atomic write batches; no interactive
5091    /// transactions or read isolation."
5092    ///
5093    /// **Returns** `(nodes_inserted, edges_inserted)`. An empty or all-noop batch
5094    /// writes zero WAL bytes and returns `(0, 0)`.
5095    ///
5096    /// # Example
5097    ///
5098    /// ```rust,ignore
5099    /// let (nodes, edges) = db.write_batch(|b| {
5100    ///     b.insert_node("Person", "alice", vec![("age".into(), Value::Int(30))]);
5101    ///     b.insert_node("Person", "bob", vec![]);
5102    ///     b.insert_edge("KNOWS", "alice", "bob");
5103    ///     b.set_prop("alice", "role", Value::Str("admin".into()));
5104    ///     b.delete_node("old_key");
5105    /// })?;
5106    /// // One fsync; on crash replay: all five ops land or none do.
5107    /// ```
5108    pub fn write_batch<C>(&mut self, build: C) -> Result<(usize, usize)>
5109    where
5110        C: FnOnce(&mut BatchBuilder<'_, F>),
5111    {
5112        let mut b = self.batch();
5113        build(&mut b);
5114        b.commit()
5115    }
5116
5117    /// Insert `rows` as nodes of `label`. One call is one atomic batch:
5118    /// auto-declared KeyMatch rules (if any) first, then the accepted node
5119    /// inserts, so incremental fire sees the new rules. Per-row key problems
5120    /// are collected in [`IngestReport::row_errors`] and skipped; a commit
5121    /// `Err` means nothing was applied.
5122    ///
5123    /// Auto-FK rule names are `auto_fk_<src_label_lowercase>_<field>` so
5124    /// distinct source labels sharing an FK field each get their own rule.
5125    pub fn ingest(
5126        &mut self,
5127        label: &str,
5128        rows: Vec<BTreeMap<String, Value>>,
5129        opts: &IngestOptions,
5130    ) -> Result<IngestReport> {
5131        self.ingest_with_edges(label, rows, opts, &[])
5132    }
5133
5134    /// [`ingest`] plus user edges in the **same** previewed WAL batch.
5135    /// A failing edge rejects the whole request; nothing is applied.
5136    pub fn ingest_with_edges(
5137        &mut self,
5138        label: &str,
5139        rows: Vec<BTreeMap<String, Value>>,
5140        opts: &IngestOptions,
5141        edges: &[(String, String, String)],
5142    ) -> Result<IngestReport> {
5143        crate::ingest::run(self, label, rows, opts, edges)
5144    }
5145
5146    /// Parse `json` as an array of objects and ingest via [`GraphDb::ingest`].
5147    ///
5148    /// JSON `null` fields are silently omitted (not stored, not a row error).
5149    /// Nested objects and arrays-of-objects are a per-row error (row skipped).
5150    /// Parse failures and a top-level value that is not an array of objects
5151    /// return [`GraphError::IngestError`].
5152    pub fn ingest_json(
5153        &mut self,
5154        label: &str,
5155        json: &str,
5156        opts: &IngestOptions,
5157    ) -> Result<IngestReport> {
5158        crate::ingest::run_json(self, label, json, opts)
5159    }
5160
5161    fn commit_logged_batch(
5162        &mut self,
5163        ops: Vec<BatchOp>,
5164        ingest: Option<(String, usize)>,
5165        // Two-source rule: write_batch_authz threads authz here directly (never
5166        // touches pending_write_authz); query_write_authz sets the field instead
5167        // and passes None.  Only one source is non-None per call.
5168        param_authz: Option<WriteAuthz>,
5169    ) -> Result<(usize, usize)> {
5170        // Read-only guard: catches empty-batch calls before the early-return
5171        // that skips log_then_apply_with, ensuring all mutation entry points fail.
5172        if self.read_only {
5173            return Err(GraphError::ReadOnly);
5174        }
5175        // Ensure provenance is decoded before MutPreview accesses it
5176        // (note_delete_rule / is_rule_owned may call engine.provenance()).
5177        self.engine.ensure_provenance_loaded_mut();
5178
5179        // ── Authz pre-check ──────────────────────────────────────────────────
5180        // Evaluate the decision table per-op BEFORE MutPreview so that a denial
5181        // produces no WAL frame (all-or-nothing at the authz boundary extends
5182        // the existing validate-then-apply contract to role-scope checks).
5183        //
5184        // `batch_created` tracks key→label for nodes created by earlier ops in
5185        // THIS batch, so InsertEdgeUpsert can count same-batch placeholder nodes
5186        // as visible without needing to call `self.ids.get` on not-yet-committed
5187        // keys (they won't be there yet).
5188        //
5189        // Two-source rule: param_authz (write_batch_authz path) takes precedence;
5190        // fall back to self.pending_write_authz (query_write_authz/Cypher path).
5191        // Cloning the field copy avoids a simultaneous borrow of self.ids below.
5192        let authz_opt = param_authz.or_else(|| self.pending_write_authz.clone());
5193        if let Some(ref authz) = authz_opt {
5194            let mut batch_created: BTreeMap<String, String> = BTreeMap::new();
5195            for op in &ops {
5196                self.check_single_op_authz(authz, op, &batch_created)?;
5197                // Update batch_created after a passing authz check so that
5198                // subsequent ops in this batch see the nodes as "about to exist".
5199                match op {
5200                    BatchOp::InsertNode { label, key, .. } => {
5201                        // Only track genuinely new nodes (absent from the
5202                        // snapshot at authz-check time). A pre-existing visible
5203                        // key would be a DuplicateKey — not a real creation —
5204                        // so MutPreview handles it. Letting it into batch_created
5205                        // would allow a later SetProp to bypass update_labels
5206                        // via the "batch-created → always updatable" ruling
5207                        // (delete+recreate exploit, fix for I1 review round 2).
5208                        //
5209                        // Accepted edge: for a delete+recreate-with-different-
5210                        // label batch, node_status resolves the pre-delete
5211                        // (store) label for any subsequent update checks. This
5212                        // grants no net-new capability — a role that can delete+
5213                        // create can already place arbitrary props via
5214                        // InsertNode's own props field.
5215                        if self.ids.get(key.as_str()).is_none() {
5216                            batch_created.insert(key.clone(), label.clone());
5217                        }
5218                    }
5219                    BatchOp::InsertEdgeUpsert {
5220                        placeholder_label,
5221                        src_key,
5222                        dst_key,
5223                        ..
5224                    } => {
5225                        // Both endpoints will be created if not already in store.
5226                        for ep_key in [src_key, dst_key] {
5227                            if self.ids.get(ep_key.as_str()).is_none()
5228                                && !batch_created.contains_key(ep_key.as_str())
5229                            {
5230                                batch_created.insert(ep_key.clone(), placeholder_label.clone());
5231                            }
5232                        }
5233                    }
5234                    _ => {}
5235                }
5236            }
5237        }
5238
5239        let recs = {
5240            let mut preview = MutPreview::new(self);
5241            let mut recs = Vec::with_capacity(ops.len());
5242            for op in ops {
5243                match op {
5244                    BatchOp::InsertNode { label, key, props } => {
5245                        preview.check_insert_node(&key)?;
5246                        preview.note_insert_node(&key, &props);
5247                        recs.push(WalRecord::InsertNode { label, key, props });
5248                    }
5249                    BatchOp::InsertEdge {
5250                        edge_type,
5251                        src_key,
5252                        dst_key,
5253                    } => {
5254                        if preview.prepare_insert_edge(&edge_type, &src_key, &dst_key)? {
5255                            preview.note_insert_edge(&edge_type, &src_key, &dst_key);
5256                            recs.push(WalRecord::InsertEdge {
5257                                edge_type,
5258                                src_key,
5259                                dst_key,
5260                            });
5261                        }
5262                    }
5263                    BatchOp::SetProp { key, field, value } => {
5264                        preview.check_live_key(&key)?;
5265                        preview.note_set_prop(&key, &field, &value);
5266                        recs.push(WalRecord::SetProp { key, field, value });
5267                    }
5268                    BatchOp::RemoveProp { key, field } => {
5269                        if preview.prepare_remove_prop(&key, &field)? {
5270                            preview.note_remove_prop(&key, &field);
5271                            recs.push(WalRecord::RemoveProp { key, field });
5272                        }
5273                    }
5274                    BatchOp::DeleteEdge {
5275                        edge_type,
5276                        src_key,
5277                        dst_key,
5278                    } => {
5279                        if preview.prepare_delete_edge(&edge_type, &src_key, &dst_key)? {
5280                            preview.note_delete_edge(&edge_type, &src_key, &dst_key);
5281                            recs.push(WalRecord::DeleteEdge {
5282                                edge_type,
5283                                src_key,
5284                                dst_key,
5285                            });
5286                        }
5287                    }
5288                    BatchOp::DeleteNode { key } => {
5289                        preview.check_live_key(&key)?;
5290                        preview.note_delete_node(&key);
5291                        recs.push(WalRecord::DeleteNode { key });
5292                    }
5293                    BatchOp::CreateRule(def) => {
5294                        preview.check_create_rule(&def)?;
5295                        let def_bytes =
5296                            bincode::serialize(&def).map_err(|e| GraphError::Corrupt {
5297                                detail: format!("serialize rule: {e}"),
5298                            })?;
5299                        preview.note_create_rule(&def);
5300                        recs.push(WalRecord::CreateRule { def_bytes });
5301                    }
5302                    BatchOp::DeleteRule { name } => {
5303                        preview.check_delete_rule(&name)?;
5304                        preview.note_delete_rule(&name);
5305                        recs.push(WalRecord::DeleteRule { name });
5306                    }
5307                    BatchOp::RenameNode { old_key, new_key } => {
5308                        preview.check_rename_node(&old_key, &new_key)?;
5309                        preview.note_rename_node(&old_key, &new_key);
5310                        recs.push(WalRecord::RenameNode { old_key, new_key });
5311                    }
5312                    BatchOp::InsertEdgeUpsert {
5313                        edge_type,
5314                        src_key,
5315                        dst_key,
5316                        placeholder_label,
5317                    } => {
5318                        // Auto-create any missing endpoints as plain InsertNode ops.
5319                        // Rules fire and last-change is updated for each created node.
5320                        for key in [&src_key, &dst_key] {
5321                            if !preview.has_key(key) {
5322                                preview.check_insert_node(key)?;
5323                                preview.note_insert_node(key, &[]);
5324                                recs.push(WalRecord::InsertNode {
5325                                    label: placeholder_label.clone(),
5326                                    key: key.clone(),
5327                                    props: vec![],
5328                                });
5329                            }
5330                        }
5331                        if preview.prepare_insert_edge(&edge_type, &src_key, &dst_key)? {
5332                            preview.note_insert_edge(&edge_type, &src_key, &dst_key);
5333                            recs.push(WalRecord::InsertEdge {
5334                                edge_type,
5335                                src_key,
5336                                dst_key,
5337                            });
5338                        }
5339                    }
5340                }
5341            }
5342            recs
5343        };
5344        if recs.is_empty() {
5345            return Ok((0, 0));
5346        }
5347        // rewrite_wal_dense converts every InsertNode/InsertEdge into its
5348        // *Id form, so only the dense variants can appear in `recs` here.
5349        let recs = self.rewrite_wal_dense(recs)?;
5350        let nodes_inserted = recs
5351            .iter()
5352            .filter(|r| matches!(r, WalRecord::InsertNodeId { .. }))
5353            .count();
5354        let edges_inserted = recs
5355            .iter()
5356            .filter(|r| matches!(r, WalRecord::InsertEdgeId { .. }))
5357            .count();
5358        // Ingest / write_batch / query_write: one Batch frame, one fsync per call
5359        // under Strict.  Pass self.fsync directly so Strict stays Strict —
5360        // wal_needs_sync(Strict, _) always returns true regardless of op count.
5361        // Mapping Strict → Batched (the prior bug) caused wal_needs_sync to
5362        // short-circuit on single-op batches and silently skip the fsync.
5363        // Batched fsyncs only for multi-op batches; Relaxed always skips.
5364        self.log_then_apply_with(WalRecord::Batch(recs), ingest, self.fsync)?;
5365        Ok((nodes_inserted, edges_inserted))
5366    }
5367
5368    fn commit_batch(&mut self, ops: Vec<BatchOp>) -> Result<(usize, usize)> {
5369        self.commit_logged_batch(ops, None, None)
5370    }
5371
5372    /// Commit one submission WITHOUT an fsync — for use inside `commit_group`
5373    /// and the group-commit drain thread, which do a single group fsync later.
5374    fn commit_batch_nosync(&mut self, ops: Vec<BatchOp>) -> Result<(usize, usize)> {
5375        // Restore fsync policy even on panic via a raw-pointer drop guard.
5376        // A panic here would poison the RwLock anyway, but the correct policy
5377        // must be in place if the guard is ever unwrapped.
5378        struct RestoreFsync(*mut FsyncPolicy, FsyncPolicy);
5379        impl Drop for RestoreFsync {
5380            fn drop(&mut self) {
5381                // SAFETY: the pointer is valid for the full duration of
5382                // commit_batch_nosync; the guard is dropped before the frame
5383                // returns, and GraphDb outlives this frame.
5384                unsafe {
5385                    *self.0 = self.1;
5386                }
5387            }
5388        }
5389        let saved = self.fsync;
5390        // SAFETY: raw pointer into self; guard dropped within this frame.
5391        let _g = RestoreFsync(&mut self.fsync as *mut FsyncPolicy, saved);
5392        self.fsync = FsyncPolicy::Relaxed;
5393        self.commit_logged_batch(ops, None, None)
5394    }
5395
5396    /// Commit multiple op-batches as a **group**: each submission gets its own
5397    /// WAL `Batch` frame, but there is exactly **one** `Fs::sync` for the whole
5398    /// group (under `Strict` / `Batched` policy; `Relaxed` skips all syncs).
5399    ///
5400    /// # Durability semantics
5401    ///
5402    /// A crash before the group fsync may lose **all** submissions in the group.
5403    /// A crash after the group fsync preserves all of them.  No submission is
5404    /// ever torn: each WAL frame is either fully applied on replay or dropped
5405    /// in its entirety (CRC-protected frame boundaries).
5406    ///
5407    /// Events and subscription notifications fire per-submission immediately
5408    /// after apply, which may be before the group fsync.  From a subscriber's
5409    /// perspective this is equivalent to the `Relaxed` durability window.
5410    /// Submitters using [`SharedDb::submit_batch`] only unblock after the group
5411    /// fsync, so from their perspective durability is fully guaranteed.
5412    ///
5413    /// # MVCC interplay
5414    ///
5415    /// Each submission records its own `CommitDelta`; the fold-every-K counter
5416    /// increments per submission (not per group), preserving existing reader
5417    /// snapshot semantics.
5418    ///
5419    /// # Returns
5420    ///
5421    /// One `Result<(nodes_inserted, edges_inserted)>` per input group element,
5422    /// in order.  Failures are per-submission (validation errors); the group
5423    /// fsync error (if any) is returned as the second tuple element.
5424    pub fn commit_group(
5425        &mut self,
5426        groups: Vec<Vec<BatchOp>>,
5427    ) -> (Vec<Result<(usize, usize)>>, Option<GraphError>) {
5428        let mut results = Vec::with_capacity(groups.len());
5429        for ops in groups {
5430            results.push(self.commit_batch_nosync(ops));
5431        }
5432        let any_ok = results.iter().any(|r| r.is_ok());
5433        let sync_err = if self.fsync != FsyncPolicy::Relaxed && any_ok {
5434            self.fs
5435                .sync(core_storage::fs::FileId::Wal)
5436                .map_err(GraphError::Io)
5437                .err()
5438        } else {
5439            None
5440        };
5441        (results, sync_err)
5442    }
5443
5444    /// Like [`commit_group`] but skips the group fsync entirely.
5445    ///
5446    /// Used by the drain thread to apply submissions under the write lock and
5447    /// then perform the single fsync OUTSIDE the lock (via
5448    /// `core_storage::sync_wal_at`), reducing the write-lock hold time visible
5449    /// to concurrent readers.
5450    pub fn commit_group_nosync(
5451        &mut self,
5452        groups: Vec<Vec<BatchOp>>,
5453    ) -> Vec<Result<(usize, usize)>> {
5454        let mut results = Vec::with_capacity(groups.len());
5455        for ops in groups {
5456            results.push(self.commit_batch_nosync(ops));
5457        }
5458        results
5459    }
5460
5461    pub fn insert_node(
5462        &mut self,
5463        label: &str,
5464        key: &str,
5465        props: Vec<(String, Value)>,
5466    ) -> Result<()> {
5467        if self.read_only {
5468            return Err(GraphError::ReadOnly);
5469        }
5470        MutPreview::new(self).check_insert_node(key)?;
5471        self.log_dense(vec![WalRecord::InsertNode {
5472            label: label.into(),
5473            key: key.into(),
5474            props,
5475        }])
5476    }
5477
5478    pub fn insert_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) -> Result<bool> {
5479        if self.read_only {
5480            return Err(GraphError::ReadOnly);
5481        }
5482        if !MutPreview::new(self).prepare_insert_edge(edge_type, src_key, dst_key)? {
5483            return Ok(false);
5484        }
5485        self.log_dense(vec![WalRecord::InsertEdge {
5486            edge_type: edge_type.into(),
5487            src_key: src_key.into(),
5488            dst_key: dst_key.into(),
5489        }])?;
5490        Ok(true)
5491    }
5492
5493    pub fn set_prop(&mut self, key: &str, field: &str, value: Value) -> Result<()> {
5494        if self.read_only {
5495            return Err(GraphError::ReadOnly);
5496        }
5497        if let Some(view_name) = self.view_store.view_for_prop(field) {
5498            return Err(GraphError::ViewPropReadOnly {
5499                view_name: view_name.to_string(),
5500            });
5501        }
5502        MutPreview::new(self).check_live_key(key)?;
5503        self.log_dense(vec![WalRecord::SetProp {
5504            key: key.into(),
5505            field: field.into(),
5506            value,
5507        }])
5508    }
5509
5510    /// Remove a property. Returns `Ok(false)` (and does not log) if the field
5511    /// is already absent. Unknown or tombstoned keys are `Err(KeyNotFound)`.
5512    pub fn remove_prop(&mut self, key: &str, field: &str) -> Result<bool> {
5513        if self.read_only {
5514            return Err(GraphError::ReadOnly);
5515        }
5516        if let Some(view_name) = self.view_store.view_for_prop(field) {
5517            return Err(GraphError::ViewPropReadOnly {
5518                view_name: view_name.to_string(),
5519            });
5520        }
5521        if !MutPreview::new(self).prepare_remove_prop(key, field)? {
5522            return Ok(false);
5523        }
5524        self.log_then_apply(WalRecord::RemoveProp {
5525            key: key.into(),
5526            field: field.into(),
5527        })?;
5528        Ok(true)
5529    }
5530
5531    /// Delete a user edge. Returns `Ok(false)` (and does not log) if the edge
5532    /// is absent. Unknown keys are `Err(KeyNotFound)`. Rule-owned edges — in
5533    /// provenance, or a pair a live rule would derive — are `Err(RuleOwned)`
5534    /// (the rule would just put the edge back; delete or change the rule).
5535    pub fn delete_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) -> Result<bool> {
5536        if self.read_only {
5537            return Err(GraphError::ReadOnly);
5538        }
5539        if !MutPreview::new(self).prepare_delete_edge(edge_type, src_key, dst_key)? {
5540            return Ok(false);
5541        }
5542        self.log_then_apply(WalRecord::DeleteEdge {
5543            edge_type: edge_type.into(),
5544            src_key: src_key.into(),
5545            dst_key: dst_key.into(),
5546        })?;
5547        Ok(true)
5548    }
5549
5550    /// Delete a live node. Unknown or already-tombstoned keys are
5551    /// `Err(KeyNotFound)` and are not logged. Validation runs before the WAL
5552    /// write; `apply` of a logged `DeleteNode` for an already-tombstoned key
5553    /// (crash window) is a clean no-op.
5554    ///
5555    /// Returns a [`DeleteReport`] with counts of manual and derived edges
5556    /// removed (computed from live state before the deletion is applied).
5557    pub fn delete_node(&mut self, key: &str) -> Result<DeleteReport> {
5558        if self.read_only {
5559            return Err(GraphError::ReadOnly);
5560        }
5561        // Provenance must be loaded before we query provenance_touching.
5562        self.engine.ensure_provenance_loaded_mut();
5563        let id = self
5564            .ids
5565            .get(key)
5566            .ok_or_else(|| GraphError::KeyNotFound { key: key.into() })?;
5567
5568        // Count edges before the delete is applied so we can report counts.
5569        let derived_set: BTreeSet<(u32, u32, u32)> = self
5570            .engine
5571            .provenance_touching(id)
5572            .map(|(_, etype, src, dst)| (etype, src, dst))
5573            .collect();
5574        let derived_edges = derived_set.len() as u64;
5575
5576        let mut total_topo = 0u64;
5577        let tv = self.topo_view();
5578        for et in tv.etypes() {
5579            total_topo += tv.neighbors(et, Direction::Out, id).len() as u64
5580                + tv.neighbors(et, Direction::In, id).len() as u64;
5581        }
5582        // For symmetric rules (e.g. Overlap), a→b and b→a are two separate directed
5583        // triples in both the topo scan (Out and In from id) and in provenance_touching.
5584        // The subtraction remains correct because both counts include both directions.
5585        let manual_edges = total_topo.saturating_sub(derived_edges);
5586
5587        self.log_then_apply(WalRecord::DeleteNode { key: key.into() })?;
5588        Ok(DeleteReport {
5589            manual_edges,
5590            derived_edges,
5591        })
5592    }
5593
5594    /// Rename a live node's key.  The dense id (and therefore all edges,
5595    /// props, history, and last-change tracking) is unaffected.
5596    ///
5597    /// Returns `Err(KeyNotFound)` if `old` is not a live key.
5598    /// Returns `Err(DuplicateKey)` if `new` is already live.
5599    pub fn rename_node(&mut self, old: &str, new: &str) -> Result<()> {
5600        if self.read_only {
5601            return Err(GraphError::ReadOnly);
5602        }
5603        MutPreview::new(self).check_rename_node(old, new)?;
5604        self.log_then_apply(WalRecord::RenameNode {
5605            old_key: old.into(),
5606            new_key: new.into(),
5607        })
5608    }
5609
5610    /// Return the IVF drift counter for the dst-side candidate index of `rule`.
5611    /// `None` if the rule does not exist or is not approximate.
5612    ///
5613    /// The drift counter increments on IVF insert/remove after the last fit.
5614    /// When dst-side drift exceeds [`core_rules::IVF_DRIFT_REBUILD`], apply
5615    /// WAL-logs `RebuildRule` as a second commit (rebuild resets the counter).
5616    pub fn ivf_dst_drift(&self, rule: &str) -> Option<u64> {
5617        // SideIvfExport = (centroids, node→cluster, drift)
5618        self.engine
5619            .export_ivf_state()
5620            .remove(rule)
5621            .map(|(_src, dst)| dst.2)
5622    }
5623
5624    /// Validate and WAL-log a new rule, then backfill derived edges inside apply.
5625    /// Validation and duplicate-name check run before logging so invalid rules
5626    /// never enter the WAL.
5627    pub fn create_rule(&mut self, def: RuleDef) -> Result<()> {
5628        if self.read_only {
5629            return Err(GraphError::ReadOnly);
5630        }
5631        MutPreview::new(self).check_create_rule(&def)?;
5632        let def_bytes = bincode::serialize(&def).map_err(|e| GraphError::Corrupt {
5633            detail: format!("serialize rule: {e}"),
5634        })?;
5635        self.log_then_apply(WalRecord::CreateRule { def_bytes })
5636    }
5637
5638    /// WAL-log rule deletion. Returns RuleNotFound if the rule does not exist.
5639    pub fn delete_rule(&mut self, name: &str) -> Result<()> {
5640        if self.read_only {
5641            return Err(GraphError::ReadOnly);
5642        }
5643        MutPreview::new(self).check_delete_rule(name)?;
5644        self.log_then_apply(WalRecord::DeleteRule { name: name.into() })
5645    }
5646
5647    /// Return a snapshot of all registered rules.
5648    pub fn rules(&self) -> Vec<RuleDef> {
5649        self.engine.rules().cloned().collect()
5650    }
5651
5652    // -----------------------------------------------------------------------
5653    // Rule suggestion API
5654    // -----------------------------------------------------------------------
5655
5656    /// Profile the database and suggest linking rules with previewed edge counts.
5657    ///
5658    /// Uses the default seed ([`core_rules::SUGGEST_DEFAULT_SEED`]) for deterministic
5659    /// sampling. Suggestions are sorted by estimated edge count (descending).
5660    /// **NO auto-accept** — call [`GraphDb::create_rule`] explicitly to apply.
5661    pub fn suggest_rules(&self) -> Vec<core_rules::RuleSuggestion> {
5662        self.suggest_rules_seeded(core_rules::SUGGEST_DEFAULT_SEED)
5663    }
5664
5665    /// Like [`suggest_rules`] but with a caller-supplied RNG seed for
5666    /// reproducibility. Same seed + same data = identical output.
5667    pub fn suggest_rules_seeded(&self, seed: u64) -> Vec<core_rules::RuleSuggestion> {
5668        self.suggest_rules_with_config(&core_rules::suggest::SuggestConfig::default(), seed)
5669            .suggestions
5670    }
5671
5672    /// [`suggest_rules_seeded`] with a fully custom [`SuggestConfig`].
5673    ///
5674    /// Returns a [`core_rules::SuggestReport`] that includes both the candidate list
5675    /// and a `truncated` flag indicating whether the global budget fired before all
5676    /// candidates were evaluated.
5677    pub fn suggest_rules_with_config(
5678        &self,
5679        config: &core_rules::suggest::SuggestConfig,
5680        seed: u64,
5681    ) -> core_rules::SuggestReport {
5682        use std::collections::BTreeMap;
5683
5684        // Collect (node_id, key) pairs per label, skipping tombstoned nodes.
5685        let mut label_nodes: BTreeMap<String, Vec<(u32, String)>> = BTreeMap::new();
5686        for id in 0..self.ids.len() as u32 {
5687            let Some(key) = self.ids.key_of(id) else {
5688                continue;
5689            };
5690            let Some(&sym) = self.labels.get(id as usize) else {
5691                continue;
5692            };
5693            if sym == u32::MAX {
5694                continue; // tombstoned
5695            }
5696            let Some(label) = self.syms.resolve(sym) else {
5697                continue;
5698            };
5699            label_nodes
5700                .entry(label.to_string())
5701                .or_default()
5702                .push((id, key.to_string()));
5703        }
5704
5705        let existing = self.rules();
5706        let pv = build_props_view(&self.props, &self.base);
5707        let all_fields: Vec<String> = pv.field_names();
5708
5709        core_rules::suggest::suggest_rules(
5710            &label_nodes,
5711            &|id, field| pv.get(id, field).map(|vr| vr.into_value()),
5712            &all_fields,
5713            &existing,
5714            config,
5715            seed,
5716        )
5717    }
5718
5719    /// Recompute a rule's derived edges from scratch. WAL-logged so un-trip
5720    /// plus later mutations replay identically (rebuild is a pure function
5721    /// of state).
5722    ///
5723    /// Only exit from the tripped latch: if the full desired set fits the
5724    /// budget, it is applied completely and `tripped` clears; if it still
5725    /// exceeds the budget, provenance is left untouched and `tripped` stays
5726    /// true. Counts as a fire evaluation (see [`RuleStats::fires`]).
5727    /// Unknown rule → `RuleNotFound`, nothing logged.
5728    pub fn rebuild_rule(&mut self, name: &str) -> Result<()> {
5729        if self.read_only {
5730            return Err(GraphError::ReadOnly);
5731        }
5732        if !self.engine.rules().any(|r| r.name == name) {
5733            return Err(GraphError::RuleNotFound { name: name.into() });
5734        }
5735        self.log_then_apply(WalRecord::RebuildRule { name: name.into() })
5736    }
5737
5738    // -----------------------------------------------------------------------
5739    // Materialized view API
5740    // -----------------------------------------------------------------------
5741
5742    /// Register a new materialized property view, backfill its values for all
5743    /// existing nodes, and WAL-log the definition.
5744    ///
5745    /// # Errors
5746    /// - `ReadOnly`: called on an as-of instance.
5747    /// - `RuleInvalid`: name collision, view_prop collision, or invalid def.
5748    pub fn create_view(&mut self, def: ViewDef) -> Result<()> {
5749        if self.read_only {
5750            return Err(GraphError::ReadOnly);
5751        }
5752        // Pre-validate before WAL write.
5753        def.validate()
5754            .map_err(|e| GraphError::RuleInvalid { detail: e })?;
5755        if self.view_store.has_view(&def.name) {
5756            return Err(GraphError::RuleInvalid {
5757                detail: format!("view {:?} already exists", def.name),
5758            });
5759        }
5760        if let Some(existing) = self.view_store.view_for_prop(&def.view_prop) {
5761            return Err(GraphError::RuleInvalid {
5762                detail: format!(
5763                    "view_prop {:?} is already used by view {:?}",
5764                    def.view_prop, existing
5765                ),
5766            });
5767        }
5768        let def_bytes = bincode::serialize(&def).map_err(|e| GraphError::Corrupt {
5769            detail: format!("serialize view: {e}"),
5770        })?;
5771        // Enable delta accumulation before the view is registered so subsequent
5772        // incremental edge events reach view maintenance from this point onward.
5773        // (The backfill inside create_view reads topo directly; it does not rely
5774        // on pending deltas.)
5775        self.engine.set_emit_deltas(true);
5776        self.log_then_apply(WalRecord::CreateView { def_bytes })
5777    }
5778
5779    /// Remove a named view and delete its values from every node.
5780    ///
5781    /// # Errors
5782    /// - `ReadOnly`: called on an as-of instance.
5783    /// - `RuleNotFound`: view does not exist.
5784    pub fn delete_view(&mut self, name: &str) -> Result<()> {
5785        if self.read_only {
5786            return Err(GraphError::ReadOnly);
5787        }
5788        if !self.view_store.has_view(name) {
5789            return Err(GraphError::RuleNotFound { name: name.into() });
5790        }
5791        let result = self.log_then_apply(WalRecord::DeleteView { name: name.into() });
5792        // After deletion, disable accumulation if no listeners remain.
5793        if !self.needs_emit_deltas() {
5794            self.engine.set_emit_deltas(false);
5795        }
5796        result
5797    }
5798
5799    /// Snapshot of all registered view definitions.
5800    pub fn views(&self) -> Vec<ViewDef> {
5801        self.view_store.views().cloned().collect()
5802    }
5803
5804    // -----------------------------------------------------------------------
5805    // Full-text-lite API
5806    // -----------------------------------------------------------------------
5807
5808    /// Enable full-text indexing for all nodes of `label` on property `field`.
5809    ///
5810    /// After this call, every subsequent write to `(label, field)` is reflected
5811    /// in the index incrementally.  Existing nodes are backfilled immediately.
5812    /// The declaration is persisted as a WAL record; the index itself is rebuilt
5813    /// from scratch on re-open (no snapshot format changes).
5814    ///
5815    /// # Errors
5816    /// - [`GraphError::ReadOnly`]: called on an as-of instance.
5817    /// - [`GraphError::RuleInvalid`]: `(label, field)` is already indexed.
5818    pub fn enable_fulltext(&mut self, label: &str, field: &str) -> Result<()> {
5819        if self.read_only {
5820            return Err(GraphError::ReadOnly);
5821        }
5822        if self.fulltext.is_enabled(label, field) {
5823            return Err(GraphError::RuleInvalid {
5824                detail: format!("full-text index for ({label:?}, {field:?}) already enabled"),
5825            });
5826        }
5827        self.log_then_apply(WalRecord::EnableFulltext {
5828            label: label.into(),
5829            field: field.into(),
5830        })
5831    }
5832
5833    /// Disable full-text indexing for `(label, field)` and drop its postings.
5834    ///
5835    /// # Errors
5836    /// - [`GraphError::ReadOnly`]: called on an as-of instance.
5837    /// - [`GraphError::RuleNotFound`]: `(label, field)` is not currently indexed.
5838    pub fn disable_fulltext(&mut self, label: &str, field: &str) -> Result<()> {
5839        if self.read_only {
5840            return Err(GraphError::ReadOnly);
5841        }
5842        if !self.fulltext.is_enabled(label, field) {
5843            return Err(GraphError::RuleNotFound {
5844                name: format!("fulltext({label},{field})"),
5845            });
5846        }
5847        self.log_then_apply(WalRecord::DisableFulltext {
5848            label: label.into(),
5849            field: field.into(),
5850        })
5851    }
5852
5853    /// Whether `(label, field)` is currently indexed for full-text search.
5854    pub fn is_fulltext_enabled(&self, label: &str, field: &str) -> bool {
5855        self.fulltext.is_enabled(label, field)
5856    }
5857
5858    /// Every `(label, field)` pair with a live full-text index, sorted.
5859    ///
5860    /// Note that [`GraphDb::search`] is keyed by field alone — a pair only
5861    /// declares which nodes are *indexed*, so callers that want to search
5862    /// everything indexed should query each distinct field once.
5863    pub fn fulltext_pairs(&self) -> Vec<(String, String)> {
5864        let mut v: Vec<(String, String)> = self.fulltext.enabled_pairs().cloned().collect();
5865        v.sort();
5866        v
5867    }
5868
5869    /// Enable an equality index for all nodes of `label` on scalar property
5870    /// `field`. Subsequent `WHERE n.field = value` lookups become O(matches)
5871    /// instead of an O(N_label) scan. Existing nodes are backfilled; the
5872    /// declaration persists via WAL and the postings rebuild on re-open.
5873    ///
5874    /// # Errors
5875    /// - [`GraphError::ReadOnly`]: called on an as-of instance.
5876    /// - [`GraphError::RuleInvalid`]: `(label, field)` is already indexed.
5877    pub fn enable_index(&mut self, label: &str, field: &str) -> Result<()> {
5878        if self.read_only {
5879            return Err(GraphError::ReadOnly);
5880        }
5881        if self.prop_index.is_enabled(label, field) {
5882            return Err(GraphError::RuleInvalid {
5883                detail: format!("property index for ({label:?}, {field:?}) already enabled"),
5884            });
5885        }
5886        self.log_then_apply(WalRecord::EnableIndex {
5887            label: label.into(),
5888            field: field.into(),
5889        })
5890    }
5891
5892    /// Disable the equality index for `(label, field)` and drop its postings.
5893    ///
5894    /// # Errors
5895    /// - [`GraphError::ReadOnly`]: called on an as-of instance.
5896    /// - [`GraphError::RuleNotFound`]: `(label, field)` is not currently indexed.
5897    pub fn disable_index(&mut self, label: &str, field: &str) -> Result<()> {
5898        if self.read_only {
5899            return Err(GraphError::ReadOnly);
5900        }
5901        if !self.prop_index.is_enabled(label, field) {
5902            return Err(GraphError::RuleNotFound {
5903                name: format!("index({label},{field})"),
5904            });
5905        }
5906        self.log_then_apply(WalRecord::DisableIndex {
5907            label: label.into(),
5908            field: field.into(),
5909        })
5910    }
5911
5912    /// Whether `(label, field)` currently has an equality index.
5913    pub fn is_index_enabled(&self, label: &str, field: &str) -> bool {
5914        self.prop_index.is_enabled(label, field)
5915    }
5916
5917    /// Search a full-text-indexed field.
5918    ///
5919    /// Returns `(node_key, match_count)` pairs sorted by match_count descending,
5920    /// ties broken by key (lexicographic).  Tombstoned nodes are excluded.
5921    ///
5922    /// **Query syntax:**
5923    /// - Space-separated terms are AND'd: `"foo bar"` requires both.
5924    /// - `OR` between terms forms disjunction: `"foo OR bar"` matches either.
5925    /// - Trailing `*` on a term is a prefix match: `"rust*"` matches `rustlang`, `rusty`.
5926    /// - `AND` keyword is accepted explicitly and is the default.
5927    /// - Tokenization is unicode-alphanumeric (same as index time); case-insensitive.
5928    ///
5929    /// **Unindexed field:** returns `Ok(vec![])` if `field` is not indexed.
5930    /// Pin: this is the documented, tested, stable behavior for v1.
5931    ///
5932    /// **Memory / performance:** O(postings) lookup; no scan.  The index is
5933    /// in-memory and proportional to total indexed text across all enabled fields.
5934    ///
5935    /// **v2 grammar:** supports `"phrase"`, `-negation`, `prefix*`, `OR`, `AND`.
5936    /// Results are BM25-scored (k1=1.2, b=0.75) and sorted by score descending,
5937    /// key ascending for deterministic tiebreaking.
5938    pub fn search(&self, field: &str, query: &str) -> Vec<(String, f64)> {
5939        // Resolve node_ids to keys (excluding tombstones) then re-sort by
5940        // (score DESC, key ASC) to give a deterministic, key-lexicographic
5941        // tiebreak.  FulltextIndex::search sorts by (score DESC, node_id ASC)
5942        // which diverges from key order when nodes were not inserted in key-lex order.
5943        let mut results: Vec<(String, f64)> = self
5944            .fulltext
5945            .search(field, query, 0)
5946            .into_iter()
5947            .filter_map(|(id, score)| self.ids.key_of(id).map(|key| (key.to_string(), score)))
5948            .collect();
5949        results.sort_by(|a, b| {
5950            b.1.partial_cmp(&a.1)
5951                .unwrap_or(std::cmp::Ordering::Equal)
5952                .then(a.0.cmp(&b.0))
5953        });
5954        results
5955    }
5956
5957    /// [`search`](Self::search), stopping at the `k` best hits.
5958    ///
5959    /// Same ranking and the same deterministic tiebreak, but the index drops
5960    /// everything past `k` before any key is resolved, so a caller that wants
5961    /// the top few out of a field that matched thousands does not pay to
5962    /// materialise and re-sort the tail. `k == 0` means no limit, exactly as
5963    /// [`search`](Self::search) behaves.
5964    ///
5965    /// The BM25 scoring itself is not bounded by `k` — every candidate is
5966    /// scored either way — so this trims the resolve and the sort, not the
5967    /// search.
5968    pub fn search_top(&self, field: &str, query: &str, k: usize) -> Vec<(String, f64)> {
5969        // A tombstoned id resolves to nothing, so asking the index for exactly
5970        // `k` could return fewer. Over-fetching a little and truncating after
5971        // the filter keeps the count right without unbounding the call.
5972        let want = if k == 0 { 0 } else { k.saturating_mul(2) };
5973        let mut results: Vec<(String, f64)> = self
5974            .fulltext
5975            .search(field, query, want)
5976            .into_iter()
5977            .filter_map(|(id, score)| self.ids.key_of(id).map(|key| (key.to_string(), score)))
5978            .collect();
5979        results.sort_by(|a, b| {
5980            b.1.partial_cmp(&a.1)
5981                .unwrap_or(std::cmp::Ordering::Equal)
5982                .then(a.0.cmp(&b.0))
5983        });
5984        if k > 0 {
5985            results.truncate(k);
5986        }
5987        results
5988    }
5989
5990    /// Hybrid search: Reciprocal Rank Fusion (RRF) over fulltext + vector results.
5991    ///
5992    /// Takes up to `4*k` fulltext hits for `(text_field, query_text)` and up to
5993    /// `4*k` vector hits for `(vector_field, query_vec, min=0.0)`, then fuses
5994    /// them with RRF using a fixed constant of 60.
5995    ///
5996    /// ```text
5997    /// score(d) = Σ  1 / (60 + rank_i(d))    (rank 1-based per list)
5998    /// ```
5999    ///
6000    /// Returns the top `k` nodes by fused score, ties broken by node key
6001    /// ascending (deterministic).
6002    ///
6003    /// # Vector leg fallback
6004    ///
6005    /// When `query_vec` is empty the vector leg is skipped entirely and
6006    /// results are ranked by the text list alone through the same RRF path
6007    /// (each text result scores `1/(60 + rank)` from that single list).
6008    ///
6009    /// When `label` is `None`, the vector leg **always** returns empty results.
6010    /// Internally `label` is mapped to `""`, which does not match any rule-created
6011    /// HNSW index (all such indexes are keyed to a specific non-empty label), and
6012    /// the brute-force fallback finds no nodes with an empty label.  The fused
6013    /// ranking is therefore text-only in this case.
6014    pub fn search_hybrid(
6015        &self,
6016        text_field: &str,
6017        query_text: &str,
6018        vector_field: &str,
6019        query_vec: &[f64],
6020        label: Option<&str>,
6021        k: usize,
6022    ) -> Vec<(String, f64)> {
6023        use std::collections::HashMap;
6024
6025        const RRF_K: f64 = 60.0;
6026        let pool = 4 * k;
6027
6028        // Accumulate per-node RRF scores.
6029        let mut scores: HashMap<String, f64> = HashMap::new();
6030
6031        // Text leg.
6032        let text_hits = self.search(text_field, query_text);
6033        for (rank0, (key, _count)) in text_hits.into_iter().take(pool).enumerate() {
6034            let rank = (rank0 + 1) as f64;
6035            *scores.entry(key).or_insert(0.0) += 1.0 / (RRF_K + rank);
6036        }
6037
6038        // Vector leg (skipped when query_vec is empty).
6039        if !query_vec.is_empty() {
6040            let vec_hits = self.find_similar_vector(vector_field, label, query_vec, pool, 0.0);
6041            for (rank0, (key, _sim)) in vec_hits.into_iter().enumerate() {
6042                let rank = (rank0 + 1) as f64;
6043                *scores.entry(key).or_insert(0.0) += 1.0 / (RRF_K + rank);
6044            }
6045        }
6046
6047        // Sort: score DESC, then key ASC for deterministic tie-breaking.
6048        let mut ranked: Vec<(String, f64)> = scores.into_iter().collect();
6049        ranked.sort_by(|a, b| {
6050            b.1.partial_cmp(&a.1)
6051                .unwrap_or(std::cmp::Ordering::Equal)
6052                .then(a.0.cmp(&b.0))
6053        });
6054        ranked.truncate(k);
6055        ranked
6056    }
6057
6058    /// For DST/testing: scratch BM25 search over live nodes without the index.
6059    /// Walks every live node, re-stems field tokens, computes corpus stats, and
6060    /// returns BM25-ranked results.
6061    ///
6062    /// The oracle: the ordered key list of `search(field, q)` must equal that of
6063    /// `scratch_search(field, q)` at every quiescent state.
6064    #[doc(hidden)]
6065    pub fn scratch_search(&self, field: &str, query: &str) -> Vec<(String, f64)> {
6066        use core_storage::fulltext::{parse_query, value_tokens_stemmed_with_positions};
6067        use std::collections::BTreeMap;
6068
6069        let groups = parse_query(query);
6070        if groups.is_empty() {
6071            return vec![];
6072        }
6073
6074        // --- Pass 1: collect all live indexed nodes with stemmed token data ---
6075        struct NodeData {
6076            key: String,
6077            /// stemmed_token → positions (sorted)
6078            tokens: BTreeMap<String, Vec<u32>>,
6079            dl: u32,
6080        }
6081
6082        let mut nodes: Vec<NodeData> = Vec::new();
6083        for id in 0..self.ids.len() as u32 {
6084            let Some(key) = self.ids.key_of(id) else {
6085                continue;
6086            };
6087            let Some(&sym) = self.labels.get(id as usize) else {
6088                continue;
6089            };
6090            if sym == u32::MAX {
6091                continue;
6092            }
6093            let label = match self.syms.resolve(sym) {
6094                Some(l) => l,
6095                None => continue,
6096            };
6097            if !self.fulltext.is_enabled(label, field) {
6098                continue;
6099            }
6100            let Some(value) = self.props_view().get(id, field).map(|vr| vr.into_value()) else {
6101                continue;
6102            };
6103            // Use value_tokens_stemmed_with_positions so list elements are
6104            // separated by POSITION_GAP — identical to the index path, which
6105            // prevents phrase queries from matching across element boundaries.
6106            let stemmed_with_pos = match &value {
6107                Value::Str(_) | Value::List(_) => value_tokens_stemmed_with_positions(&value),
6108                _ => continue,
6109            };
6110            let dl = stemmed_with_pos.len() as u32;
6111            let mut tok_map: BTreeMap<String, Vec<u32>> = BTreeMap::new();
6112            for (tok, pos) in stemmed_with_pos {
6113                tok_map.entry(tok).or_default().push(pos);
6114            }
6115            nodes.push(NodeData {
6116                key: key.to_string(),
6117                tokens: tok_map,
6118                dl,
6119            });
6120        }
6121
6122        if nodes.is_empty() {
6123            return vec![];
6124        }
6125
6126        // --- BM25 corpus stats ---
6127        let n = nodes.len() as f64;
6128        let avg_dl: f64 = nodes.iter().map(|nd| nd.dl as f64).sum::<f64>() / n;
6129        // df per stemmed token across all live indexed nodes.
6130        let mut df_map: BTreeMap<&str, f64> = BTreeMap::new();
6131        for nd in &nodes {
6132            for tok in nd.tokens.keys() {
6133                *df_map.entry(tok.as_str()).or_insert(0.0) += 1.0;
6134            }
6135        }
6136
6137        const K1: f64 = 1.2;
6138        const B: f64 = 0.75;
6139
6140        // --- Pass 2: score each node against each OR-group ---
6141        let mut results: Vec<(String, f64)> = Vec::new();
6142        for nd in &nodes {
6143            let dl = nd.dl as f64;
6144            let mut total_score = 0.0f64;
6145
6146            'group: for group in &groups {
6147                let mut group_score = 0.0f64;
6148
6149                for term in group {
6150                    if term.negated {
6151                        // Negated: if doc has this stemmed token → group fails.
6152                        let present = if term.prefix {
6153                            nd.tokens.keys().any(|t| t.starts_with(term.token.as_str()))
6154                        } else {
6155                            nd.tokens.contains_key(term.token.as_str())
6156                        };
6157                        if present {
6158                            continue 'group;
6159                        }
6160                        continue;
6161                    }
6162                    if term.prefix {
6163                        // Prefix: sum BM25 for all matching stemmed tokens.
6164                        let mut prefix_matched = false;
6165                        for (tok, positions) in &nd.tokens {
6166                            if tok.starts_with(term.token.as_str()) {
6167                                let tf = positions.len() as f64;
6168                                let df = df_map.get(tok.as_str()).copied().unwrap_or(1.0);
6169                                let idf = ((n - df + 0.5) / (df + 0.5) + 1.0).ln();
6170                                let tf_norm =
6171                                    tf * (K1 + 1.0) / (tf + K1 * (1.0 - B + B * dl / avg_dl));
6172                                group_score += idf * tf_norm;
6173                                prefix_matched = true;
6174                            }
6175                        }
6176                        if !prefix_matched {
6177                            continue 'group;
6178                        }
6179                    } else {
6180                        // term.token is already stemmed by parse_query; use directly.
6181                        match nd.tokens.get(term.token.as_str()) {
6182                            None => continue 'group,
6183                            Some(positions) => {
6184                                let tf = positions.len() as f64;
6185                                let df = df_map.get(term.token.as_str()).copied().unwrap_or(1.0);
6186                                let idf = ((n - df + 0.5) / (df + 0.5) + 1.0).ln();
6187                                let tf_norm =
6188                                    tf * (K1 + 1.0) / (tf + K1 * (1.0 - B + B * dl / avg_dl));
6189                                group_score += idf * tf_norm;
6190                            }
6191                        }
6192                    }
6193                }
6194
6195                if group_score > 0.0 {
6196                    total_score += group_score;
6197                }
6198            }
6199
6200            if total_score > 0.0 {
6201                results.push((nd.key.clone(), total_score));
6202            }
6203        }
6204
6205        results.sort_by(|a, b| {
6206            b.1.partial_cmp(&a.1)
6207                .unwrap_or(std::cmp::Ordering::Equal)
6208                .then(a.0.cmp(&b.0))
6209        });
6210        results
6211    }
6212
6213    /// Return the current view-maintained value of `view_prop` for node `key`.
6214    /// Equivalent to `get_prop` but documents that it reads a view-managed column.
6215    pub fn get_view_prop(&self, key: &str, view_prop: &str) -> Option<Value> {
6216        let id = self.ids.get(key)?;
6217        self.props_view()
6218            .get(id, view_prop)
6219            .map(|vr| vr.into_value())
6220    }
6221
6222    /// For testing / DST oracle: scratch recompute of a view value for one node.
6223    ///
6224    /// Returns `None` if the node does not exist, the view does not exist, or
6225    /// the view has no result for the node (e.g. Avg with no qualifying neighbors).
6226    #[doc(hidden)]
6227    pub fn scratch_view_value(&self, key: &str, view_name: &str) -> Option<Value> {
6228        let node = self.ids.get(key)?;
6229        let def = self.view_store.views().find(|v| v.name == view_name)?;
6230        // Use TopologyView so that NeighborAgg sees base + overlay edges
6231        // without materialising a temporary Topology (I1).
6232        let topo_view = self.topo_view();
6233        core_rules::views::compute_view_value(
6234            def,
6235            node,
6236            self.props_view(),
6237            &topo_view,
6238            &self.ids,
6239            &self.syms,
6240            &self.labels,
6241        )
6242    }
6243
6244    // -----------------------------------------------------------------------
6245    // Graph algorithm API
6246    // -----------------------------------------------------------------------
6247
6248    /// Run PageRank over the unified topology (manual + derived edges).
6249    ///
6250    /// Returns a [`PageRankReport`] with scores sorted descending (ties: key
6251    /// ascending).  Set `config.edge_type` to restrict to one edge type.
6252    /// `config.converged` is `true` only when the power iteration converged
6253    /// within `config.max_iters` and within any time budget.
6254    pub fn pagerank(&self, config: &crate::algo::PageRankConfig) -> crate::algo::PageRankReport {
6255        let topo = build_topo_view(&self.topo, &self.base);
6256        let edge_props = self.edge_props_view();
6257        crate::algo::pagerank(
6258            &topo,
6259            &self.ids,
6260            &self.syms,
6261            &self.labels,
6262            &edge_props,
6263            config,
6264        )
6265    }
6266
6267    /// Weakly-connected components over the unified topology (treated as
6268    /// undirected regardless of how edges were inserted).
6269    ///
6270    /// Component IDs are the key of the smallest member in the component
6271    /// (deterministic).  Result sorted by (component_id, key).
6272    pub fn connected_components(&self, config: &crate::algo::WccConfig) -> crate::algo::WccReport {
6273        let topo = build_topo_view(&self.topo, &self.base);
6274        let edge_props = self.edge_props_view();
6275        crate::algo::wcc(
6276            &topo,
6277            &self.ids,
6278            &self.syms,
6279            &self.labels,
6280            &edge_props,
6281            config,
6282        )
6283    }
6284
6285    /// Degree centrality for every live node.
6286    ///
6287    /// `direction`: `AlgoDir::Out` = out-degree, `AlgoDir::In` = in-degree,
6288    /// `AlgoDir::Both` = out + in (total directed degree).
6289    ///
6290    /// For one-shot ranking use this; for a live property updated on every
6291    /// write, create a Degree materialized view instead (see `docs/site/algorithms.md`).
6292    pub fn degree_centrality(
6293        &self,
6294        config: &crate::algo::DegreeConfig,
6295    ) -> crate::algo::DegreeReport {
6296        let topo = build_topo_view(&self.topo, &self.base);
6297        let edge_props = self.edge_props_view();
6298        crate::algo::degree_centrality(
6299            &topo,
6300            &self.ids,
6301            &self.syms,
6302            &self.labels,
6303            &edge_props,
6304            config,
6305        )
6306    }
6307
6308    /// Louvain community detection over the unified topology (undirected).
6309    ///
6310    /// See [`crate::algo::LouvainConfig`] for edge-type/weight/label
6311    /// restriction and [`crate::algo::CommunityReport`] for the shape of the
6312    /// result (communities sorted size-desc, then smallest member key asc).
6313    pub fn communities(&self, config: &crate::algo::LouvainConfig) -> crate::algo::CommunityReport {
6314        let topo = build_topo_view(&self.topo, &self.base);
6315        let edge_props = self.edge_props_view();
6316        crate::algo::louvain(
6317            &topo,
6318            &self.ids,
6319            &self.syms,
6320            &self.labels,
6321            &edge_props,
6322            config,
6323        )
6324    }
6325
6326    /// Write a vector of `(node_key, score)` pairs as `prop_name` on each node,
6327    /// atomically via a single write-batch (one WAL frame, one fsync).
6328    ///
6329    /// # Errors
6330    /// - [`GraphError::ReadOnly`]: called on an as-of instance.
6331    /// - [`GraphError::RuleInvalid`]: `prop_name` is managed by an existing view
6332    ///   (collision check mirrors `create_view`).
6333    /// - [`GraphError::KeyNotFound`]: a key in `scores` does not exist as a live node.
6334    pub fn write_scores(&mut self, prop_name: &str, scores: &[(String, f64)]) -> Result<()> {
6335        if self.read_only {
6336            return Err(GraphError::ReadOnly);
6337        }
6338        // Collision check: refuse if prop_name is view-managed.
6339        if let Some(view_name) = self.view_store.view_for_prop(prop_name) {
6340            return Err(GraphError::RuleInvalid {
6341                detail: format!(
6342                    "prop {:?} is managed by view {:?} and cannot be written as scores",
6343                    prop_name, view_name
6344                ),
6345            });
6346        }
6347        // Refuse if prop_name is a view name itself (confusing namespace collision).
6348        if self.view_store.has_view(prop_name) {
6349            return Err(GraphError::RuleInvalid {
6350                detail: format!(
6351                    "prop_name {:?} collides with an existing view name",
6352                    prop_name
6353                ),
6354            });
6355        }
6356        // Write all scores in a single crash-atomic batch.
6357        self.write_batch(|b| {
6358            for (key, score) in scores {
6359                b.set_prop(key, prop_name, Value::Float(*score));
6360            }
6361        })?;
6362        Ok(())
6363    }
6364
6365    /// Return the value of `field` for the node with key `key`, or `None` if
6366    /// the node or field is absent.  Reads through the overlay-over-base
6367    /// `ColumnsView`, materialising base values on demand (zero heap cost for
6368    /// overlay hits; one clone per base hit).
6369    pub fn get_prop(&self, key: &str, field: &str) -> Option<Value> {
6370        let id = self.ids.get(key)?;
6371        self.props_view().get(id, field).map(|vr| vr.into_value())
6372    }
6373
6374    pub fn has_node(&self, key: &str) -> bool {
6375        self.ids.get(key).is_some()
6376    }
6377
6378    /// Borrow the raw id map. Used by `NodeMask::from_keys` to resolve keys.
6379    pub(crate) fn ids(&self) -> &IdMap {
6380        &self.ids
6381    }
6382
6383    // -----------------------------------------------------------------------
6384    // RBAC role resolution
6385    // -----------------------------------------------------------------------
6386
6387    /// Parse `roles.json` bytes from `fs`.
6388    ///
6389    /// Return values:
6390    ///   `Ok(Some(roles))` — file absent (returns `vec![]`) **or** file present
6391    ///                       and valid; in both cases `mask_for_role` uses the
6392    ///                       list normally (an absent file means no roles defined).
6393    ///   `Ok(None)`        — file present but corrupt or unrecognised version
6394    ///                       → poisoned state; `mask_for_role` returns `Err` for
6395    ///                       any role name until the file is fixed and the DB
6396    ///                       re-opened (or `apply_schema` is called to repair it).
6397    ///
6398    /// Note: `None` signals corruption, not absence — the opposite of what an
6399    /// optional "file missing" convention would suggest.  The open path stores
6400    /// this result on `db.roles` directly.
6401    fn load_roles_from_fs(fs: &F) -> Result<Option<Vec<RoleDef>>> {
6402        let bytes = fs.read(FileId::Roles).map_err(GraphError::Io)?;
6403        if bytes.is_empty() {
6404            // Empty bytes means either the file is absent or zero-byte — both
6405            // are treated identically as "no roles defined".  A zero-byte
6406            // roles.json does NOT widen access: an absent file and a zero-byte
6407            // file both resolve to an empty role list (sees nothing by default).
6408            return Ok(Some(vec![]));
6409        }
6410        match serde_json::from_slice::<RolesFile>(&bytes) {
6411            Ok(f) if matches!(f.version, 1..=3) => Ok(Some(f.roles)),
6412            // Corrupt or unrecognised version (>3): poison the roles state.
6413            // Never widen: a version this binary does not know may carry a
6414            // narrowing this binary would not apply.
6415            _ => Ok(None),
6416        }
6417    }
6418
6419    /// Resolve a role to a node-visibility mask against the current graph state.
6420    ///
6421    /// Returns `Err` when:
6422    /// - `roles.json` was present but corrupt at open (poisoned state), or
6423    /// - `role` does not match any defined role name.
6424    ///
6425    /// The mask union is: explicit `keys` (unknown keys silently ignored) plus
6426    /// all live nodes carrying any label in `labels` that also pass the role's
6427    /// [`visible_where`](crate::roles::RoleDef::visible_where) predicate, if it
6428    /// has one.  Label resolution is live — new nodes of an allowed label are
6429    /// visible without re-applying the schema, and a property edited out of the
6430    /// predicate takes its node out of the mask on the next read.  An empty
6431    /// union = empty mask = sees nothing.
6432    ///
6433    /// This is the one resolver every read path calls, live and as-of alike, so
6434    /// the predicate applies everywhere at once.  On an as-of handle the role
6435    /// *definition* is the current one and the graph is the historical one: the
6436    /// predicate is evaluated against the property values at the commit being
6437    /// read.
6438    ///
6439    /// The result is memoised per `(role, commit_seq)`, so a scoped reader
6440    /// between two writes resolves the role once.  See
6441    /// [`RoleMaskCache`](crate::mask::RoleMaskCache) for why that cannot go
6442    /// stale.
6443    pub fn mask_for_role(&self, role: &str) -> Result<crate::mask::NodeMask> {
6444        self.role_masks
6445            .get_or_build(role, self.commit_seq, || self.build_mask_for_role(role))
6446            .map(|m| (*m).clone())
6447    }
6448
6449    /// Resolve `role` against the current graph, ignoring the memo.
6450    fn build_mask_for_role(&self, role: &str) -> Result<crate::mask::NodeMask> {
6451        let roles = self.roles.as_ref().ok_or_else(|| GraphError::Corrupt {
6452            detail:
6453                "roles.json was corrupt at open; fix the file and re-open to restore role access"
6454                    .into(),
6455        })?;
6456        let def = roles
6457            .iter()
6458            .find(|r| r.name == role)
6459            .ok_or_else(|| GraphError::KeyNotFound {
6460                key: format!("role:{role}"),
6461            })?;
6462
6463        let mut visible = std::collections::HashSet::new();
6464
6465        // Key leg: resolve explicit keys to dense ids (unknown keys ignored).
6466        // An administrative grant, never narrowed by the predicate.
6467        for key in &def.keys {
6468            if let Some(id) = self.ids.get(key) {
6469                visible.insert(id);
6470            }
6471        }
6472
6473        // Label leg: live scan — iterate labels vec for matching symbol, and
6474        // when the role carries a predicate, test the property as well.  The
6475        // property comes from the store's own merged view (overlay over the
6476        // mmap'd base), so an as-of handle reads the values of its own commit.
6477        let props = def.visible_where.as_ref().map(|_| self.props_view());
6478        for label_name in &def.labels {
6479            if let Some(sym) = self.syms.get(label_name) {
6480                for (i, &s) in self.labels.iter().enumerate() {
6481                    if s != sym {
6482                        continue;
6483                    }
6484                    let id = i as u32;
6485                    match (&def.visible_where, &props) {
6486                        (Some(pred), Some(view)) => {
6487                            let value = view.get(id, &pred.field).map(|vr| vr.into_value());
6488                            if pred.holds(value.as_ref()) {
6489                                visible.insert(id);
6490                            }
6491                        }
6492                        _ => {
6493                            visible.insert(id);
6494                        }
6495                    }
6496                }
6497            }
6498        }
6499
6500        Ok(crate::mask::NodeMask::from_ids(visible))
6501    }
6502
6503    /// Return the current list of role definitions.
6504    ///
6505    /// Returns an empty list when no roles are defined or when `roles.json`
6506    /// was corrupt at open (check [`mask_for_role`](Self::mask_for_role) for
6507    /// the fail-loud error in that case).
6508    pub fn roles(&self) -> Vec<RoleDef> {
6509        self.roles.as_deref().unwrap_or(&[]).to_vec()
6510    }
6511
6512    // ── Role-scoped write authz ───────────────────────────────────────────────
6513
6514    /// Execute `ops` with optional role-scoped write authorization.
6515    ///
6516    /// - `None` → full authority, identical to [`write_batch`](Self::write_batch)
6517    ///   (zero-cost bypass of all authz checks).
6518    /// - `Some(authz)` → the decision table is evaluated per-op BEFORE any WAL
6519    ///   record is built.  A denial returns an error with no WAL frame written
6520    ///   (all-or-nothing at the authz boundary, then at the MutPreview boundary).
6521    ///
6522    /// See the plan's "authz decision table" section for the full semantics.
6523    pub fn write_batch_authz(
6524        &mut self,
6525        authz: Option<&WriteAuthz>,
6526        ops: Vec<BatchOp>,
6527    ) -> Result<(usize, usize)> {
6528        // Thread authz as a direct parameter — never touches pending_write_authz.
6529        self.commit_logged_batch(ops, None, authz.cloned())
6530    }
6531
6532    /// Execute a Cypher write statement with role-scoped write authorization.
6533    ///
6534    /// Resolves scope + mask from `self.roles` inside the call (same write-guard
6535    /// lifetime as execution, satisfying §5 lock discipline).  The resolved
6536    /// `WriteAuthz` is stored as `pending_write_authz` for the duration of the
6537    /// call so that all inner `batch.commit()` calls are authz-checked.
6538    ///
6539    /// MERGE is handled specially: the MERGE scope precondition (§3.3) is
6540    /// checked in `exec_merge` BEFORE `has_node` to close the §6.2
6541    /// timing-oracle item (hidden ≡ absent for unscoped roles).
6542    ///
6543    /// Roles with `write: None` (v1 behavior) → `RoleWriteDenied` with
6544    /// "this endpoint is not permitted".
6545    pub fn query_write_authz(
6546        &mut self,
6547        role: &str,
6548        cypher: &str,
6549        params: &BTreeMap<String, Value>,
6550    ) -> Result<ResultSet> {
6551        // Resolve scope (fails fast if role has no write scope).
6552        // write:None → byte-identical v1 blanket-403 body (plan §v1-sidecar mandate).
6553        let scope =
6554            {
6555                let roles = self.roles.as_deref().ok_or_else(|| GraphError::Corrupt {
6556                    detail: "roles.json was corrupt at open; re-open to restore role access".into(),
6557                })?;
6558                let def = roles.iter().find(|r| r.name == role).ok_or_else(|| {
6559                    GraphError::KeyNotFound {
6560                        key: format!("role:{role}"),
6561                    }
6562                })?;
6563                def.write
6564                    .clone()
6565                    .ok_or_else(|| GraphError::RoleWriteDenied {
6566                        reason: "role-bound token: writes are not permitted".into(),
6567                    })?
6568            };
6569        // Resolve mask inside the call (same guard, §5 coherence).
6570        let mask = self.mask_for_role(role)?;
6571        self.pending_write_authz = Some(WriteAuthz {
6572            role: role.into(),
6573            scope,
6574            mask,
6575        });
6576        // RAII guard: always clears pending_write_authz on scope exit, including
6577        // on panic or early-return, mirroring the RestoreEmitDeltas precedent.
6578        struct ClearPendingAuthzOnDrop(*mut Option<WriteAuthz>);
6579        impl Drop for ClearPendingAuthzOnDrop {
6580            fn drop(&mut self) {
6581                // SAFETY: pointer into the owning GraphDb; guard is dropped
6582                // within this function's frame before it returns.
6583                unsafe { *self.0 = None };
6584            }
6585        }
6586        // SAFETY: raw pointer into self; guard dropped before this fn returns.
6587        let _authz_guard = ClearPendingAuthzOnDrop(&mut self.pending_write_authz as *mut _);
6588        let tokens = lex(cypher).map_err(|e| GraphError::QueryError {
6589            detail: format!("lex: {e}"),
6590        })?;
6591        let stmt = parse_write(&tokens).map_err(|e| GraphError::QueryError {
6592            detail: format!("parse: {e}"),
6593        })?;
6594        self.exec_write_stmt(stmt, params)
6595    }
6596
6597    /// Execute `ops` with optional role-scoped write authorization, suppressing
6598    /// fsync (for use inside the group-commit drain thread, which performs one
6599    /// group fsync after releasing the write lock).
6600    ///
6601    /// Identical to [`write_batch_authz`] except the fsync policy is temporarily
6602    /// forced to `Relaxed` for the duration of the call, matching the drain-thread
6603    /// contract established by [`commit_batch_nosync`].
6604    pub(crate) fn write_batch_authz_nosync(
6605        &mut self,
6606        authz: Option<&WriteAuthz>,
6607        ops: Vec<BatchOp>,
6608    ) -> Result<(usize, usize)> {
6609        let saved = self.fsync;
6610        struct RestoreFsync(*mut FsyncPolicy, FsyncPolicy);
6611        impl Drop for RestoreFsync {
6612            fn drop(&mut self) {
6613                // SAFETY: pointer into the owning GraphDb; guard is dropped
6614                // within the enclosing function's frame before it returns.
6615                unsafe { *self.0 = self.1 };
6616            }
6617        }
6618        // SAFETY: raw pointer into self; guard dropped before this fn returns.
6619        let _g = RestoreFsync(&mut self.fsync as *mut FsyncPolicy, saved);
6620        self.fsync = FsyncPolicy::Relaxed;
6621        self.commit_logged_batch(ops, None, authz.cloned())
6622    }
6623
6624    /// Execute a `/ingest` request with role-scoped write authorization.
6625    ///
6626    /// Resolves the role's `WriteScope` and `NodeMask` inside this call (same
6627    /// write-guard lifetime as the mutation, satisfying §5 lock discipline).
6628    /// Sets `pending_write_authz` for the duration of the call so that the
6629    /// `commit_ingest` → `commit_logged_batch` path picks up the authz context
6630    /// and evaluates the decision table per-op before any WAL write.
6631    ///
6632    /// §7.3: roles with empty `create_labels` will see every `InsertNode` op
6633    /// denied by the decision table with the appropriate §4.3 scope reason;
6634    /// no special HTTP-layer check is needed.
6635    ///
6636    /// Roles with `write: None` return `RoleWriteDenied` with
6637    /// "writes are not permitted" (byte-identical to v1 blanket 403).
6638    pub fn ingest_with_edges_authz(
6639        &mut self,
6640        role: &str,
6641        label: &str,
6642        rows: Vec<std::collections::BTreeMap<String, Value>>,
6643        opts: &crate::ingest::IngestOptions,
6644        edges: &[(String, String, String)],
6645    ) -> Result<crate::ingest::IngestReport> {
6646        // Resolve scope (fails fast if role has no write scope).
6647        // write:None → byte-identical v1 blanket-403 body (plan §v1-sidecar mandate).
6648        let scope =
6649            {
6650                let roles = self.roles.as_deref().ok_or_else(|| GraphError::Corrupt {
6651                    detail: "roles.json was corrupt at open; re-open to restore role access".into(),
6652                })?;
6653                let def = roles.iter().find(|r| r.name == role).ok_or_else(|| {
6654                    GraphError::KeyNotFound {
6655                        key: format!("role:{role}"),
6656                    }
6657                })?;
6658                def.write
6659                    .clone()
6660                    .ok_or_else(|| GraphError::RoleWriteDenied {
6661                        reason: "role-bound token: writes are not permitted".into(),
6662                    })?
6663            };
6664        let mask = self.mask_for_role(role)?;
6665        self.pending_write_authz = Some(WriteAuthz {
6666            role: role.into(),
6667            scope,
6668            mask,
6669        });
6670        // RAII guard: always clears pending_write_authz on scope exit, including
6671        // on panic or early-return, mirroring the RestoreEmitDeltas precedent.
6672        struct ClearPendingAuthzOnDrop(*mut Option<WriteAuthz>);
6673        impl Drop for ClearPendingAuthzOnDrop {
6674            fn drop(&mut self) {
6675                // SAFETY: pointer into the owning GraphDb; guard is dropped
6676                // within this function's frame before it returns.
6677                unsafe { *self.0 = None };
6678            }
6679        }
6680        // SAFETY: raw pointer into self; guard dropped before this fn returns.
6681        let _authz_guard = ClearPendingAuthzOnDrop(&mut self.pending_write_authz as *mut _);
6682        self.ingest_with_edges(label, rows, opts, edges)
6683    }
6684
6685    /// Evaluate the write-authz decision table for one `BatchOp`.
6686    ///
6687    /// Called by `commit_logged_batch` for each op when `pending_write_authz`
6688    /// is `Some`, BEFORE MutPreview.  A denial returns an error immediately;
6689    /// the remaining ops are not evaluated and no WAL frame is written.
6690    ///
6691    /// `batch_created` carries the key→label pairs of nodes that earlier ops in
6692    /// THIS batch will create.  Used by `InsertEdgeUpsert` to count same-batch
6693    /// placeholder nodes as visible (spec: "a placeholder endpoint the SAME
6694    /// batch creates counts as visible if its label passed the create-class gate").
6695    fn check_single_op_authz(
6696        &self,
6697        authz: &WriteAuthz,
6698        op: &BatchOp,
6699        batch_created: &BTreeMap<String, String>,
6700    ) -> Result<()> {
6701        // Helper: 3-way node status under the authz mask.
6702        //
6703        // Batch-created nodes (from earlier InsertNode in THIS batch) are treated
6704        // as Visible with their recorded label — their create gate already passed
6705        // and they are not yet in self.ids (not committed).  This fixes the
6706        // MERGE+ON CREATE SET case where InsertNode + SetProp arrive together:
6707        // the SetProp must not see the node as Absent.
6708        let node_status = |key: &str| -> NodeAuthzStatus {
6709            if let Some(label) = batch_created.get(key) {
6710                return NodeAuthzStatus::Visible(label.clone());
6711            }
6712            match self.ids.get(key) {
6713                None => NodeAuthzStatus::Absent,
6714                Some(id) if !authz.mask.contains_id(id) => NodeAuthzStatus::Hidden,
6715                Some(id) => {
6716                    let label = self
6717                        .labels
6718                        .get(id as usize)
6719                        .and_then(|&sym| {
6720                            if sym == u32::MAX {
6721                                None
6722                            } else {
6723                                self.syms.resolve(sym).map(str::to_string)
6724                            }
6725                        })
6726                        .unwrap_or_default();
6727                    NodeAuthzStatus::Visible(label)
6728                }
6729            }
6730        };
6731
6732        // Helper: is an InsertEdgeUpsert endpoint visible?
6733        // A same-batch placeholder counts as visible if its label passed
6734        // the create-class gate (spec "upsert placeholder-counts-as-visible").
6735        let upsert_ep_visible = |ep_key: &str, placeholder_label: &str| -> bool {
6736            // In store and visible?
6737            if let Some(id) = self.ids.get(ep_key) {
6738                return authz.mask.contains_id(id);
6739            }
6740            // Created by an earlier op in this batch?
6741            if let Some(created_label) = batch_created.get(ep_key) {
6742                return authz.scope.create_labels.contains(created_label);
6743            }
6744            // Will be created by THIS InsertEdgeUpsert: placeholder_label
6745            // must pass the create-class gate.
6746            authz
6747                .scope
6748                .create_labels
6749                .contains(&placeholder_label.to_string())
6750        };
6751
6752        match op {
6753            // RenameNode / CreateRule / DeleteRule: defense-in-depth gate.
6754            // These ops are never routed to role-scoped paths by the HTTP layer,
6755            // but we 403 them here to close any future bypass route.
6756            BatchOp::RenameNode { .. } | BatchOp::CreateRule(_) | BatchOp::DeleteRule { .. } => {
6757                return Err(GraphError::RoleWriteDenied {
6758                    reason: "role-bound token: this endpoint is not permitted".into(),
6759                });
6760            }
6761
6762            // ── CREATE-class: InsertNode ─────────────────────────────────────
6763            //
6764            // Decision table row 1 (scope-before-lookup): check label in
6765            // create_labels BEFORE any key lookup.  This is the structural
6766            // closure of the §6.2 timing-oracle item — the denial fires even
6767            // when the store is EMPTY (see test_create_scope_denied_empty_store).
6768            BatchOp::InsertNode { label, key, .. } => {
6769                if !authz.scope.create_labels.contains(label) {
6770                    return Err(GraphError::RoleWriteDenied {
6771                        reason: format!(
6772                            "role-bound token: label '{}' not in write scope (create_labels)",
6773                            label
6774                        ),
6775                    });
6776                }
6777                // Row 2/3: key lookup.
6778                match self.ids.get(key.as_str()) {
6779                    Some(id) if authz.mask.contains_id(id) => {
6780                        // Visible: DuplicateKey — let MutPreview handle this.
6781                    }
6782                    Some(_) => {
6783                        // Hidden: indistinguishable from absent to the role.
6784                        return Err(GraphError::RoleWriteDenied {
6785                            reason: "role-bound token: target node not visible".into(),
6786                        });
6787                    }
6788                    None => {
6789                        // Absent: proceed (create).
6790                    }
6791                }
6792            }
6793
6794            // ── UPDATE-class: SetProp, RemoveProp ────────────────────────────
6795            BatchOp::SetProp { key, .. } | BatchOp::RemoveProp { key, .. } => {
6796                if batch_created.contains_key(key.as_str()) {
6797                    // Batch-created node: create gate already passed this batch.
6798                    // Updating it in the same batch is always allowed, regardless
6799                    // of update_labels (ruling §3.5: "writer just created it").
6800                } else {
6801                    let label = match node_status(key) {
6802                        NodeAuthzStatus::Visible(lbl) => lbl,
6803                        _ => {
6804                            return Err(GraphError::RoleWriteDenied {
6805                                reason: "role-bound token: target node not visible".into(),
6806                            });
6807                        }
6808                    };
6809                    if !authz.scope.update_labels.contains(&label) {
6810                        return Err(GraphError::RoleWriteDenied {
6811                            reason: format!(
6812                                "role-bound token: label '{}' not in write scope (update_labels)",
6813                                label
6814                            ),
6815                        });
6816                    }
6817                }
6818            }
6819
6820            // ── DELETE-class: DeleteNode ─────────────────────────────────────
6821            BatchOp::DeleteNode { key } => {
6822                let label = match node_status(key) {
6823                    NodeAuthzStatus::Visible(lbl) => lbl,
6824                    _ => {
6825                        return Err(GraphError::RoleWriteDenied {
6826                            reason: "role-bound token: target node not visible".into(),
6827                        });
6828                    }
6829                };
6830                if !authz.scope.delete_labels.contains(&label) {
6831                    return Err(GraphError::RoleWriteDenied {
6832                        reason: format!(
6833                            "role-bound token: label '{}' not in write scope (delete_labels)",
6834                            label
6835                        ),
6836                    });
6837                }
6838            }
6839
6840            // ── DELETE-class: DeleteEdge ─────────────────────────────────────
6841            //
6842            // Derived-edge rejection runs BEFORE the delete_edge_types scope
6843            // check (spec §3.5: "existing derived-edge rejection precedes
6844            // delete_edge_types check").
6845            BatchOp::DeleteEdge {
6846                edge_type,
6847                src_key,
6848                dst_key,
6849            } => {
6850                // Check provenance ownership BEFORE scope (spec §3.5 ordering).
6851                if let (Some(src_id), Some(dst_id), Some(et_sym)) = (
6852                    self.ids.get(src_key.as_str()),
6853                    self.ids.get(dst_key.as_str()),
6854                    self.syms.get(edge_type.as_str()),
6855                ) {
6856                    if self.engine.is_owned(et_sym, src_id, dst_id) {
6857                        return Err(GraphError::RuleOwned {
6858                            detail: format!(
6859                                "edge {edge_type} {src_key}→{dst_key} is rule-owned; \
6860                                 delete or change the owning rule"
6861                            ),
6862                        });
6863                    }
6864                    // Also check would_derive via MutPreview (empty overlay, pre-batch).
6865                    let preview = MutPreview::new(self);
6866                    if preview.would_derive(edge_type, src_key, dst_key) {
6867                        return Err(GraphError::RuleOwned {
6868                            detail: format!(
6869                                "edge {edge_type} {src_key}→{dst_key} is rule-owned; \
6870                                 delete or change the owning rule, or a live rule would \
6871                                 re-derive it"
6872                            ),
6873                        });
6874                    }
6875                }
6876                // Scope check (AFTER derived-edge check, BEFORE endpoint visibility).
6877                if !authz.scope.delete_edge_types.contains(edge_type) {
6878                    return Err(GraphError::RoleWriteDenied {
6879                        reason: format!(
6880                            "role-bound token: edge type '{}' not in write scope (delete_edge_types)",
6881                            edge_type
6882                        ),
6883                    });
6884                }
6885                // Both endpoints must be visible.
6886                for ep_key in [src_key.as_str(), dst_key.as_str()] {
6887                    match self.ids.get(ep_key) {
6888                        None => {
6889                            return Err(GraphError::RoleWriteDenied {
6890                                reason: "role-bound token: edge endpoint not visible".into(),
6891                            });
6892                        }
6893                        Some(id) if !authz.mask.contains_id(id) => {
6894                            return Err(GraphError::RoleWriteDenied {
6895                                reason: "role-bound token: edge endpoint not visible".into(),
6896                            });
6897                        }
6898                        _ => {}
6899                    }
6900                }
6901            }
6902
6903            // ── EDGE-CREATE: InsertEdge ──────────────────────────────────────
6904            //
6905            // Scope check BEFORE endpoint lookup (preserves timing symmetry).
6906            BatchOp::InsertEdge {
6907                edge_type,
6908                src_key,
6909                dst_key,
6910            } => {
6911                if !authz.scope.create_edge_types.contains(edge_type) {
6912                    return Err(GraphError::RoleWriteDenied {
6913                        reason: format!(
6914                            "role-bound token: edge type '{}' not in write scope (create_edge_types)",
6915                            edge_type
6916                        ),
6917                    });
6918                }
6919                // Both endpoints must be visible. A node created by an earlier
6920                // InsertNode in the same batch (tracked in batch_created) counts
6921                // as visible if its label passed the create-class gate.
6922                for ep_key in [src_key.as_str(), dst_key.as_str()] {
6923                    if batch_created.contains_key(ep_key) {
6924                        // Created earlier this batch — already scope-checked.
6925                        continue;
6926                    }
6927                    match self.ids.get(ep_key) {
6928                        None => {
6929                            return Err(GraphError::RoleWriteDenied {
6930                                reason: "role-bound token: edge endpoint not visible".into(),
6931                            });
6932                        }
6933                        Some(id) if !authz.mask.contains_id(id) => {
6934                            return Err(GraphError::RoleWriteDenied {
6935                                reason: "role-bound token: edge endpoint not visible".into(),
6936                            });
6937                        }
6938                        _ => {}
6939                    }
6940                }
6941            }
6942
6943            // ── EDGE-CREATE: InsertEdgeUpsert ────────────────────────────────
6944            //
6945            // Scope check first; then endpoint visibility using same-batch
6946            // placeholder awareness (spec: "a placeholder endpoint the SAME
6947            // batch creates counts as visible if its label passed the
6948            // create-class gate").
6949            BatchOp::InsertEdgeUpsert {
6950                edge_type,
6951                src_key,
6952                dst_key,
6953                placeholder_label,
6954            } => {
6955                if !authz.scope.create_edge_types.contains(edge_type) {
6956                    return Err(GraphError::RoleWriteDenied {
6957                        reason: format!(
6958                            "role-bound token: edge type '{}' not in write scope (create_edge_types)",
6959                            edge_type
6960                        ),
6961                    });
6962                }
6963                // Check placeholder label against create_labels (create-class gate).
6964                // This ensures the auto-created endpoints are scope-allowed.
6965                for ep_key in [src_key.as_str(), dst_key.as_str()] {
6966                    if !upsert_ep_visible(ep_key, placeholder_label) {
6967                        return Err(GraphError::RoleWriteDenied {
6968                            reason: "role-bound token: edge endpoint not visible".into(),
6969                        });
6970                    }
6971                }
6972            }
6973        }
6974        Ok(())
6975    }
6976
6977    /// Write `roles` to `roles.json` atomically and update the in-memory list.
6978    ///
6979    /// Called by `apply_schema` when roles change. Never called on unchanged
6980    /// re-apply — this preserves byte-identical idempotency.
6981    pub(crate) fn commit_roles(&mut self, roles: Vec<RoleDef>) -> Result<()> {
6982        let file = RolesFile::new_versioned(roles.clone());
6983        let bytes = serde_json::to_vec(&file).map_err(|e| GraphError::Corrupt {
6984            detail: format!("roles serialization: {e}"),
6985        })?;
6986        self.fs
6987            .write_atomic(FileId::Roles, &bytes)
6988            .map_err(GraphError::Io)?;
6989        self.roles = Some(roles);
6990        // Rewriting the sidecar is not a commit, so `commit_seq` does not move
6991        // and a memoised mask would still match its version. Install a fresh
6992        // cache instead of clearing the shared one: a reader snapshot frozen
6993        // against the old definitions keeps the old `Arc` to itself and can
6994        // never publish an answer this handle would read back.
6995        self.role_masks = Arc::new(crate::mask::RoleMaskCache::new());
6996        // Refresh the MVCC frozen overlay so that reader() immediately sees the
6997        // updated role definitions without waiting for the next K-commit fold.
6998        self.fold_now();
6999        Ok(())
7000    }
7001
7002    fn view(&self) -> GraphView<'_> {
7003        GraphView {
7004            ids: &self.ids,
7005            syms: &self.syms,
7006            labels: &self.labels,
7007            props: self.props_view(),
7008            topo: self.topo_view(),
7009            edge_props: self.edge_props_view(),
7010            mask: None,
7011            prop_index: Some(&self.prop_index),
7012        }
7013    }
7014
7015    fn view_masked<'a>(&'a self, mask: &'a crate::mask::NodeMask) -> GraphView<'a> {
7016        GraphView {
7017            ids: &self.ids,
7018            syms: &self.syms,
7019            labels: &self.labels,
7020            props: self.props_view(),
7021            topo: self.topo_view(),
7022            edge_props: self.edge_props_view(),
7023            mask: Some(&mask.visible),
7024            prop_index: Some(&self.prop_index),
7025        }
7026    }
7027
7028    /// Execute a read-only Cypher query with a node visibility mask.
7029    ///
7030    /// Only nodes whose key is in `mask` are accessible: label scans, key
7031    /// lookups, and neighbor expansions all respect the mask. Edges where
7032    /// either endpoint is hidden are silently dropped.
7033    ///
7034    /// Returns `Err` with a "masked queries are read-only" message when
7035    /// `cypher` is a write statement (CREATE / MERGE / MATCH…SET / DELETE).
7036    pub fn query_masked(
7037        &self,
7038        cypher: &str,
7039        params: &std::collections::BTreeMap<String, Value>,
7040        mask: &crate::mask::NodeMask,
7041    ) -> Result<ResultSet> {
7042        // Reject write statements up front.
7043        let tokens = lex(cypher).map_err(|e| GraphError::QueryError {
7044            detail: format!("lex: {e}"),
7045        })?;
7046        if is_write_tokens(&tokens) {
7047            return Err(GraphError::MaskedReadOnly);
7048        }
7049        let union = parse_read(&tokens).map_err(|e| GraphError::QueryError {
7050            detail: format!("parse: {e}"),
7051        })?;
7052        // Each UNION part executes against the same masked view, so the mask
7053        // applies uniformly across the chain.
7054        execute_union(&self.view_masked(mask), &union, &Params(params)).map_err(|e| {
7055            GraphError::QueryError {
7056                detail: format!("execute: {e}"),
7057            }
7058        })
7059    }
7060
7061    pub fn node_ref(&self, key: &str) -> Option<NodeRef<'_, F>> {
7062        let id = self.ids.get(key)?;
7063        Some(NodeRef { db: self, id })
7064    }
7065
7066    /// BFS neighborhood expansion restricted to visible nodes in `mask`.
7067    ///
7068    /// Hidden nodes are never used as traversal intermediaries in either
7069    /// [`MaskMode::Omit`] or [`MaskMode::Stub`] — a visible node reachable
7070    /// only through a hidden node will not appear in results.
7071    ///
7072    /// In [`MaskMode::Stub`] mode, hidden nodes that are direct neighbours of
7073    /// a visited visible node are appended to the result as stub rows
7074    /// (`label` column is `null`, same key+depth columns as visible rows).
7075    /// They are NOT added to the BFS frontier.
7076    ///
7077    /// Returns `None` when `key` does not exist (caller should 404).
7078    ///
7079    /// **SECURITY**: role-token callers always pass an Omit-mode mask, so
7080    /// stub rows are never produced on the role path.
7081    pub fn neighborhood_masked(
7082        &self,
7083        key: &str,
7084        depth: u32,
7085        edge_types: Option<&[&str]>,
7086        dir: Dir,
7087        mask: &crate::mask::NodeMask,
7088    ) -> Option<ResultSet> {
7089        let start_id = self.ids.get(key)?;
7090        let view = self.view_masked(mask);
7091        let resolved: Option<Vec<u32>> = edge_types.map(|names| {
7092            names
7093                .iter()
7094                .filter_map(|name| view.syms.get(name))
7095                .collect()
7096        });
7097        let nb = neighborhood(&view, start_id, depth, resolved.as_deref(), dir);
7098        let mut rs = ResultSet::new(vec!["key".into(), "label".into(), "depth".into()]);
7099        // Collect visible BFS results (start_id at depth 0, BFS nodes after).
7100        let mut visited: Vec<(u32, u32)> = Vec::with_capacity(nb.nodes.len() + 1);
7101        visited.push((start_id, 0));
7102        for (nid, d) in &nb.nodes {
7103            let k = view.key_of(*nid);
7104            let label = view
7105                .label_of(*nid)
7106                .expect("real nodes always have a label; u32::MAX sentinel cannot occur");
7107            rs.push_row(vec![
7108                Some(Value::Str(k.to_string())),
7109                Some(Value::Str(label.to_string())),
7110                Some(Value::Int(*d as i64)),
7111            ]);
7112            visited.push((*nid, *d));
7113        }
7114        // Stub mode: add hidden direct neighbours of each visited node as stubs.
7115        // Hidden nodes are edge-endpoints only — they are not added to the BFS
7116        // frontier, so the BFS never expands through them.
7117        if mask.mode() == crate::mask::MaskMode::Stub {
7118            let raw_view = self.view();
7119            let mut seen: std::collections::HashSet<u32> =
7120                visited.iter().map(|(id, _)| *id).collect();
7121            for (node_id, node_depth) in &visited {
7122                if *node_depth >= depth {
7123                    continue;
7124                }
7125                for e in expand(&raw_view, *node_id, resolved.as_deref(), dir) {
7126                    let nbr = if e.src == *node_id { e.dst } else { e.src };
7127                    if !mask.contains_id(nbr) && seen.insert(nbr) {
7128                        if let Some(k) = self.ids.key_of(nbr) {
7129                            rs.push_row(vec![
7130                                Some(Value::Str(k.to_string())),
7131                                None,
7132                                Some(Value::Int((*node_depth + 1) as i64)),
7133                            ]);
7134                        }
7135                    }
7136                }
7137            }
7138        }
7139        Some(rs)
7140    }
7141
7142    /// Live node's key, label, and columnar props. Unknown or tombstoned → `None`.
7143    pub fn node_info(&self, key: &str) -> Option<NodeInfo> {
7144        let n = self.node_ref(key)?;
7145        Some(NodeInfo {
7146            key: n.key().to_string(),
7147            label: n.label().to_string(),
7148            props: n.props(),
7149        })
7150    }
7151
7152    /// Look up a node with mask awareness.
7153    ///
7154    /// | Key state         | Omit mode       | Stub mode              |
7155    /// |-------------------|-----------------|------------------------|
7156    /// | does not exist    | `None` (→ 404)  | `None` (→ 404)         |
7157    /// | exists, visible   | `Some(Visible)` | `Some(Visible)`        |
7158    /// | exists, hidden    | `None` (→ 404)  | `Some(Restricted)`     |
7159    ///
7160    /// **SECURITY**: only call from client-mask (full-token) paths.
7161    /// Role-token paths must use [`node_info`] after an explicit visibility check.
7162    pub fn node_info_masked(
7163        &self,
7164        key: &str,
7165        mask: &crate::mask::NodeMask,
7166    ) -> Option<MaskedNodeResult> {
7167        let id = self.ids.get(key)?;
7168        if mask.contains_id(id) {
7169            Some(MaskedNodeResult::Visible(self.node_info(key)?))
7170        } else {
7171            match mask.mode() {
7172                crate::mask::MaskMode::Stub => Some(MaskedNodeResult::Restricted),
7173                crate::mask::MaskMode::Omit => None,
7174            }
7175        }
7176    }
7177
7178    /// Get edges for `key` with mask-aware hidden-endpoint handling.
7179    ///
7180    /// - Omit mode: edges to hidden endpoints are excluded (same as role-path filtering).
7181    /// - Stub mode: edges to hidden endpoints are included; `src_restricted`/`dst_restricted`
7182    ///   is `true` for each hidden endpoint.
7183    ///
7184    /// Unknown key → [`GraphError::KeyNotFound`].
7185    ///
7186    /// **SECURITY**: only call from client-mask (full-token) paths.
7187    pub fn node_edges_masked(
7188        &self,
7189        key: &str,
7190        mask: &crate::mask::NodeMask,
7191    ) -> Result<Vec<MaskedEdge>> {
7192        self.ensure_v8_base_sections_loaded();
7193        let id = self
7194            .ids
7195            .get(key)
7196            .ok_or_else(|| GraphError::KeyNotFound { key: key.into() })?;
7197        let derived: BTreeSet<(u32, u32, u32)> = self
7198            .engine
7199            .provenance_touching(id)
7200            .map(|(_rule, etype, src, dst)| (etype, src, dst))
7201            .collect();
7202        let mut edges = Vec::new();
7203        let tv = self.topo_view();
7204        for etype in tv.etypes() {
7205            // etype comes from the archived CSR (access_unchecked, no eager CRC).
7206            // A bit-flip in the large TOPOLOGY section can produce an etype id
7207            // that is not in the interner.  Return Corrupt rather than panic.
7208            let edge_type = self
7209                .syms
7210                .resolve(etype)
7211                .ok_or_else(|| GraphError::Corrupt {
7212                    detail: format!("v8: topology etype {etype} not in interner"),
7213                })?
7214                .to_string();
7215            for dir in [Direction::Out, Direction::In] {
7216                for &nbr in tv.neighbors(etype, dir, id).as_ref() {
7217                    let nbr_restricted = !mask.contains_id(nbr);
7218                    if nbr_restricted && mask.mode() == crate::mask::MaskMode::Omit {
7219                        continue;
7220                    }
7221                    let nbr_key = self
7222                        .ids
7223                        .key_of(nbr)
7224                        .ok_or_else(|| GraphError::Corrupt {
7225                            detail: format!("topology id {nbr} has no key"),
7226                        })?
7227                        .to_string();
7228                    let (src_id, dst_id, src_key, dst_key, src_restricted, dst_restricted) =
7229                        match dir {
7230                            Direction::Out => {
7231                                (id, nbr, key.to_string(), nbr_key, false, nbr_restricted)
7232                            }
7233                            Direction::In => {
7234                                (nbr, id, nbr_key, key.to_string(), nbr_restricted, false)
7235                            }
7236                        };
7237                    edges.push(MaskedEdge {
7238                        edge_type: edge_type.clone(),
7239                        src_key,
7240                        src_restricted,
7241                        dst_key,
7242                        dst_restricted,
7243                        derived: derived.contains(&(etype, src_id, dst_id)),
7244                    });
7245                }
7246            }
7247        }
7248        edges.sort_by(|a, b| {
7249            a.edge_type
7250                .cmp(&b.edge_type)
7251                .then(a.src_key.cmp(&b.src_key))
7252                .then(a.dst_key.cmp(&b.dst_key))
7253        });
7254        edges.dedup_by(|a, b| {
7255            a.edge_type == b.edge_type && a.src_key == b.src_key && a.dst_key == b.dst_key
7256        });
7257        Ok(edges)
7258    }
7259
7260    /// Every directed edge incident on `key`, both directions, every etype.
7261    ///
7262    /// Walk is `topology.etypes()` × `{Out, In}` × `neighbors()`. `derived` is
7263    /// membership in [`RuleEngine::provenance_touching`] (O(degree) via the
7264    /// Plan-8 `by_node` index). Sorted by `(edge_type, src_key, dst_key)`.
7265    /// Unknown key → [`GraphError::KeyNotFound`].
7266    pub fn node_edges(&self, key: &str) -> Result<Vec<EdgeInfo>> {
7267        self.ensure_v8_base_sections_loaded();
7268        let id = self
7269            .ids
7270            .get(key)
7271            .ok_or_else(|| GraphError::KeyNotFound { key: key.into() })?;
7272        let derived: BTreeSet<(u32, u32, u32)> = self
7273            .engine
7274            .provenance_touching(id)
7275            .map(|(_rule, etype, src, dst)| (etype, src, dst))
7276            .collect();
7277        let mut edges = Vec::new();
7278        let tv = self.topo_view();
7279        for etype in tv.etypes() {
7280            // Same guard as node_edges_masked: etype from unchecked-CRC CSR.
7281            let edge_type = self
7282                .syms
7283                .resolve(etype)
7284                .ok_or_else(|| GraphError::Corrupt {
7285                    detail: format!("v8: topology etype {etype} not in interner"),
7286                })?
7287                .to_string();
7288            for dir in [Direction::Out, Direction::In] {
7289                for &nbr in tv.neighbors(etype, dir, id).as_ref() {
7290                    let (src, dst, src_key, dst_key) = match dir {
7291                        Direction::Out => (
7292                            id,
7293                            nbr,
7294                            key.to_string(),
7295                            self.ids
7296                                .key_of(nbr)
7297                                .ok_or_else(|| GraphError::Corrupt {
7298                                    detail: format!("topology id {nbr} has no key"),
7299                                })?
7300                                .to_string(),
7301                        ),
7302                        Direction::In => (
7303                            nbr,
7304                            id,
7305                            self.ids
7306                                .key_of(nbr)
7307                                .ok_or_else(|| GraphError::Corrupt {
7308                                    detail: format!("topology id {nbr} has no key"),
7309                                })?
7310                                .to_string(),
7311                            key.to_string(),
7312                        ),
7313                    };
7314                    edges.push(EdgeInfo {
7315                        edge_type: edge_type.clone(),
7316                        src_key,
7317                        dst_key,
7318                        derived: derived.contains(&(etype, src, dst)),
7319                    });
7320                }
7321            }
7322        }
7323        edges.sort_by(|a, b| {
7324            a.edge_type
7325                .cmp(&b.edge_type)
7326                .then(a.src_key.cmp(&b.src_key))
7327                .then(a.dst_key.cmp(&b.dst_key))
7328        });
7329        // Self-loops appear in both Out and In; sort makes the pair adjacent
7330        // (sort key matches PartialEq for this case) so one pass drops the dup.
7331        edges.dedup();
7332        Ok(edges)
7333    }
7334
7335    // ── Backup ────────────────────────────────────────────────────────────────
7336
7337    /// Copy this store to `dest` as a consistent, verified snapshot.
7338    ///
7339    /// Copies every durable file in the database directory — `snapshot.bin`,
7340    /// `wal.bin`, all `wal.<N>.archive` files, `wal.floor`, `wal.genesis`, and
7341    /// `roles.json` — into a freshly created `dest` directory using OS-level
7342    /// `copy` calls (no large in-process buffers).
7343    ///
7344    /// # Consistency guarantee
7345    ///
7346    /// The guarantee is **process-local**: the caller holds `&self`, which
7347    /// prevents any concurrent writer in the **same process** from modifying
7348    /// the files during the copy.  Running `mushroomdb backup` against a
7349    /// directory that is **concurrently being written by another process** (e.g.
7350    /// `mushroomdb serve`) is **unsafe** — the copy can be torn.  The post-copy
7351    /// `verified: true` result reduces but does not eliminate the risk of a
7352    /// silent corrupt backup (CRC catches many bit-flips; it cannot catch a
7353    /// consistent mid-write snapshot).
7354    ///
7355    /// **The safe path for a live-served store is `POST /backup` on the HTTP
7356    /// server.** That handler acquires the read lock on the shared database
7357    /// before calling this method, which is the correct cross-process
7358    /// synchronisation point because the server is the single process writing
7359    /// the files.
7360    ///
7361    /// After copying, opens the destination read-only and runs the CRC section
7362    /// verifier (`verify_snapshot`) to confirm byte-for-byte integrity.
7363    /// `BackupReport::verified` reflects whether both checks passed.
7364    ///
7365    /// Returns `Err` when `self` is not backed by a `RealFs` (e.g. `SimFs`).
7366    pub fn backup_to(&self, dest: &std::path::Path) -> Result<BackupReport> {
7367        // Derive source directory from snapshot_path (RealFs only).
7368        let src_dir = match self.fs.snapshot_path() {
7369            Some(p) => p.parent().map(|d| d.to_path_buf()).ok_or_else(|| {
7370                GraphError::Io(std::io::Error::other("snapshot has no parent dir"))
7371            })?,
7372            None => {
7373                return Err(GraphError::Io(std::io::Error::other(
7374                    "backup_to requires a real filesystem (RealFs)",
7375                )))
7376            }
7377        };
7378
7379        std::fs::create_dir_all(dest)?;
7380
7381        let mut files: Vec<String> = Vec::new();
7382        let mut bytes: u64 = 0;
7383
7384        // Helper: copy src_dir/name → dest/name if the file exists.
7385        let mut try_copy = |name: &str| -> std::io::Result<()> {
7386            let src_path = src_dir.join(name);
7387            if src_path.exists() {
7388                let n = std::fs::copy(&src_path, dest.join(name))?;
7389                bytes += n;
7390                files.push(name.to_string());
7391            }
7392            Ok(())
7393        };
7394
7395        try_copy("snapshot.bin")?;
7396        try_copy("snapshot.bin.bak")?;
7397        try_copy("wal.bin")?;
7398        try_copy("wal.floor")?;
7399        try_copy("wal.genesis")?;
7400        try_copy("roles.json")?;
7401
7402        // Copy WAL archives.
7403        let archives = self.fs.list_archives()?;
7404        for n in &archives {
7405            let name = format!("wal.{n}.archive");
7406            let n_bytes = std::fs::copy(src_dir.join(&name), dest.join(&name))?;
7407            bytes += n_bytes;
7408            files.push(name);
7409        }
7410
7411        files.sort();
7412
7413        // Post-copy verification: open dest and run CRC checks.
7414        let snap_in_dest = dest.join("snapshot.bin").exists();
7415        let crc_ok = if snap_in_dest {
7416            crate::verify_snapshot(dest)
7417                .map(|results| results.iter().all(|(_, _, _, r)| r.is_ok()))
7418                .unwrap_or(false)
7419        } else {
7420            true // WAL-only store: nothing to CRC-check in snapshot
7421        };
7422        let opens_ok = GraphDb::<core_storage::fs::RealFs>::open(dest).is_ok();
7423        let verified = crc_ok && opens_ok;
7424
7425        Ok(BackupReport {
7426            files,
7427            bytes,
7428            verified,
7429        })
7430    }
7431
7432    // ── Export helpers ────────────────────────────────────────────────────────
7433
7434    /// All live nodes, sorted by key (deterministic).
7435    ///
7436    /// Reads base + WAL overlay. Tombstoned nodes are excluded.
7437    pub fn all_nodes_for_export(&self) -> Vec<NodeInfo> {
7438        self.ensure_v8_base_sections_loaded();
7439        let pv = self.props_view();
7440        let mut nodes = Vec::new();
7441        for id in 0..self.ids.len() as u32 {
7442            let Some(key) = self.ids.key_of(id) else {
7443                continue;
7444            };
7445            let Some(&sym) = self.labels.get(id as usize) else {
7446                continue;
7447            };
7448            if sym == u32::MAX {
7449                continue; // tombstoned
7450            }
7451            let Some(label) = self.syms.resolve(sym) else {
7452                continue;
7453            };
7454            let mut props = BTreeMap::new();
7455            for field in pv.field_names() {
7456                if let Some(vr) = pv.get(id, &field) {
7457                    props.insert(field, vr.into_value());
7458                }
7459            }
7460            nodes.push(NodeInfo {
7461                key: key.to_string(),
7462                label: label.to_string(),
7463                props,
7464            });
7465        }
7466        nodes.sort_by(|a, b| a.key.cmp(&b.key));
7467        nodes
7468    }
7469
7470    /// All directed edges, sorted by `(edge_type, src, dst)`. Each edge appears once.
7471    ///
7472    /// Derived edges carry `derived: true` and the creating rule's name in `rule`.
7473    /// Manual edges carry `derived: false` and `rule: None`.
7474    /// `weight` is the creating rule's `weight_prop` value read off the edge
7475    /// (numeric only), mirroring the convention used by [`GraphDb::explain`]
7476    /// and [`GraphDb::weighted_edges`]. Deterministic across runs on the same
7477    /// store state.
7478    pub fn all_edges_for_export(&self) -> Vec<ExportEdge> {
7479        self.ensure_v8_base_sections_loaded();
7480
7481        // Build (etype_sym, src_id, dst_id) → rule_name for O(1) derivation lookup.
7482        let mut prov: HashMap<(u32, u32, u32), String> = HashMap::new();
7483        for (rule_name, triples) in self.engine.provenance() {
7484            for &(etype, src, dst) in triples {
7485                prov.insert((etype, src, dst), rule_name.clone());
7486            }
7487        }
7488
7489        // rule_name → weight_prop, for O(1) lookup per derived edge.
7490        let weight_props: HashMap<&str, Option<&str>> = self
7491            .engine
7492            .rules()
7493            .map(|r| (r.name.as_str(), r.weight_prop.as_deref()))
7494            .collect();
7495
7496        let tv = self.topo_view();
7497        let ep = self.edge_props_view();
7498        let mut edges = Vec::new();
7499
7500        for id in 0..self.ids.len() as u32 {
7501            let Some(key) = self.ids.key_of(id) else {
7502                continue;
7503            };
7504            let Some(&lsym) = self.labels.get(id as usize) else {
7505                continue;
7506            };
7507            if lsym == u32::MAX {
7508                continue; // tombstoned
7509            }
7510
7511            for etype_sym in tv.etypes() {
7512                // etype from archived CSR (access_unchecked, no eager CRC).
7513                // Skip edges whose etype is not in the interner; this can only
7514                // occur with a corrupt large TOPOLOGY section (bit-flip on an
7515                // etype field in the archived data).  The function returns Vec,
7516                // not Result, so we continue rather than propagate.
7517                let Some(edge_type) = self.syms.resolve(etype_sym) else {
7518                    continue;
7519                };
7520                let edge_type = edge_type.to_string();
7521                for &nbr in tv.neighbors(etype_sym, Direction::Out, id).as_ref() {
7522                    let Some(dst_key) = self.ids.key_of(nbr) else {
7523                        continue; // skip corrupt entries
7524                    };
7525                    let prov_key = (etype_sym, id, nbr);
7526                    let rule = prov.get(&prov_key).cloned();
7527                    let derived = rule.is_some();
7528                    let weight = rule
7529                        .as_deref()
7530                        .and_then(|rn| weight_props.get(rn).copied().flatten())
7531                        .and_then(|prop| match ep.get(etype_sym, id, nbr, prop) {
7532                            Some(Value::Float(f)) => Some(f),
7533                            Some(Value::Int(i)) => Some(i as f64),
7534                            _ => None,
7535                        });
7536                    edges.push(ExportEdge {
7537                        edge_type: edge_type.clone(),
7538                        src: key.to_string(),
7539                        dst: dst_key.to_string(),
7540                        derived,
7541                        rule,
7542                        weight,
7543                    });
7544                }
7545            }
7546        }
7547
7548        edges.sort_by(|a, b| {
7549            a.edge_type
7550                .cmp(&b.edge_type)
7551                .then(a.src.cmp(&b.src))
7552                .then(a.dst.cmp(&b.dst))
7553        });
7554        edges
7555    }
7556
7557    /// What each edge type *is*, without building one record per edge.
7558    ///
7559    /// [`all_edges_for_export`](Self::all_edges_for_export) answers the same
7560    /// question by materialising every edge — three `String`s apiece, a
7561    /// provenance `HashMap` over every derived edge, and a final sort. That is
7562    /// the right shape for an export, and the wrong one for a summary: on a
7563    /// store with 1.3 M derived edges it allocates hundreds of megabytes to
7564    /// produce nine lines. This walks the topology instead, summing neighbour
7565    /// slice lengths and collecting *label symbols* rather than label strings,
7566    /// so the per-edge cost is an integer add and a set insert on a set with
7567    /// as many members as the store has labels.
7568    ///
7569    /// The rule names come off the rule *definitions*, which each declare the
7570    /// `edge_type` they derive, so naming them costs one pass over the rules
7571    /// rather than one provenance lookup per edge. That is also why `rules`
7572    /// is a list: two rules may derive the same type — the association store
7573    /// derives `INDUSTRY_ALIGNMENT` from both a talent→company and a
7574    /// talent→job rule — and naming only one of them would be a half-truth.
7575    /// A type with no rules is one written by hand.
7576    ///
7577    /// `sample` is the first edge of the type in the store's own id order,
7578    /// which is insertion order: deterministic for a given store, and not the
7579    /// same as key order, which cannot be had without resolving a key per
7580    /// edge. Sorted by `edge_type`.
7581    pub fn edge_type_census(&self) -> Vec<EdgeTypeCensus> {
7582        self.ensure_v8_base_sections_loaded();
7583
7584        let mut rules_by_type: BTreeMap<&str, BTreeSet<&str>> = BTreeMap::new();
7585        for r in self.engine.rules() {
7586            rules_by_type
7587                .entry(r.edge_type.as_str())
7588                .or_default()
7589                .insert(r.name.as_str());
7590        }
7591
7592        let tv = self.topo_view();
7593        let node_count = self.ids.len() as u32;
7594        let mut out = Vec::new();
7595        for etype_sym in tv.etypes() {
7596            // An etype the interner cannot resolve means a corrupt TOPOLOGY
7597            // section; skip it rather than name it, as `all_edges_for_export`
7598            // does for the same reason.
7599            let Some(edge_type) = self.syms.resolve(etype_sym) else {
7600                continue;
7601            };
7602            let mut edges: u64 = 0;
7603            let mut src_syms: BTreeSet<u32> = BTreeSet::new();
7604            let mut dst_syms: BTreeSet<u32> = BTreeSet::new();
7605            let mut sample: Option<(u32, u32)> = None;
7606            for id in 0..node_count {
7607                let Some(&lsym) = self.labels.get(id as usize) else {
7608                    continue;
7609                };
7610                if lsym == u32::MAX {
7611                    continue; // tombstoned
7612                }
7613                let nbrs = tv.neighbors(etype_sym, Direction::Out, id);
7614                let nbrs = nbrs.as_ref();
7615                if nbrs.is_empty() {
7616                    continue;
7617                }
7618                edges += nbrs.len() as u64;
7619                src_syms.insert(lsym);
7620                for &nbr in nbrs {
7621                    if let Some(&dsym) = self.labels.get(nbr as usize) {
7622                        if dsym != u32::MAX {
7623                            dst_syms.insert(dsym);
7624                        }
7625                    }
7626                }
7627                if sample.is_none() {
7628                    sample = Some((id, nbrs[0]));
7629                }
7630            }
7631            let resolve = |syms: &BTreeSet<u32>| -> Vec<String> {
7632                syms.iter()
7633                    .filter_map(|&s| self.syms.resolve(s))
7634                    .map(ToString::to_string)
7635                    .collect()
7636            };
7637            out.push(EdgeTypeCensus {
7638                edge_type: edge_type.to_string(),
7639                edges,
7640                src_labels: resolve(&src_syms),
7641                dst_labels: resolve(&dst_syms),
7642                rules: rules_by_type
7643                    .get(edge_type)
7644                    .map(|rs| rs.iter().map(ToString::to_string).collect())
7645                    .unwrap_or_default(),
7646                sample: sample.and_then(|(s, d)| {
7647                    Some((
7648                        self.ids.key_of(s)?.to_string(),
7649                        self.ids.key_of(d)?.to_string(),
7650                    ))
7651                }),
7652            });
7653        }
7654        out.sort_by(|a, b| a.edge_type.cmp(&b.edge_type));
7655        out
7656    }
7657
7658    /// All directed edges of `edge_type`, with the raw value of `weight_prop`
7659    /// on each edge when given.
7660    ///
7661    /// `weight` is `Some(f)` only when `weight_prop` is set and the edge
7662    /// carries that property with a numeric (`Int`/`Float`) value; otherwise
7663    /// `None` — callers that want a default weight (e.g. `1.0` for missing
7664    /// props) apply it themselves, matching the convention used internally
7665    /// by [`GraphDb::pagerank`], [`GraphDb::connected_components`],
7666    /// [`GraphDb::degree_centrality`], and [`GraphDb::communities`].
7667    ///
7668    /// Sorted by `(src, dst)` for determinism. Reads the unified topology
7669    /// (manual + rule-derived edges).  An unknown `edge_type` returns an
7670    /// empty vec.
7671    pub fn weighted_edges(
7672        &self,
7673        edge_type: &str,
7674        weight_prop: Option<&str>,
7675    ) -> Vec<(String, String, Option<f64>)> {
7676        let Some(etype_sym) = self.syms.get(edge_type) else {
7677            return Vec::new();
7678        };
7679        let tv = self.topo_view();
7680        let ep = self.edge_props_view();
7681        let mut out = Vec::new();
7682        for id in 0..self.ids.len() as u32 {
7683            let Some(key) = self.ids.key_of(id) else {
7684                continue;
7685            };
7686            let Some(&sym) = self.labels.get(id as usize) else {
7687                continue;
7688            };
7689            if sym == u32::MAX {
7690                continue; // tombstoned
7691            }
7692            for &nbr in tv.neighbors(etype_sym, Direction::Out, id).as_ref() {
7693                let Some(dst_key) = self.ids.key_of(nbr) else {
7694                    continue;
7695                };
7696                let weight = weight_prop.and_then(|prop| match ep.get(etype_sym, id, nbr, prop) {
7697                    Some(Value::Float(f)) => Some(f),
7698                    Some(Value::Int(i)) => Some(i as f64),
7699                    _ => None,
7700                });
7701                out.push((key.to_string(), dst_key.to_string(), weight));
7702            }
7703        }
7704        out.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.cmp(&b.1)));
7705        out
7706    }
7707
7708    pub fn nodes_with_label(&self, label: &str) -> Vec<NodeRef<'_, F>> {
7709        self.view()
7710            .nodes_with_label(label)
7711            .into_iter()
7712            .map(|id| NodeRef { db: self, id })
7713            .collect()
7714    }
7715
7716    pub fn find_nodes(&self, label: &str, filter: &Filter) -> Vec<NodeRef<'_, F>> {
7717        let view = self.view();
7718        view.nodes_with_label(label)
7719            .into_iter()
7720            .filter(|&id| {
7721                eval_filter(filter, &|field| {
7722                    view.prop(id, field).map(|vr| vr.into_value())
7723                })
7724            })
7725            .map(|id| NodeRef { db: self, id })
7726            .collect()
7727    }
7728
7729    /// Returns `true` if any approximate (HNSW) VectorSimilar rule covers
7730    /// `field`.  Use as a capability probe: when `true`, `find_similar_vector`
7731    /// with `label = None` will use the native ANN path rather than the O(n)
7732    /// brute-force scan.
7733    pub fn has_vector_rule(&self, field: &str) -> bool {
7734        self.engine.hnsw_has_rule(field)
7735    }
7736
7737    /// How many HNSW graphs this handle has built from scratch since it was
7738    /// opened (one per side of an approximate rule).
7739    ///
7740    /// An open that restored every graph from the snapshot reports `0`.
7741    /// Exposed for tests that assert the open path reuses the persisted index
7742    /// rather than rebuilding it; not part of the stable surface.
7743    #[doc(hidden)]
7744    pub fn hnsw_build_count(&self) -> u64 {
7745        self.engine.hnsw_build_count()
7746    }
7747
7748    /// Rules whose vector graphs the clean-open read path still holds a second
7749    /// copy of. Zero before the first ANN query and again after the first
7750    /// write. Exposed for tests; not part of the stable surface.
7751    #[doc(hidden)]
7752    pub fn lazy_hnsw_len(&self) -> usize {
7753        self.engine.lazy_hnsw_len()
7754    }
7755
7756    /// Find nodes whose `field` vector is most similar to `q` (cosine
7757    /// similarity), returning up to `k` results with similarity ≥ `min`,
7758    /// sorted descending.
7759    ///
7760    /// When `label` is `None` the search spans all labels (via
7761    /// `hnsw_search_any_dst` or a full brute-force scan); when `label` is
7762    /// `Some(lbl)` it restricts to nodes with that label.
7763    ///
7764    /// Uses the HNSW index when one is available (fast path); otherwise falls
7765    /// back to an O(n) brute-force scan.
7766    pub fn find_similar_vector(
7767        &self,
7768        field: &str,
7769        label: Option<&str>,
7770        q: &[f64],
7771        k: usize,
7772        min: f64,
7773    ) -> Vec<(String, f64)> {
7774        // Ensure any HNSW blobs retained from the snapshot are deserialized
7775        // before the first ANN query on a clean-open (no-WAL) path.  The
7776        // section read has to come first: on a clean open nothing else has
7777        // called it, so without it `retained_hnsw_blobs` is empty,
7778        // `ensure_hnsw_loaded` caches an empty map in its `OnceLock`, and every
7779        // approximate query on the handle runs brute force — correct results,
7780        // silently off the index.  Both calls are idempotent and cheap once hot.
7781        self.ensure_v8_base_sections_loaded();
7782        self.engine.ensure_hnsw_loaded();
7783        // L2-normalise query for cosine via dot product.
7784        let norm: f64 = q.iter().map(|x| x * x).sum::<f64>().sqrt();
7785        if norm == 0.0 {
7786            return vec![];
7787        }
7788        let q_unit: Vec<f64> = q.iter().map(|x| x / norm).collect();
7789
7790        // Try HNSW fast path.
7791        // `None` label searches across all VectorSimilar rules covering `field`
7792        // (merging their results); `Some(lbl)` restricts to rules whose
7793        // dst_label matches.  Returns `None` when no populated HNSW index
7794        // covers the request — the O(n) brute-force fallback handles that case.
7795        let hnsw_hits = match label {
7796            Some(lbl) => self.engine.hnsw_search_dst(field, lbl, &q_unit, k),
7797            None => self.engine.hnsw_search_any_dst(field, &q_unit, k),
7798        };
7799        if let Some(hits) = hnsw_hits {
7800            let mut out: Vec<(String, f64)> = hits
7801                .into_iter()
7802                .filter(|&(_, sim)| sim >= min)
7803                .filter_map(|(id, sim)| self.ids.key_of(id).map(|key| (key.to_string(), sim)))
7804                .collect();
7805            out.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
7806            out.truncate(k);
7807            return out;
7808        }
7809
7810        // Brute-force fallback: O(n) scan (only reached when no HNSW index
7811        // covers the request).
7812        let view = self.view();
7813        let candidate_ids: Vec<u32> = match label {
7814            Some(lbl) => view.nodes_with_label(lbl),
7815            None => view.nodes_all(),
7816        };
7817        let mut scored: Vec<(String, f64)> = candidate_ids
7818            .into_iter()
7819            .filter_map(|id| {
7820                let v = view.prop(id, field)?;
7821                let v_owned = v.into_value();
7822                let xs = value_as_float_list(&v_owned)?;
7823                let v_norm: f64 = xs.iter().map(|x| x * x).sum::<f64>().sqrt();
7824                if v_norm == 0.0 {
7825                    return None;
7826                }
7827                let dot: f64 = q_unit
7828                    .iter()
7829                    .zip(xs.iter())
7830                    .map(|(a, b)| a * (b / v_norm))
7831                    .sum();
7832                if dot < min {
7833                    return None;
7834                }
7835                let key = self.ids.key_of(id)?.to_string();
7836                Some((key, dot))
7837            })
7838            .collect();
7839        scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
7840        scored.truncate(k);
7841        scored
7842    }
7843
7844    /// Like [`find_similar_vector`] but restricts results to nodes visible in
7845    /// `mask`. Hidden nodes never appear in results; the mask is applied
7846    /// **before** k-truncation so a caller still receives up to `k` visible
7847    /// hits.
7848    ///
7849    /// # HNSW path (over-fetch policy)
7850    ///
7851    /// When an HNSW index covers the request, this function fetches `4 * k`
7852    /// candidates from the index and discards hidden nodes in the post-filter
7853    /// step.  If fewer than `k` visible nodes remain after filtering the caller
7854    /// receives whatever is available — we do not re-query the index.  The 4×
7855    /// multiplier is a heuristic suited for sparsely masked graphs; callers
7856    /// operating under a very selective mask should register a VectorSimilar
7857    /// rule with a non-approximate index, or use the brute-force path (no HNSW
7858    /// rule) which exhaustively filters through the masked [`GraphView`].
7859    ///
7860    /// # Brute-force path
7861    ///
7862    /// When no HNSW index covers the request the function builds a masked
7863    /// [`GraphView`] so that `nodes_all` / `nodes_with_label` return only
7864    /// visible nodes, guaranteeing exact `k` results (or all visible nodes if
7865    /// fewer than `k` exist).
7866    pub fn find_similar_vector_masked(
7867        &self,
7868        field: &str,
7869        label: Option<&str>,
7870        q: &[f64],
7871        k: usize,
7872        min: f64,
7873        mask: &crate::mask::NodeMask,
7874    ) -> Vec<(String, f64)> {
7875        // Section read before the blob decode — see `find_similar_vector`.
7876        self.ensure_v8_base_sections_loaded();
7877        self.engine.ensure_hnsw_loaded();
7878        let norm: f64 = q.iter().map(|x| x * x).sum::<f64>().sqrt();
7879        if norm == 0.0 {
7880            return vec![];
7881        }
7882        let q_unit: Vec<f64> = q.iter().map(|x| x / norm).collect();
7883
7884        // HNSW fast path — over-fetch 4×k so post-masking still yields up to k
7885        // visible hits.  See doc comment above for the policy rationale.
7886        let over_k = k.saturating_mul(4).max(k + 1);
7887        let hnsw_hits = match label {
7888            Some(lbl) => self.engine.hnsw_search_dst(field, lbl, &q_unit, over_k),
7889            None => self.engine.hnsw_search_any_dst(field, &q_unit, over_k),
7890        };
7891        if let Some(hits) = hnsw_hits {
7892            let mut out: Vec<(String, f64)> = hits
7893                .into_iter()
7894                .filter(|&(id, sim)| sim >= min && mask.visible.contains(&id))
7895                .filter_map(|(id, sim)| self.ids.key_of(id).map(|key| (key.to_string(), sim)))
7896                .collect();
7897            out.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
7898            out.truncate(k);
7899            return out;
7900        }
7901
7902        // Brute-force fallback — masked view ensures only visible nodes are
7903        // enumerated by nodes_all(); nodes_with_label() does not filter by
7904        // mask so we apply view.visible() explicitly for the labeled case.
7905        let view = self.view_masked(mask);
7906        let candidate_ids: Vec<u32> = match label {
7907            Some(lbl) => view
7908                .nodes_with_label(lbl)
7909                .into_iter()
7910                .filter(|&id| view.visible(id))
7911                .collect(),
7912            None => view.nodes_all(),
7913        };
7914        let mut scored: Vec<(String, f64)> = candidate_ids
7915            .into_iter()
7916            .filter_map(|id| {
7917                let v = view.prop(id, field)?;
7918                let v_owned = v.into_value();
7919                let xs = value_as_float_list(&v_owned)?;
7920                let v_norm: f64 = xs.iter().map(|x| x * x).sum::<f64>().sqrt();
7921                if v_norm == 0.0 {
7922                    return None;
7923                }
7924                let dot: f64 = q_unit
7925                    .iter()
7926                    .zip(xs.iter())
7927                    .map(|(a, b)| a * (b / v_norm))
7928                    .sum();
7929                if dot < min {
7930                    return None;
7931                }
7932                let key = self.ids.key_of(id)?.to_string();
7933                Some((key, dot))
7934            })
7935            .collect();
7936        scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
7937        scored.truncate(k);
7938        scored
7939    }
7940
7941    /// Read a single property from an edge.
7942    ///
7943    /// Returns `None` when the edge does not exist, the field is absent, or any
7944    /// of the string keys cannot be resolved to interned ids.  Only edge props
7945    /// written by rules (weight fields) are accessible without a `set_edge_prop`
7946    /// binding; topology-only edges (no props set) return `None` for every field.
7947    pub fn get_edge_prop(
7948        &self,
7949        edge_type: &str,
7950        src_key: &str,
7951        dst_key: &str,
7952        field: &str,
7953    ) -> Option<Value> {
7954        let etype = self.syms.get(edge_type)?;
7955        let src = self.ids.get(src_key)?;
7956        let dst = self.ids.get(dst_key)?;
7957        self.edge_props_view().get(etype, src, dst, field)
7958    }
7959
7960    /// Lex → parse → plan → execute `cypher` over a read-only view.
7961    /// Every pipeline `Err(String)` becomes `GraphError::QueryError` with a
7962    /// stage prefix (`lex:` / `parse:` / `plan:` / `execute:`).
7963    pub fn query(&self, cypher: &str, params: &BTreeMap<String, Value>) -> Result<ResultSet> {
7964        let tokens = lex(cypher).map_err(|e| GraphError::QueryError {
7965            detail: format!("lex: {e}"),
7966        })?;
7967        let union = parse_read(&tokens).map_err(|e| GraphError::QueryError {
7968            detail: format!("parse: {e}"),
7969        })?;
7970        let t0 = std::time::Instant::now();
7971        let result = execute_union(&self.view(), &union, &Params(params)).map_err(|e| {
7972            GraphError::QueryError {
7973                detail: format!("execute: {e}"),
7974            }
7975        });
7976        let elapsed_ms = t0.elapsed().as_millis() as u64;
7977        let threshold = self.slow_query_threshold_ms;
7978        if threshold > 0 && elapsed_ms >= threshold {
7979            eprintln!("[mushroomdb] slow query ({elapsed_ms}ms): {cypher}");
7980            let entry = SlowQueryEntry {
7981                ms: elapsed_ms,
7982                query: cypher.to_string(),
7983                at_commit: self.commit_seq,
7984            };
7985            if let Ok(mut log) = self.slow_queries.lock() {
7986                if log.entries.len() == SLOW_QUERY_RING_CAP {
7987                    log.entries.pop_front();
7988                }
7989                log.entries.push_back(entry);
7990                log.total += 1;
7991            }
7992        }
7993        result
7994    }
7995
7996    /// Convenience entry-point that accepts a slice of `(name, value)` pairs
7997    /// instead of a pre-built `BTreeMap`.  Equivalent to building the map and
7998    /// calling [`GraphDb::query`].
7999    pub fn query_with_params(&self, cypher: &str, params: &[(&str, Value)]) -> Result<ResultSet> {
8000        let map: BTreeMap<String, Value> = params
8001            .iter()
8002            .map(|(k, v)| (k.to_string(), v.clone()))
8003            .collect();
8004        self.query(cypher, &map)
8005    }
8006
8007    /// Execute a Cypher write statement (CREATE / MATCH…SET / MATCH…DELETE / MERGE).
8008    ///
8009    /// All mutations flow through the same `insert_node` / `set_prop` /
8010    /// `delete_edge` / `insert_edge` path as the Rust API so the rule engine
8011    /// fires and the WAL captures everything with one fsync per statement.
8012    ///
8013    /// Returns a one-row [`ResultSet`] with columns `created`, `properties_set`,
8014    /// and `deleted` matching the write-result contract.
8015    ///
8016    /// **Mutation routing**: mutations are collected into a single
8017    /// [`BatchBuilder`] and committed atomically (one WAL `Batch` frame, one
8018    /// fsync). The MATCH phase for SET/DELETE uses a read-only `execute` call
8019    /// over `self.view()` — the borrow is dropped before the batch is opened.
8020    ///
8021    /// **Limitations (v1)**:
8022    /// - SET RHS must be a literal, `$param`, or arithmetic; bare property copy → named error.
8023    /// - `DETACH DELETE n` → calls `delete_node` for each matched node (removes all edges).
8024    /// - Bare `DELETE n` → error if n has any incident edges; succeeds for isolated nodes.
8025    /// - MERGE supports `ON CREATE SET` / `ON MATCH SET` in the same write batch.
8026    /// - Deleting a derived edge → named error "cannot delete derived edge".
8027    pub fn query_write(
8028        &mut self,
8029        cypher: &str,
8030        params: &BTreeMap<String, Value>,
8031    ) -> Result<ResultSet> {
8032        let tokens = lex(cypher).map_err(|e| GraphError::QueryError {
8033            detail: format!("lex: {e}"),
8034        })?;
8035        let stmt = parse_write(&tokens).map_err(|e| GraphError::QueryError {
8036            detail: format!("parse: {e}"),
8037        })?;
8038        self.exec_write_stmt(stmt, params)
8039    }
8040
8041    fn exec_write_stmt(
8042        &mut self,
8043        stmt: WriteStatement,
8044        params: &BTreeMap<String, Value>,
8045    ) -> Result<ResultSet> {
8046        match stmt {
8047            WriteStatement::Create(s) => self.exec_create(s, params),
8048            WriteStatement::MatchSet(s) => self.exec_match_set(s, params),
8049            WriteStatement::MatchDelete(s) => self.exec_match_delete(s, params),
8050            WriteStatement::MatchDeleteNode(s) => self.exec_match_delete_node(s, params),
8051            WriteStatement::Merge(s) => self.exec_merge(s, params),
8052        }
8053    }
8054
8055    fn exec_create(
8056        &mut self,
8057        stmt: core_query::cypher::CreateStmt,
8058        params: &BTreeMap<String, Value>,
8059    ) -> Result<ResultSet> {
8060        // Extract the node key from props: require a string-valued `id` field.
8061        let mut var_to_key: BTreeMap<String, String> = BTreeMap::new();
8062        for node in &stmt.nodes {
8063            let var = node.var.as_deref().unwrap_or("_cn0");
8064            let key = node
8065                .props
8066                .iter()
8067                .find(|(f, _)| f == "id")
8068                .and_then(|(_, v)| {
8069                    if let Value::Str(s) = v {
8070                        Some(s.clone())
8071                    } else {
8072                        None
8073                    }
8074                })
8075                .ok_or_else(|| GraphError::QueryError {
8076                    detail: format!(
8077                        "CREATE node ({}:{}) requires a string 'id' property",
8078                        var, node.label
8079                    ),
8080                })?;
8081            var_to_key.insert(var.to_string(), key);
8082        }
8083
8084        let mut batch = self.batch();
8085        let mut created: usize = 0;
8086        for node in &stmt.nodes {
8087            let var = node.var.as_deref().unwrap_or("_cn0");
8088            let key = &var_to_key[var];
8089            batch.insert_node(&node.label, key, node.props.clone());
8090            created += 1;
8091        }
8092        for edge in &stmt.edges {
8093            let src_key = var_to_key
8094                .get(&edge.src_var)
8095                .ok_or_else(|| GraphError::QueryError {
8096                    detail: format!("CREATE edge src variable '{}' is not bound", edge.src_var),
8097                })?;
8098            let dst_key = var_to_key
8099                .get(&edge.dst_var)
8100                .ok_or_else(|| GraphError::QueryError {
8101                    detail: format!("CREATE edge dst variable '{}' is not bound", edge.dst_var),
8102                })?;
8103            batch.insert_edge(&edge.etype, src_key, dst_key);
8104        }
8105        batch.commit()?;
8106
8107        // Optional RETURN clause: project created bindings as a read result.
8108        if let Some(returns) = stmt.returns {
8109            // Each created node is looked up by its key via a separate MATCH pattern.
8110            // Multiple single-node patterns cross-join to produce 1 output row with
8111            // all variables bound (each pattern returns exactly 1 row).
8112            let patterns: Vec<Pattern> = stmt
8113                .nodes
8114                .iter()
8115                .map(|node| {
8116                    let var = node.var.as_deref().unwrap_or("_cn0");
8117                    let key = var_to_key[var].clone();
8118                    Pattern {
8119                        start: NodePat {
8120                            var: Some(var.to_string()),
8121                            label: Some(node.label.clone()),
8122                            props: vec![("id".to_string(), Operand::Lit(Value::Str(key)))],
8123                        },
8124                        chain: vec![],
8125                        shortest: false,
8126                    }
8127                })
8128                .collect();
8129            let q = Query {
8130                matches: patterns,
8131                optional_clauses: vec![],
8132                where_expr: None,
8133                unwinds: vec![],
8134                post_unwind_where: None,
8135                stages: vec![],
8136                returns,
8137                distinct: false,
8138                order_by: vec![],
8139                skip: None,
8140                limit: None,
8141            };
8142            let ops = plan(&q).map_err(|e| GraphError::QueryError {
8143                detail: format!("plan: {e}"),
8144            })?;
8145            return execute(&self.view(), &ops, &Params(params)).map_err(|e| {
8146                GraphError::QueryError {
8147                    detail: format!("execute: {e}"),
8148                }
8149            });
8150        }
8151
8152        let mut rs = write_result_set();
8153        rs.push_row(vec![
8154            Some(Value::Int(created as i64)),
8155            Some(Value::Int(0)),
8156            Some(Value::Int(0)),
8157        ]);
8158        Ok(rs)
8159    }
8160
8161    fn exec_match_set(
8162        &mut self,
8163        stmt: core_query::cypher::MatchSetStmt,
8164        params: &BTreeMap<String, Value>,
8165    ) -> Result<ResultSet> {
8166        let project_returns = stmt.returns.clone();
8167        // Collect unique node vars targeted by SET clauses, plus RETURN bindings
8168        // so the post-write projection can look them up by key.
8169        let mut set_vars: Vec<String> = Vec::new();
8170        for s in &stmt.sets {
8171            if !set_vars.contains(&s.var) {
8172                set_vars.push(s.var.clone());
8173            }
8174        }
8175        let rel_vars = pattern_rel_vars(&stmt.matches);
8176        let mut lookup_vars = set_vars.clone();
8177        for v in pattern_node_vars(&stmt.matches) {
8178            add_var(&mut lookup_vars, &v);
8179        }
8180        if let Some(ref returns) = project_returns {
8181            for v in ret_node_vars(returns) {
8182                if !rel_vars.iter().any(|r| r == &v) {
8183                    add_var(&mut lookup_vars, &v);
8184                }
8185            }
8186        }
8187
8188        // Synthesize a read query: MATCH … WHERE … RETURN <lookup_vars>, <set_values…>
8189        // SET values are projected as ScalarExpr items so that arithmetic expressions
8190        // (e.g. `SET n.score = n.score * 1.5`) are evaluated in the matched-row context.
8191        let mut set_returns: Vec<RetItem> = lookup_vars
8192            .iter()
8193            .map(|v| RetItem {
8194                value: RetVal::Var(v.clone()),
8195                alias: None,
8196            })
8197            .collect();
8198        // One computed column per SET clause; alias is `__sv_<i>`.
8199        let set_val_cols: Vec<String> = stmt
8200            .sets
8201            .iter()
8202            .enumerate()
8203            .map(|(i, _)| format!("__sv_{i}"))
8204            .collect();
8205        for (sc, col) in stmt.sets.iter().zip(&set_val_cols) {
8206            set_returns.push(RetItem {
8207                value: RetVal::ScalarExpr(sc.value.clone()),
8208                alias: Some(col.clone()),
8209            });
8210        }
8211        // Capture relationship types while r is bound; SET does not change them.
8212        for r in &rel_vars {
8213            set_returns.push(RetItem {
8214                value: RetVal::FuncCall {
8215                    name: "type".into(),
8216                    args: vec![Operand::Var(r.clone())],
8217                },
8218                alias: Some(rel_type_alias(r)),
8219            });
8220        }
8221
8222        let read_q = Query {
8223            matches: stmt.matches.clone(),
8224            optional_clauses: vec![],
8225            where_expr: stmt.where_expr.clone(),
8226            unwinds: vec![],
8227            post_unwind_where: None,
8228            stages: vec![],
8229            returns: set_returns,
8230            distinct: false,
8231            order_by: vec![],
8232            skip: None,
8233            limit: None,
8234        };
8235        let ops = plan(&read_q).map_err(|e| GraphError::QueryError {
8236            detail: format!("plan: {e}"),
8237        })?;
8238        // MATCH phase is read-only; borrow ends before batch opens.
8239        //
8240        // When a role-scoped write is in flight, run the MATCH read through
8241        // view_masked so hidden nodes are invisible → hidden ≡ absent ≡
8242        // zero-rows (no SetProp ops generated, no existence-oracle 403).
8243        // Full-authority writes (pending_write_authz=None) keep view().
8244        let match_rs = {
8245            let mask_opt = self.pending_write_authz.as_ref().map(|a| a.mask.clone());
8246            if let Some(ref mask) = mask_opt {
8247                execute(&self.view_masked(mask), &ops, &Params(params))
8248            } else {
8249                execute(&self.view(), &ops, &Params(params))
8250            }
8251        }
8252        .map_err(|e| GraphError::QueryError {
8253            detail: format!("execute: {e}"),
8254        })?;
8255
8256        // Collect (key, field, value) for each matched row × each SET clause.
8257        let mut set_ops: Vec<(String, String, Value)> = Vec::new();
8258        for row_i in 0..match_rs.len() {
8259            for (sc, col) in stmt.sets.iter().zip(&set_val_cols) {
8260                let key = match match_rs.get(row_i, &sc.var) {
8261                    Some(Value::Str(k)) => k.clone(),
8262                    _ => {
8263                        return Err(GraphError::QueryError {
8264                            detail: format!(
8265                                "SET variable '{}' did not resolve to a node key",
8266                                sc.var
8267                            ),
8268                        })
8269                    }
8270                };
8271                // The SET value was already evaluated by the executor.
8272                let value = match match_rs.get(row_i, col) {
8273                    Some(v) => v.clone(),
8274                    None => {
8275                        return Err(GraphError::QueryError {
8276                            detail: format!(
8277                                "SET value for {}.{} evaluated to null",
8278                                sc.var, sc.field
8279                            ),
8280                        })
8281                    }
8282                };
8283                set_ops.push((key, sc.field.clone(), value));
8284            }
8285        }
8286
8287        // Apply as one atomic batch.
8288        let props_set = set_ops.len();
8289        let mut batch = self.batch();
8290        for (key, field, value) in set_ops {
8291            batch.set_prop(&key, &field, value);
8292        }
8293        batch.commit()?;
8294
8295        if let Some(returns) = project_returns {
8296            return project_set_return_rows(self, &rel_vars, &match_rs, &returns, params);
8297        }
8298
8299        let mut rs = write_result_set();
8300        rs.push_row(vec![
8301            Some(Value::Int(0)),
8302            Some(Value::Int(props_set as i64)),
8303            Some(Value::Int(0)),
8304        ]);
8305        Ok(rs)
8306    }
8307
8308    fn exec_match_delete(
8309        &mut self,
8310        stmt: core_query::cypher::MatchDeleteStmt,
8311        params: &BTreeMap<String, Value>,
8312    ) -> Result<ResultSet> {
8313        // Collect unique node vars needed to identify edge endpoints.
8314        let mut node_vars: Vec<String> = Vec::new();
8315        for ed in &stmt.deletes {
8316            if !node_vars.contains(&ed.src_var) {
8317                node_vars.push(ed.src_var.clone());
8318            }
8319            if !node_vars.contains(&ed.dst_var) {
8320                node_vars.push(ed.dst_var.clone());
8321            }
8322        }
8323
8324        // Synthesize read query.
8325        let returns: Vec<RetItem> = node_vars
8326            .iter()
8327            .map(|v| RetItem {
8328                value: RetVal::Var(v.clone()),
8329                alias: None,
8330            })
8331            .collect();
8332        let read_q = Query {
8333            matches: stmt.matches,
8334            optional_clauses: vec![],
8335            where_expr: stmt.where_expr,
8336            unwinds: vec![],
8337            post_unwind_where: None,
8338            stages: vec![],
8339            returns,
8340            distinct: false,
8341            order_by: vec![],
8342            skip: None,
8343            limit: None,
8344        };
8345        let ops = plan(&read_q).map_err(|e| GraphError::QueryError {
8346            detail: format!("plan: {e}"),
8347        })?;
8348        // Role-scoped writes: mask the MATCH read phase so hidden nodes are
8349        // invisible → hidden ≡ absent ≡ zero-rows (spec §3.1, hidden ≡ absent).
8350        let match_rs = {
8351            let mask_opt = self.pending_write_authz.as_ref().map(|a| a.mask.clone());
8352            if let Some(ref mask) = mask_opt {
8353                execute(&self.view_masked(mask), &ops, &Params(params))
8354            } else {
8355                execute(&self.view(), &ops, &Params(params))
8356            }
8357        }
8358        .map_err(|e| GraphError::QueryError {
8359            detail: format!("execute: {e}"),
8360        })?;
8361
8362        // Collect (etype, src_key, dst_key) for each row × each delete target.
8363        let mut del_ops: Vec<(String, String, String)> = Vec::new();
8364        for row_i in 0..match_rs.len() {
8365            for ed in &stmt.deletes {
8366                let src_key = match match_rs.get(row_i, &ed.src_var) {
8367                    Some(Value::Str(k)) => k.clone(),
8368                    _ => {
8369                        return Err(GraphError::QueryError {
8370                            detail: format!(
8371                                "DELETE src variable '{}' did not resolve to a node key",
8372                                ed.src_var
8373                            ),
8374                        })
8375                    }
8376                };
8377                let dst_key = match match_rs.get(row_i, &ed.dst_var) {
8378                    Some(Value::Str(k)) => k.clone(),
8379                    _ => {
8380                        return Err(GraphError::QueryError {
8381                            detail: format!(
8382                                "DELETE dst variable '{}' did not resolve to a node key",
8383                                ed.dst_var
8384                            ),
8385                        })
8386                    }
8387                };
8388                del_ops.push((ed.etype.clone(), src_key, dst_key));
8389            }
8390        }
8391
8392        // Apply as one atomic batch.
8393        let deleted = del_ops.len();
8394        let mut batch = self.batch();
8395        for (etype, src_key, dst_key) in del_ops {
8396            batch.delete_edge(&etype, &src_key, &dst_key);
8397        }
8398        batch.commit().map_err(|e| match e {
8399            GraphError::RuleOwned { .. } => GraphError::QueryError {
8400                detail: "cannot delete derived edge; retract via the rule or change the property"
8401                    .to_string(),
8402            },
8403            other => other,
8404        })?;
8405
8406        let mut rs = write_result_set();
8407        rs.push_row(vec![
8408            Some(Value::Int(0)),
8409            Some(Value::Int(0)),
8410            Some(Value::Int(deleted as i64)),
8411        ]);
8412        Ok(rs)
8413    }
8414
8415    /// Execute `MATCH … [DETACH] DELETE <node_var> [, …]`.
8416    ///
8417    /// Collects the matching node keys via an ephemeral read query, then calls
8418    /// `delete_node` on each one.  When `stmt.detach` is `false` (bare DELETE)
8419    /// the executor first checks that the node has no incident edges; if any
8420    /// remain it returns a named error matching openCypher semantics.
8421    fn exec_match_delete_node(
8422        &mut self,
8423        stmt: MatchDeleteNodeStmt,
8424        params: &BTreeMap<String, Value>,
8425    ) -> Result<ResultSet> {
8426        // Build a read query returning only the node keys we need.
8427        let returns: Vec<RetItem> = stmt
8428            .node_vars
8429            .iter()
8430            .map(|v| RetItem {
8431                value: RetVal::Var(v.clone()),
8432                alias: None,
8433            })
8434            .collect();
8435        let read_q = Query {
8436            matches: stmt.matches,
8437            optional_clauses: vec![],
8438            where_expr: stmt.where_expr,
8439            unwinds: vec![],
8440            post_unwind_where: None,
8441            stages: vec![],
8442            returns,
8443            distinct: false,
8444            order_by: vec![],
8445            skip: None,
8446            limit: None,
8447        };
8448        let ops = plan(&read_q).map_err(|e| GraphError::QueryError {
8449            detail: format!("plan: {e}"),
8450        })?;
8451        // Role-scoped writes: mask the MATCH read phase so hidden nodes are
8452        // invisible → hidden ≡ absent ≡ zero-rows (spec §3.1, hidden ≡ absent).
8453        let match_rs = {
8454            let mask_opt = self.pending_write_authz.as_ref().map(|a| a.mask.clone());
8455            if let Some(ref mask) = mask_opt {
8456                execute(&self.view_masked(mask), &ops, &Params(params))
8457            } else {
8458                execute(&self.view(), &ops, &Params(params))
8459            }
8460        }
8461        .map_err(|e| GraphError::QueryError {
8462            detail: format!("execute: {e}"),
8463        })?;
8464
8465        // Collect unique node keys to delete (deduplicate across rows × vars).
8466        let mut keys: Vec<String> = Vec::new();
8467        for row_i in 0..match_rs.len() {
8468            for var in &stmt.node_vars {
8469                if let Some(Value::Str(k)) = match_rs.get(row_i, var) {
8470                    if !keys.contains(k) {
8471                        keys.push(k.clone());
8472                    }
8473                }
8474            }
8475        }
8476
8477        if !stmt.detach {
8478            // openCypher bare DELETE: error if any matched node has incident edges.
8479            for key in &keys {
8480                if let Some(id) = self.ids.get(key) {
8481                    let tv = self.topo_view();
8482                    let has_edges = tv.etypes().any(|et| {
8483                        !tv.neighbors(et, Direction::Out, id).is_empty()
8484                            || !tv.neighbors(et, Direction::In, id).is_empty()
8485                    });
8486                    if has_edges {
8487                        return Err(GraphError::QueryError {
8488                            detail: format!(
8489                                "Cannot delete node `{key}` because it still has incident edges. \
8490                                 Use DETACH DELETE to remove the node and all its edges."
8491                            ),
8492                        });
8493                    }
8494                }
8495            }
8496        }
8497
8498        let mut nodes_deleted = 0i64;
8499        let mut edges_deleted = 0i64;
8500        for key in keys {
8501            match self.delete_node(&key) {
8502                Ok(report) => {
8503                    nodes_deleted += 1;
8504                    edges_deleted += (report.manual_edges + report.derived_edges) as i64;
8505                }
8506                Err(GraphError::KeyNotFound { .. }) => {
8507                    // Node may have been deleted by an earlier iteration (e.g., via
8508                    // multiple MATCH rows for the same node).  Safe to skip.
8509                }
8510                Err(e) => return Err(e),
8511            }
8512        }
8513
8514        let mut rs = write_result_set();
8515        rs.push_row(vec![
8516            Some(Value::Int(0)),
8517            Some(Value::Int(0)),
8518            Some(Value::Int(nodes_deleted + edges_deleted)),
8519        ]);
8520        Ok(rs)
8521    }
8522
8523    fn exec_merge(
8524        &mut self,
8525        stmt: core_query::cypher::MergeStmt,
8526        params: &BTreeMap<String, Value>,
8527    ) -> Result<ResultSet> {
8528        // MERGE: check if a node with the given key already exists.
8529        let key = match &stmt.key_value {
8530            Value::Str(s) => s.clone(),
8531            _ => {
8532                return Err(GraphError::QueryError {
8533                    detail: format!(
8534                        "MERGE key value must be a string (got {:?})",
8535                        stmt.key_value
8536                    ),
8537                })
8538            }
8539        };
8540
8541        if let Some(var) = stmt.var.as_deref() {
8542            for sc in stmt.on_create.iter().chain(&stmt.on_match) {
8543                if sc.var != var {
8544                    return Err(GraphError::QueryError {
8545                        detail: format!(
8546                            "SET variable '{}' does not match MERGE variable '{var}'",
8547                            sc.var
8548                        ),
8549                    });
8550                }
8551            }
8552        }
8553
8554        // ── MERGE authz pre-check (when role-scoped) ─────────────────────────
8555        //
8556        // MERGE scope precondition: check create OR update scope for the
8557        // declared label BEFORE calling `has_node` (timing-oracle closure,
8558        // spec §6.2 "MERGE visibility oracle" item: hidden ≡ absent for
8559        // unscoped roles — the scope denial fires without touching the key store).
8560        //
8561        // Clone to avoid holding a borrow on `self.pending_write_authz` while
8562        // also calling `self.ids.get(key)`.
8563        let merge_existed: bool = if let Some(authz) = self.pending_write_authz.clone() {
8564            let has_create = authz.scope.create_labels.contains(&stmt.label);
8565            let has_update = authz.scope.update_labels.contains(&stmt.label);
8566            if !has_create && !has_update {
8567                // Scope-before-lookup: 403 without has_node call (timing oracle
8568                // closure — see test_merge_unscoped_no_key_lookup).
8569                return Err(GraphError::RoleWriteDenied {
8570                    reason: format!(
8571                        "role-bound token: label '{}' not in write scope (create_labels)",
8572                        stmt.label
8573                    ),
8574                });
8575            }
8576            // Key lookup under mask.
8577            match self.ids.get(key.as_str()) {
8578                Some(id) if authz.mask.contains_id(id) => {
8579                    // Visible: must have update scope to proceed to match arm.
8580                    if !has_update {
8581                        return Err(GraphError::RoleWriteDenied {
8582                            reason: format!(
8583                                "role-bound token: label '{}' not in write scope (update_labels)",
8584                                stmt.label
8585                            ),
8586                        });
8587                    }
8588                    true // existed = true → match arm
8589                }
8590                Some(_) => {
8591                    // Hidden: same error as absent to the role (spec §3.1/§3.3).
8592                    return Err(GraphError::RoleWriteDenied {
8593                        reason: "role-bound token: target node not visible".into(),
8594                    });
8595                }
8596                None => {
8597                    // Absent: must have create scope to proceed to the create arm.
8598                    //
8599                    // Update-only roles (create_labels empty, update_labels set):
8600                    // return the SAME "not visible" error as the hidden-key branch
8601                    // so hidden ≡ absent — no distinguishing oracle (spec §6.1
8602                    // "confirm existence of hidden nodes: No").
8603                    //
8604                    // Create-scoped roles (has_create=true): absent → create arm
8605                    // as before.  The accepted structural key-existence disclosure
8606                    // (§THREAT-MODEL) applies only when the role holds create scope.
8607                    if !has_create {
8608                        return Err(GraphError::RoleWriteDenied {
8609                            reason: "role-bound token: target node not visible".into(),
8610                        });
8611                    }
8612                    false // existed = false → create arm
8613                }
8614            }
8615        } else {
8616            // Full authority: use the existing non-masked has_node check.
8617            self.has_node(&key)
8618        };
8619
8620        let existed = merge_existed;
8621        let mut created = 0i64;
8622        if !existed || !stmt.on_match.is_empty() {
8623            let mut batch = self.batch();
8624            if !existed {
8625                let props = vec![(stmt.key_field.clone(), stmt.key_value.clone())];
8626                batch.insert_node(&stmt.label, &key, props);
8627                for sc in &stmt.on_create {
8628                    let value = resolve_merge_set_value(&sc.value, params)?;
8629                    batch.set_prop(&key, &sc.field, value);
8630                }
8631                created = 1;
8632            } else {
8633                for sc in &stmt.on_match {
8634                    let value = resolve_merge_set_value(&sc.value, params)?;
8635                    batch.set_prop(&key, &sc.field, value);
8636                }
8637            }
8638            batch.commit()?;
8639        }
8640
8641        // Refresh the role mask so the just-created node is visible to this
8642        // statement's RETURN (read-after-write). Safe: create_labels ⊆ read labels
8643        // (apply_schema subset rule), so the new node's label is already in the
8644        // role's read scope — this never widens beyond the role's declared labels.
8645        if !existed {
8646            if let Some(role) = self.pending_write_authz.as_ref().map(|a| a.role.clone()) {
8647                let new_mask = self.mask_for_role(&role)?;
8648                if let Some(a) = self.pending_write_authz.as_mut() {
8649                    a.mask = new_mask;
8650                }
8651            }
8652        }
8653
8654        // Optional RETURN clause: project the node (created or matched) as a read result.
8655        if let Some(returns) = stmt.returns {
8656            let var = stmt.var.as_deref().unwrap_or("_mn0");
8657            let q = Query {
8658                matches: vec![Pattern {
8659                    start: NodePat {
8660                        var: Some(var.to_string()),
8661                        label: Some(stmt.label.clone()),
8662                        props: vec![("id".to_string(), Operand::Lit(stmt.key_value.clone()))],
8663                    },
8664                    chain: vec![],
8665                    shortest: false,
8666                }],
8667                optional_clauses: vec![],
8668                where_expr: None,
8669                unwinds: vec![],
8670                post_unwind_where: None,
8671                stages: vec![],
8672                returns,
8673                distinct: false,
8674                order_by: vec![],
8675                skip: None,
8676                limit: None,
8677            };
8678            let ops = plan(&q).map_err(|e| GraphError::QueryError {
8679                detail: format!("plan: {e}"),
8680            })?;
8681            // Use view_masked when a role-scoped write is in flight so the
8682            // post-merge projection is consistent with the masked read phase.
8683            let mask_opt = self.pending_write_authz.as_ref().map(|a| a.mask.clone());
8684            return (if let Some(ref mask) = mask_opt {
8685                execute(&self.view_masked(mask), &ops, &Params(params))
8686            } else {
8687                execute(&self.view(), &ops, &Params(params))
8688            })
8689            .map_err(|e| GraphError::QueryError {
8690                detail: format!("execute: {e}"),
8691            });
8692        }
8693
8694        let mut rs = write_result_set();
8695        rs.push_row(vec![
8696            Some(Value::Int(created)),
8697            Some(Value::Int(0)),
8698            Some(Value::Int(0)),
8699        ]);
8700        Ok(rs)
8701    }
8702
8703    /// Return all rule-owned edges between `key_a` and `key_b` (either direction),
8704    /// annotated with rule name, edge type, direction, and weight.
8705    /// Results are sorted by (rule, edge_type).
8706    /// Returns `Err(KeyNotFound)` if either key is unknown.
8707    pub fn explain(&self, key_a: &str, key_b: &str) -> Result<Vec<Explanation>> {
8708        self.ensure_v8_base_sections_loaded();
8709        let id_a = self
8710            .ids
8711            .get(key_a)
8712            .ok_or_else(|| GraphError::KeyNotFound { key: key_a.into() })?;
8713        let id_b = self
8714            .ids
8715            .get(key_b)
8716            .ok_or_else(|| GraphError::KeyNotFound { key: key_b.into() })?;
8717
8718        let mut results = Vec::new();
8719
8720        // Walk the smaller incident set so explain is O(min(deg(a), deg(b)))
8721        // rather than O(total provenance).
8722        let scan = if self.engine.provenance_touching_len(id_a)
8723            <= self.engine.provenance_touching_len(id_b)
8724        {
8725            id_a
8726        } else {
8727            id_b
8728        };
8729        for (rule_name, etype, src, dst) in self.engine.provenance_touching(scan) {
8730            if !((src == id_a && dst == id_b) || (src == id_b && dst == id_a)) {
8731                continue;
8732            }
8733            let Some(rule_def) = self.engine.rules().find(|r| r.name == rule_name) else {
8734                continue;
8735            };
8736            let edge_type = match self.syms.resolve(etype) {
8737                Some(s) => s.to_string(),
8738                None => continue,
8739            };
8740            // Provenance (src, dst) ids come from the archived PROVENANCE section
8741            // (large, no eager CRC).  A corrupt section can produce ids that are
8742            // out of range; return Corrupt rather than panic.
8743            let src_key = self
8744                .ids
8745                .key_of(src)
8746                .ok_or_else(|| GraphError::Corrupt {
8747                    detail: format!("v8: provenance src id {src} not in id table"),
8748                })?
8749                .to_string();
8750            let dst_key = self
8751                .ids
8752                .key_of(dst)
8753                .ok_or_else(|| GraphError::Corrupt {
8754                    detail: format!("v8: provenance dst id {dst} not in id table"),
8755                })?
8756                .to_string();
8757            let stored = rule_def.weight_prop.as_deref().and_then(|prop| {
8758                self.edge_props_view()
8759                    .get(etype, src, dst, prop)
8760                    .and_then(|v| {
8761                        if let Value::Float(f) = v {
8762                            Some(f)
8763                        } else {
8764                            None
8765                        }
8766                    })
8767            });
8768            // Rules that store no weight (KeyMatch/FieldEqual defaults, auto-FK)
8769            // still have a score: recompute it from the predicate so explain
8770            // never reports "no score" for an edge the engine scored.  Via-hop
8771            // rules score over their via set, not over (src, dst), so leave
8772            // those None rather than report a number the rule did not produce.
8773            let weight = stored.or_else(|| {
8774                if rule_def.via_edge.is_some() {
8775                    return None;
8776                }
8777                let props_view = build_props_view(&self.props, &self.base);
8778                let src_get = |field: &str| props_view.get(src, field).map(|vr| vr.into_value());
8779                let dst_get = |field: &str| props_view.get(dst, field).map(|vr| vr.into_value());
8780                let src_view = NodeView {
8781                    key: &src_key,
8782                    props: &src_get,
8783                };
8784                let dst_view = NodeView {
8785                    key: &dst_key,
8786                    props: &dst_get,
8787                };
8788                evaluate(&rule_def.predicate, &src_view, &dst_view)
8789            });
8790            results.push(Explanation {
8791                rule: rule_name.to_string(),
8792                edge_type,
8793                src_key,
8794                dst_key,
8795                weight,
8796                predicate: PredicateSummary {
8797                    approximate: rule_def.approximate,
8798                    ..PredicateSummary::from(&rule_def.predicate)
8799                },
8800                via_edge: rule_def.via_edge.clone(),
8801            });
8802        }
8803
8804        results.sort_by(|a, b| a.rule.cmp(&b.rule).then(a.edge_type.cmp(&b.edge_type)));
8805        Ok(results)
8806    }
8807
8808    pub fn neighbors(&self, key: &str, edge_type: &str, dir: Direction) -> Result<Vec<String>> {
8809        let id = self
8810            .ids
8811            .get(key)
8812            .ok_or_else(|| GraphError::KeyNotFound { key: key.into() })?;
8813        let Some(sym) = self.syms.get(edge_type) else {
8814            return Ok(Vec::new());
8815        };
8816        self.topo_view()
8817            .neighbors(sym, dir, id)
8818            .iter()
8819            .map(|&n| {
8820                self.ids
8821                    .key_of(n)
8822                    .map(|k| k.to_string())
8823                    .ok_or_else(|| GraphError::Corrupt {
8824                        detail: format!("topology id {n} has no key"),
8825                    })
8826            })
8827            .collect::<Result<Vec<_>>>()
8828    }
8829
8830    /// Return the last-change commit sequence for `key`, or `None` if the node
8831    /// does not exist or has never been mutated since the last V5-V7 snapshot
8832    /// (horizon-bounded for legacy stores).
8833    ///
8834    /// The returned sequence is a monotonically increasing counter that starts
8835    /// at 1 for the first commit after `open` and increments with every
8836    /// successful write.  WAL replay at open also assigns sequences (1..N for N
8837    /// replayed frames), so sequences are consistent across snapshot+WAL cycles.
8838    ///
8839    /// For V5-V7 stores opened without a V8 snapshot, nodes that were present
8840    /// in the snapshot but not touched by any WAL frame will return `None`
8841    /// (horizon-bounded: CAS against such nodes is only safe after the first
8842    /// V8 snapshot or after the node is next mutated).
8843    pub fn last_changed(&self, key: &str) -> Option<u64> {
8844        let id = self.ids.get(key)?;
8845        self.last_change.get(&id).copied()
8846    }
8847
8848    /// The current commit sequence (number of successful commits since open,
8849    /// including WAL replay frames).  Useful for recording a baseline before
8850    /// a read-modify-write cycle.
8851    pub fn commit_seq(&self) -> u64 {
8852        self.commit_seq
8853    }
8854
8855    /// Check that all `preconds` are satisfied against the current db state.
8856    /// Returns `Err(GraphError::CasConflict)` on the first failing precondition.
8857    pub(crate) fn check_preconditions(&self, preconds: &[Precondition]) -> Result<()> {
8858        for precond in preconds {
8859            match precond {
8860                Precondition::NodeUnchangedSince { key, expected } => {
8861                    // Missing entry means the node predates the WAL window or
8862                    // does not exist; treat as 0 (before any commit).
8863                    let actual = self.last_changed(key).unwrap_or_default();
8864                    if actual != *expected {
8865                        return Err(GraphError::CasConflict {
8866                            key: key.clone(),
8867                            expected: *expected,
8868                            actual,
8869                        });
8870                    }
8871                }
8872                Precondition::NodeAbsent { key } => {
8873                    // Node must not exist (not live).
8874                    if self.ids.get(key).is_some() {
8875                        let actual = self.last_changed(key).unwrap_or(0);
8876                        return Err(GraphError::CasConflict {
8877                            key: key.clone(),
8878                            expected: u64::MAX,
8879                            actual,
8880                        });
8881                    }
8882                }
8883            }
8884        }
8885        Ok(())
8886    }
8887
8888    /// Apply a batch of mutations with compare-and-set preconditions.
8889    ///
8890    /// All preconditions are checked atomically before any operation is applied.
8891    /// If any precondition fails, the entire batch is rejected with
8892    /// [`GraphError::CasConflict`] and no WAL frame is written.
8893    ///
8894    /// # Returns
8895    /// `(nodes_inserted, edges_inserted)` on success, same as [`write_batch`].
8896    ///
8897    /// # Errors
8898    /// - [`GraphError::CasConflict`] if any precondition is not satisfied.
8899    /// - Any error that [`write_batch`] would return for the ops themselves.
8900    pub fn write_batch_cas(
8901        &mut self,
8902        preconds: Vec<Precondition>,
8903        ops: Vec<BatchOp>,
8904    ) -> Result<(usize, usize)> {
8905        self.check_preconditions(&preconds)?;
8906        self.commit_logged_batch(ops, None, None)
8907    }
8908
8909    /// Update the per-node last-change map for a WAL record at commit `seq`.
8910    ///
8911    /// Called after a successful apply to record which nodes were touched.
8912    /// For replay, called with the WAL-frame's replayed seq.
8913    ///
8914    /// Touch definition (see [`Precondition`] doc):
8915    /// - InsertNode / InsertNodeId / SetProp / SetPropId / RemoveProp → the node.
8916    /// - InsertEdge / InsertEdgeId / DeleteEdge → both src and dst.
8917    /// - DeleteNode → node tombstoned; last_changed() returns None so no update needed.
8918    /// - DerivedEdge markers, Intern, rule/view records → no-ops.
8919    /// - Batch → recurse into inner records.
8920    fn update_last_change_from_rec(&mut self, rec: &WalRecord, seq: u64) {
8921        match rec {
8922            WalRecord::InsertNode { key, .. }
8923            | WalRecord::SetProp { key, .. }
8924            | WalRecord::RemoveProp { key, .. } => {
8925                if let Some(id) = self.ids.get(key) {
8926                    self.last_change.insert(id, seq);
8927                }
8928            }
8929            WalRecord::InsertNodeId { key, .. } => {
8930                if let Some(id) = self.ids.get(key) {
8931                    self.last_change.insert(id, seq);
8932                }
8933            }
8934            WalRecord::SetPropId { id, .. } => {
8935                self.last_change.insert(*id, seq);
8936            }
8937            WalRecord::InsertEdge {
8938                src_key, dst_key, ..
8939            }
8940            | WalRecord::DeleteEdge {
8941                src_key, dst_key, ..
8942            } => {
8943                if let Some(src_id) = self.ids.get(src_key) {
8944                    self.last_change.insert(src_id, seq);
8945                }
8946                if let Some(dst_id) = self.ids.get(dst_key) {
8947                    self.last_change.insert(dst_id, seq);
8948                }
8949            }
8950            WalRecord::InsertEdgeId { src, dst, .. } => {
8951                self.last_change.insert(*src, seq);
8952                self.last_change.insert(*dst, seq);
8953            }
8954            // DeleteNode: node is tombstoned; last_changed(key) returns None for
8955            // deleted keys (ids.get() returns None post-tombstone), so no update needed.
8956            // History markers: state no-ops; the underlying mutation already
8957            // touched the relevant nodes' last_change entries.
8958            WalRecord::DeleteNode { .. }
8959            | WalRecord::DerivedEdgeAdded { .. }
8960            | WalRecord::DerivedEdgeRetracted { .. }
8961            | WalRecord::Intern { .. }
8962            | WalRecord::CreateRule { .. }
8963            | WalRecord::DeleteRule { .. }
8964            | WalRecord::RebuildRule { .. }
8965            | WalRecord::CreateView { .. }
8966            | WalRecord::DeleteView { .. }
8967            | WalRecord::EnableFulltext { .. }
8968            | WalRecord::DisableFulltext { .. }
8969            | WalRecord::EnableIndex { .. }
8970            | WalRecord::DisableIndex { .. } => {}
8971            // RenameNode: node id is stable; update last_change via the new key.
8972            // Called after apply(), so ids already reflects new_key.
8973            WalRecord::RenameNode { new_key, .. } => {
8974                if let Some(id) = self.ids.get(new_key) {
8975                    self.last_change.insert(id, seq);
8976                }
8977            }
8978            WalRecord::Batch(inner) => {
8979                for inner_rec in inner {
8980                    self.update_last_change_from_rec(inner_rec, seq);
8981                }
8982            }
8983        }
8984    }
8985
8986    pub fn node_count(&self) -> usize {
8987        self.ids.len()
8988    }
8989
8990    /// Configure archive retention: keep the `N` newest WAL archives at each
8991    /// [`snapshot_with`] call when `archive_wal: true`.
8992    ///
8993    /// `Some(N)` where N > 0 → prune oldest archives keeping the newest N.
8994    /// `Some(0)` or `None` → unlimited (no pruning).
8995    ///
8996    /// Pruning only ever happens inside [`snapshot_with`]; this method only
8997    /// stores the policy.  Archives below the retention limit are deleted
8998    /// oldest-first.  The horizon floor is updated so that
8999    /// [`was_linked`] / history APIs return `CommitOutOfRange` for commits
9000    /// in pruned archives rather than silently returning wrong data.
9001    pub fn set_wal_archive_retention(&mut self, keep: Option<u32>) {
9002        self.wal_archive_retention = keep;
9003    }
9004
9005    /// Delete any WAL archives that are fully below the current horizon floor.
9006    ///
9007    /// Orphaned archives arise when the floor is written first during retention
9008    /// pruning and then a crash interrupts the archive-delete sequence.  The
9009    /// opening cleanup ensures no subsequent read path sees stale data.
9010    ///
9011    /// Under the monotonic naming scheme, the archive name N equals the
9012    /// cumulative end-frame index of the archive in global commit space (i.e.
9013    /// the archive covers global frames `[prev_n, N)`).  An archive is
9014    /// fully orphaned when `N <= wal_horizon_floor`: all of its frames fall
9015    /// below the floor and have already been counted in it.
9016    fn cleanup_orphaned_archives(&mut self) -> Result<()> {
9017        if self.wal_horizon_floor == 0 {
9018            // Floor at 0 means no pruning has ever occurred; nothing to clean.
9019            return Ok(());
9020        }
9021        let archive_ns = self.fs.list_archives()?;
9022        for n in archive_ns {
9023            if n <= self.wal_horizon_floor {
9024                // Archive N ends at global frame N; all its frames are below
9025                // the floor (floor already accounts for them) → orphaned.
9026                self.fs.delete_archive(n).map_err(GraphError::Io)?;
9027            } else {
9028                // Archives are sorted ascending; first one above floor stops scan.
9029                break;
9030            }
9031        }
9032        Ok(())
9033    }
9034
9035    /// Collect all WAL frames from surviving archives (oldest-first) then the
9036    /// live WAL into one flat list, and return the total along with the number
9037    /// of archive frames at the front of the list.
9038    ///
9039    /// Commit indices into the returned list are LOCAL (0 = first frame of
9040    /// oldest surviving archive).  To obtain the GLOBAL index add
9041    /// `self.wal_horizon_floor`.
9042    fn all_frames(&self) -> Result<(Vec<WalRecord>, u64)> {
9043        let archive_ns = self.fs.list_archives()?;
9044        let mut all: Vec<WalRecord> = Vec::new();
9045        for n in archive_ns {
9046            let bytes = self.fs.read_archive(n)?;
9047            let (frames, _) = decode_all(&bytes);
9048            all.extend(frames);
9049        }
9050        let archive_count = all.len() as u64;
9051        let live_bytes = self.fs.read(FileId::Wal)?;
9052        let (live_frames, _) = decode_all(&live_bytes);
9053        all.extend(live_frames);
9054        Ok((all, archive_count))
9055    }
9056
9057    /// Return the total number of committed WAL frames visible in the current
9058    /// horizon window, including frames in surviving WAL archives.
9059    ///
9060    /// This is the exclusive upper bound for valid `at_commit` indices in
9061    /// `was_linked`. Valid indices are `wal_horizon_floor()..wal_total_commits()`.
9062    ///
9063    /// Returns the horizon floor when all surviving history is empty.
9064    pub fn wal_total_commits(&self) -> Result<u64> {
9065        let (frames, _) = self.all_frames()?;
9066        Ok(self.wal_horizon_floor + frames.len() as u64)
9067    }
9068
9069    /// The global frame index of the first commit reachable through surviving
9070    /// archives (0 when no archives have been pruned).
9071    pub fn wal_horizon_floor(&self) -> u64 {
9072        self.wal_horizon_floor
9073    }
9074
9075    /// Return the per-node change history for `key` by scanning the on-disk WAL.
9076    ///
9077    /// ## Horizon
9078    ///
9079    /// History reaches back only to the last WAL-truncating snapshot, exactly like `open_at`.
9080    /// Snapshots written with `keep_wal: true` preserve deeper history. This is the honest,
9081    /// zero-cost contract; a durable history log is out of scope.
9082    ///
9083    /// ## Derived edges
9084    ///
9085    /// Rule-created (derived) edges are **not** in the WAL and therefore do not appear in
9086    /// history. Only edges written directly by the application are recorded.
9087    ///
9088    /// ## Deleted nodes
9089    ///
9090    /// For nodes that have been deleted, dense-id records (SetPropId, InsertEdgeId) that
9091    /// predate the deletion may not resolve (the id is tombstoned in the live map). The
9092    /// string-keyed `DeleteNode` record still matches and produces a `NodeDeleted` entry.
9093    /// Prop/edge history of a deleted node may therefore be partially unresolvable.
9094    ///
9095    /// ## Dense-id edge entries and tombstoned partners
9096    ///
9097    /// Edge entries from dense-id WAL records (`InsertEdgeId`) are omitted when the partner
9098    /// endpoint's dense id is tombstoned. As a result, a live node's history can contain an
9099    /// `EdgeRemoved` (string-keyed, always resolves) without a corresponding `EdgeAdded`.
9100    /// Build commit-bounded alias intervals for `queried_key`.
9101    ///
9102    /// Returns a list of `(key, valid_from_inclusive, valid_until_exclusive)` tuples.
9103    /// A record written under `key` at commit `c` matches the queried identity iff
9104    /// `c >= valid_from && (valid_until.is_none() || c < valid_until)`.
9105    ///
9106    /// Each alias entry carries both a lower and an upper bound so that key-reuse
9107    /// after a rename is handled correctly: if "a" is renamed to "b" at commit 5,
9108    /// then a NEW node is created as "a" at commit 7 and renamed to "c" at commit 10,
9109    /// querying "c" must NOT surface identity-1's events (commits 0–4 under "a");
9110    /// only identity-2's events (commits 7–9 under "a") are in scope.
9111    ///
9112    /// Only **forward aliasing**: querying the *new* key surfaces events written
9113    /// under the *old* key.  The reverse direction is not supported.
9114    fn build_key_alias_intervals(
9115        &self,
9116        frames: &[core_storage::wal::WalRecord],
9117        queried_key: &str,
9118    ) -> Vec<(String, u64, Option<u64>)> {
9119        use core_storage::wal::WalRecord;
9120
9121        // Pre-pass: build reverse_rename and key_starts maps.
9122        let mut reverse_rename: HashMap<String, (String, u64)> = HashMap::new();
9123        let mut key_starts: HashMap<String, Vec<u64>> = HashMap::new();
9124
9125        for (local_i, frame) in frames.iter().enumerate() {
9126            let commit = self.wal_horizon_floor + local_i as u64;
9127            let records: &[WalRecord] = match frame {
9128                WalRecord::Batch(inner) => inner.as_slice(),
9129                single => std::slice::from_ref(single),
9130            };
9131            for rec in records {
9132                match rec {
9133                    WalRecord::InsertNode { key, .. } | WalRecord::InsertNodeId { key, .. } => {
9134                        key_starts.entry(key.clone()).or_default().push(commit);
9135                    }
9136                    WalRecord::RenameNode { old_key, new_key } => {
9137                        // new_key came into existence at this commit.
9138                        key_starts.entry(new_key.clone()).or_default().push(commit);
9139                        // Record the reverse rename: new_key was introduced by renaming old_key.
9140                        reverse_rename.insert(new_key.clone(), (old_key.clone(), commit));
9141                    }
9142                    _ => {}
9143                }
9144            }
9145        }
9146
9147        // Build alias intervals by following the reverse rename chain.
9148        let mut result: Vec<(String, u64, Option<u64>)> = Vec::new();
9149        let mut current_key = queried_key.to_string();
9150        let mut current_valid_until: Option<u64> = None;
9151
9152        loop {
9153            // valid_from: the most recent commit where current_key was assigned to this
9154            // identity.  For aliases (valid_until = Some(vu)), find the last start event
9155            // for the key strictly before vu — this is where the alias's occupancy by
9156            // this identity began, correctly excluding prior identities that reused the key.
9157            let valid_from = if let Some(vu) = current_valid_until {
9158                key_starts
9159                    .get(&current_key)
9160                    .and_then(|starts| starts.iter().rev().find(|&&s| s < vu).copied())
9161                    .unwrap_or(self.wal_horizon_floor)
9162            } else {
9163                // Queried key — no upper bound; may have been introduced at any commit.
9164                self.wal_horizon_floor
9165            };
9166
9167            result.push((current_key.clone(), valid_from, current_valid_until));
9168
9169            match reverse_rename.get(&current_key) {
9170                Some((old_key, rename_commit)) => {
9171                    current_valid_until = Some(*rename_commit);
9172                    current_key = old_key.clone();
9173                }
9174                None => break,
9175            }
9176        }
9177
9178        result
9179    }
9180
9181    /// Returns true if `record_key` matches any alias interval that covers `commit`.
9182    fn aliases_match(
9183        intervals: &[(String, u64, Option<u64>)],
9184        record_key: &str,
9185        commit: u64,
9186    ) -> bool {
9187        intervals
9188            .iter()
9189            .any(|(k, vf, vu)| k == record_key && commit >= *vf && vu.is_none_or(|u| commit < u))
9190    }
9191
9192    /// Return the change history of node `key` by scanning the on-disk WAL.
9193    ///
9194    /// ## Horizon
9195    ///
9196    /// History reaches back only as far as the retained WAL. The returned
9197    /// [`HistoryResult`](crate::history::HistoryResult) carries `total_commits`
9198    /// (the exclusive upper bound for valid commit indices) and `horizon` (the
9199    /// oldest commit still reachable). When `horizon > 0`, older events were
9200    /// pruned and are not in `items`.
9201    pub fn node_history(
9202        &self,
9203        key: &str,
9204    ) -> Result<crate::history::HistoryResult<crate::history::HistoryEntry>> {
9205        use crate::history::{HistoryChange, HistoryEntry, HistoryResult};
9206        use core_storage::wal::WalRecord;
9207
9208        let (frames, _) = self.all_frames()?;
9209        let total_commits = self.wal_horizon_floor + frames.len() as u64;
9210
9211        // Resolve commit-bounded alias intervals for `key` (handles renames in the WAL).
9212        let alias_intervals = self.build_key_alias_intervals(&frames, key);
9213
9214        let mut out: Vec<HistoryEntry> = Vec::new();
9215
9216        for (local_i, frame) in frames.iter().enumerate() {
9217            let commit = self.wal_horizon_floor + local_i as u64;
9218            // Collect the inner records to process — Batch is one commit, single records are one commit.
9219            let records: &[WalRecord] = match frame {
9220                WalRecord::Batch(inner) => inner.as_slice(),
9221                single => std::slice::from_ref(single),
9222            };
9223
9224            for rec in records {
9225                let change = match rec {
9226                    WalRecord::InsertNode { label, key: k, .. }
9227                        if Self::aliases_match(&alias_intervals, k, commit) =>
9228                    {
9229                        Some(HistoryChange::NodeInserted {
9230                            label: label.clone(),
9231                        })
9232                    }
9233                    WalRecord::InsertNodeId { label, key: k, .. }
9234                        if Self::aliases_match(&alias_intervals, k, commit) =>
9235                    {
9236                        let label_str = match self.syms.resolve(*label) {
9237                            Some(s) => s.to_string(),
9238                            None => continue,
9239                        };
9240                        Some(HistoryChange::NodeInserted { label: label_str })
9241                    }
9242                    WalRecord::SetProp {
9243                        key: k,
9244                        field,
9245                        value,
9246                    } if Self::aliases_match(&alias_intervals, k, commit) => {
9247                        Some(HistoryChange::PropSet {
9248                            field: field.clone(),
9249                            value: value.clone(),
9250                        })
9251                    }
9252                    WalRecord::SetPropId { id, field, value } => {
9253                        // Use key_of_historical (not key_of) so a node's prop_set
9254                        // events remain visible after the node is later deleted:
9255                        // key_of returns None for a tombstoned id, which would
9256                        // silently drop every PropSet between insert and delete.
9257                        // Mirrors the InsertEdgeId arm below and edge_history's
9258                        // own id-keyed arms.
9259                        match self.ids.key_of_historical(*id) {
9260                            // key_of_historical returns the last-known (possibly
9261                            // post-rename, possibly post-delete) key; compare to queried key.
9262                            Some(resolved) if resolved == key => {
9263                                let field_str = match self.syms.resolve(*field) {
9264                                    Some(s) => s.to_string(),
9265                                    None => continue,
9266                                };
9267                                Some(HistoryChange::PropSet {
9268                                    field: field_str,
9269                                    value: value.clone(),
9270                                })
9271                            }
9272                            _ => None,
9273                        }
9274                    }
9275                    WalRecord::RemoveProp { key: k, field }
9276                        if Self::aliases_match(&alias_intervals, k, commit) =>
9277                    {
9278                        Some(HistoryChange::PropRemoved {
9279                            field: field.clone(),
9280                        })
9281                    }
9282                    WalRecord::InsertEdge {
9283                        edge_type,
9284                        src_key,
9285                        dst_key,
9286                    } => {
9287                        if Self::aliases_match(&alias_intervals, src_key, commit) {
9288                            Some(HistoryChange::EdgeAdded {
9289                                edge_type: edge_type.clone(),
9290                                other: dst_key.clone(),
9291                                outgoing: true,
9292                            })
9293                        } else if Self::aliases_match(&alias_intervals, dst_key, commit) {
9294                            Some(HistoryChange::EdgeAdded {
9295                                edge_type: edge_type.clone(),
9296                                other: src_key.clone(),
9297                                outgoing: false,
9298                            })
9299                        } else {
9300                            None
9301                        }
9302                    }
9303                    WalRecord::InsertEdgeId { etype, src, dst } => {
9304                        let etype_str = match self.syms.resolve(*etype) {
9305                            Some(s) => s.to_string(),
9306                            None => continue,
9307                        };
9308                        // key_of_historical (not key_of): an edge added before
9309                        // either endpoint was later deleted must still resolve —
9310                        // see the SetPropId arm above and edge_history's
9311                        // InsertEdgeId arm, which use the same lookup for the
9312                        // same reason.
9313                        let src_key = self.ids.key_of_historical(*src);
9314                        let dst_key = self.ids.key_of_historical(*dst);
9315                        if src_key == Some(key) {
9316                            let other = match dst_key {
9317                                Some(s) => s.to_string(),
9318                                None => continue,
9319                            };
9320                            Some(HistoryChange::EdgeAdded {
9321                                edge_type: etype_str,
9322                                other,
9323                                outgoing: true,
9324                            })
9325                        } else if dst_key == Some(key) {
9326                            let other = match src_key {
9327                                Some(s) => s.to_string(),
9328                                None => continue,
9329                            };
9330                            Some(HistoryChange::EdgeAdded {
9331                                edge_type: etype_str,
9332                                other,
9333                                outgoing: false,
9334                            })
9335                        } else {
9336                            None
9337                        }
9338                    }
9339                    WalRecord::DeleteEdge {
9340                        edge_type,
9341                        src_key,
9342                        dst_key,
9343                    } => {
9344                        if Self::aliases_match(&alias_intervals, src_key, commit) {
9345                            Some(HistoryChange::EdgeRemoved {
9346                                edge_type: edge_type.clone(),
9347                                other: dst_key.clone(),
9348                                outgoing: true,
9349                            })
9350                        } else if Self::aliases_match(&alias_intervals, dst_key, commit) {
9351                            Some(HistoryChange::EdgeRemoved {
9352                                edge_type: edge_type.clone(),
9353                                other: src_key.clone(),
9354                                outgoing: false,
9355                            })
9356                        } else {
9357                            None
9358                        }
9359                    }
9360                    WalRecord::DeleteNode { key: k }
9361                        if Self::aliases_match(&alias_intervals, k, commit) =>
9362                    {
9363                        Some(HistoryChange::NodeDeleted)
9364                    }
9365                    // Skip: rule/view/fulltext/intern metadata; Batch wrapper handled above.
9366                    _ => None,
9367                };
9368
9369                if let Some(change) = change {
9370                    out.push(HistoryEntry { commit, change });
9371                }
9372            }
9373        }
9374
9375        Ok(HistoryResult {
9376            items: out,
9377            total_commits,
9378            horizon: self.wal_horizon_floor,
9379        })
9380    }
9381
9382    /// Return the per-edge change history between nodes `a` and `b` by scanning
9383    /// the on-disk WAL.
9384    ///
9385    /// ## Horizon
9386    ///
9387    /// History reaches back only to the last WAL-truncating snapshot, exactly
9388    /// like `node_history` and `open_at`. The returned [`HistoryResult`] carries
9389    /// `total_commits` (= number of WAL frames), which is the exclusive upper
9390    /// bound for valid commit indices.
9391    ///
9392    /// ## Derived edges
9393    ///
9394    /// Rule-derived edges appear via `DerivedEdgeAdded` / `DerivedEdgeRetracted`
9395    /// WAL markers written by `log_then_apply_with` after each rule-firing
9396    /// mutation. The `rule` field of those events carries the rule name.
9397    ///
9398    /// ## DeleteNode
9399    ///
9400    /// When a node is deleted, its manual incident edges are swept inline without
9401    /// individual `DeleteEdge` WAL records. `edge_history` detects `DeleteNode`
9402    /// events for either endpoint and synthesises `Retracted(rule:None)` events
9403    /// for each manual edge that was active at that point. Derived edges active at
9404    /// the time of deletion are handled by the `DerivedEdgeRetracted` marker that
9405    /// the engine appends immediately after the `DeleteNode` record; those events
9406    /// carry correct rule attribution and are emitted by the marker arm, not the
9407    /// synthetic sweep.
9408    ///
9409    /// ## Masks
9410    ///
9411    /// Like `node_history`, this method has no mask parameter and returns WAL
9412    /// history regardless of any role mask. For masked history semantics, apply
9413    /// the mask at the caller level.
9414    pub fn edge_history(
9415        &self,
9416        a: &str,
9417        b: &str,
9418    ) -> Result<crate::history::HistoryResult<crate::history::EdgeHistoryEvent>> {
9419        use crate::history::{EdgeEvent, EdgeHistoryEvent, HistoryResult};
9420        use core_storage::wal::WalRecord;
9421
9422        let (frames, _) = self.all_frames()?;
9423        let total_commits = self.wal_horizon_floor + frames.len() as u64;
9424
9425        // Resolve all historical names for a and b (handles RenameNode in the WAL).
9426        // Intervals are commit-bounded so recycled keys don't contaminate histories.
9427        let alias_a = self.build_key_alias_intervals(&frames, a);
9428        let alias_b = self.build_key_alias_intervals(&frames, b);
9429
9430        // Active edges between a and b tracked as (edge_type, src_key, dst_key, is_derived).
9431        // The is_derived flag is used by the DeleteNode sweep: manual edges are
9432        // swept with a synthetic Retracted(rule:None); derived edges are skipped
9433        // because the engine writes a DerivedEdgeRetracted marker immediately after
9434        // the DeleteNode record, which carries the correct rule attribution.
9435        let mut active: Vec<(String, String, String, bool)> = Vec::new();
9436        let mut out: Vec<EdgeHistoryEvent> = Vec::new();
9437
9438        for (local_i, frame) in frames.iter().enumerate() {
9439            let commit = self.wal_horizon_floor + local_i as u64;
9440            let records: &[WalRecord] = match frame {
9441                WalRecord::Batch(inner) => inner.as_slice(),
9442                single => std::slice::from_ref(single),
9443            };
9444
9445            for rec in records {
9446                match rec {
9447                    WalRecord::InsertEdge {
9448                        edge_type,
9449                        src_key,
9450                        dst_key,
9451                    } => {
9452                        let is_ab = Self::aliases_match(&alias_a, src_key, commit)
9453                            && Self::aliases_match(&alias_b, dst_key, commit);
9454                        let is_ba = Self::aliases_match(&alias_b, src_key, commit)
9455                            && Self::aliases_match(&alias_a, dst_key, commit);
9456                        if is_ab || is_ba {
9457                            active.push((
9458                                edge_type.clone(),
9459                                src_key.clone(),
9460                                dst_key.clone(),
9461                                false,
9462                            ));
9463                            out.push(EdgeHistoryEvent {
9464                                edge_type: edge_type.clone(),
9465                                commit,
9466                                event: EdgeEvent::Added,
9467                                rule: None,
9468                            });
9469                        }
9470                    }
9471                    WalRecord::InsertEdgeId { etype, src, dst } => {
9472                        let etype_str = match self.syms.resolve(*etype) {
9473                            Some(s) => s.to_string(),
9474                            None => continue,
9475                        };
9476                        // Use key_of_historical so tombstoned nodes (deleted
9477                        // later in the WAL) still resolve during the scan.
9478                        let src_key = self.ids.key_of_historical(*src);
9479                        let dst_key = self.ids.key_of_historical(*dst);
9480                        let is_ab = src_key == Some(a) && dst_key == Some(b);
9481                        let is_ba = src_key == Some(b) && dst_key == Some(a);
9482                        if is_ab || is_ba {
9483                            let src_str = src_key.unwrap().to_string();
9484                            let dst_str = dst_key.unwrap().to_string();
9485                            active.push((etype_str.clone(), src_str, dst_str, false));
9486                            out.push(EdgeHistoryEvent {
9487                                edge_type: etype_str,
9488                                commit,
9489                                event: EdgeEvent::Added,
9490                                rule: None,
9491                            });
9492                        }
9493                    }
9494                    WalRecord::DeleteEdge {
9495                        edge_type,
9496                        src_key,
9497                        dst_key,
9498                    } => {
9499                        let is_ab = Self::aliases_match(&alias_a, src_key, commit)
9500                            && Self::aliases_match(&alias_b, dst_key, commit);
9501                        let is_ba = Self::aliases_match(&alias_b, src_key, commit)
9502                            && Self::aliases_match(&alias_a, dst_key, commit);
9503                        if is_ab || is_ba {
9504                            // Remove the first matching active entry (flag ignored).
9505                            if let Some(pos) = active.iter().position(|(et, s, d, _)| {
9506                                et == edge_type && s == src_key && d == dst_key
9507                            }) {
9508                                active.remove(pos);
9509                            }
9510                            out.push(EdgeHistoryEvent {
9511                                edge_type: edge_type.clone(),
9512                                commit,
9513                                event: EdgeEvent::Retracted,
9514                                rule: None,
9515                            });
9516                        }
9517                    }
9518                    WalRecord::DeleteNode { key: k }
9519                        if Self::aliases_match(&alias_a, k, commit)
9520                            || Self::aliases_match(&alias_b, k, commit) =>
9521                    {
9522                        // Sweep: implicitly retract only MANUAL active edges.
9523                        // Derived active edges are skipped here because the rule
9524                        // engine appends a DerivedEdgeRetracted marker immediately
9525                        // after this DeleteNode record; that marker produces the
9526                        // single correctly-attributed Retracted event.  Derived
9527                        // entries are dropped from `active` (the marker arm's
9528                        // idempotent retain finds nothing to remove).
9529                        for (et, _, _, is_derived) in active.drain(..) {
9530                            if !is_derived {
9531                                out.push(EdgeHistoryEvent {
9532                                    edge_type: et,
9533                                    commit,
9534                                    event: EdgeEvent::Retracted,
9535                                    rule: None,
9536                                });
9537                            }
9538                            // Derived: drop silently; marker carries the Retracted event.
9539                        }
9540                    }
9541                    WalRecord::DerivedEdgeAdded {
9542                        rule,
9543                        edge_type: et,
9544                        src_key,
9545                        dst_key,
9546                    } => {
9547                        let is_ab = Self::aliases_match(&alias_a, src_key, commit)
9548                            && Self::aliases_match(&alias_b, dst_key, commit);
9549                        let is_ba = Self::aliases_match(&alias_b, src_key, commit)
9550                            && Self::aliases_match(&alias_a, dst_key, commit);
9551                        if is_ab || is_ba {
9552                            active.push((et.clone(), src_key.clone(), dst_key.clone(), true));
9553                            out.push(EdgeHistoryEvent {
9554                                edge_type: et.clone(),
9555                                commit,
9556                                event: EdgeEvent::Added,
9557                                rule: Some(rule.clone()),
9558                            });
9559                        }
9560                    }
9561                    WalRecord::DerivedEdgeRetracted {
9562                        rule,
9563                        edge_type: et,
9564                        src_key,
9565                        dst_key,
9566                    } => {
9567                        let is_ab = Self::aliases_match(&alias_a, src_key, commit)
9568                            && Self::aliases_match(&alias_b, dst_key, commit);
9569                        let is_ba = Self::aliases_match(&alias_b, src_key, commit)
9570                            && Self::aliases_match(&alias_a, dst_key, commit);
9571                        if is_ab || is_ba {
9572                            // Push unconditionally: a derived edge whose Added marker
9573                            // predates the history horizon has no `active` entry, but
9574                            // the retraction is still a real in-window event.
9575                            // Remove from active idempotently if present.
9576                            active.retain(|(aet, s, d, _)| {
9577                                !(aet == et && s == src_key && d == dst_key)
9578                            });
9579                            out.push(EdgeHistoryEvent {
9580                                edge_type: et.clone(),
9581                                commit,
9582                                event: EdgeEvent::Retracted,
9583                                rule: Some(rule.clone()),
9584                            });
9585                        }
9586                    }
9587                    // All other records (InsertNode, SetProp, CreateRule, etc.)
9588                    // do not affect edges between a and b.
9589                    _ => {}
9590                }
9591            }
9592        }
9593
9594        Ok(HistoryResult {
9595            items: out,
9596            total_commits,
9597            horizon: self.wal_horizon_floor,
9598        })
9599    }
9600
9601    /// Return `true` iff an edge of `edge_type` existed between `a` and `b`
9602    /// (in either direction) at the WAL commit `at_commit`.
9603    ///
9604    /// ## Horizon
9605    ///
9606    /// Valid commit indices are `0..total_commits` where `total_commits` is the
9607    /// number of WAL frames. An `at_commit >= total_commits` is outside the
9608    /// visible horizon and returns [`GraphError::CommitOutOfRange`].
9609    ///
9610    /// ## Derived edges
9611    ///
9612    /// Rule-derived edges are tracked via `DerivedEdgeAdded` / `DerivedEdgeRetracted`
9613    /// WAL markers appended at firing time (Task 1). `was_linked` reads these markers
9614    /// and therefore includes derived edges in its point-in-time evaluation,
9615    /// matching `edge_history`'s fidelity.
9616    pub fn was_linked(&self, a: &str, b: &str, edge_type: &str, at_commit: u64) -> Result<bool> {
9617        use core_storage::wal::WalRecord;
9618
9619        let (frames, _) = self.all_frames()?;
9620        let total_commits = self.wal_horizon_floor + frames.len() as u64;
9621
9622        // Horizon floor: commits in pruned archives are unreachable.
9623        if at_commit < self.wal_horizon_floor {
9624            return Err(GraphError::CommitOutOfRange {
9625                commit: at_commit,
9626                total: total_commits,
9627                floor: self.wal_horizon_floor,
9628            });
9629        }
9630        if at_commit >= total_commits {
9631            return Err(GraphError::CommitOutOfRange {
9632                commit: at_commit,
9633                total: total_commits,
9634                floor: self.wal_horizon_floor,
9635            });
9636        }
9637
9638        // Resolve all historical names for a and b (handles RenameNode in the WAL).
9639        // Intervals are commit-bounded so recycled keys don't contaminate point-in-time reads.
9640        let alias_a = self.build_key_alias_intervals(&frames, a);
9641        let alias_b = self.build_key_alias_intervals(&frames, b);
9642
9643        // Local index into surviving frames (0 = first frame of oldest archive).
9644        let local_commit = at_commit - self.wal_horizon_floor;
9645
9646        // Replay local frames 0..=local_commit, tracking active edges.
9647        let mut active: BTreeSet<(String, String, String)> = BTreeSet::new();
9648
9649        for (local_i, frame) in frames.iter().enumerate().take((local_commit + 1) as usize) {
9650            let commit = self.wal_horizon_floor + local_i as u64;
9651            let records: &[WalRecord] = match frame {
9652                WalRecord::Batch(inner) => inner.as_slice(),
9653                single => std::slice::from_ref(single),
9654            };
9655
9656            for rec in records {
9657                match rec {
9658                    WalRecord::InsertEdge {
9659                        edge_type: et,
9660                        src_key,
9661                        dst_key,
9662                    } => {
9663                        let is_ab = Self::aliases_match(&alias_a, src_key, commit)
9664                            && Self::aliases_match(&alias_b, dst_key, commit);
9665                        let is_ba = Self::aliases_match(&alias_b, src_key, commit)
9666                            && Self::aliases_match(&alias_a, dst_key, commit);
9667                        if is_ab || is_ba {
9668                            active.insert((et.clone(), src_key.clone(), dst_key.clone()));
9669                        }
9670                    }
9671                    WalRecord::InsertEdgeId { etype, src, dst } => {
9672                        let etype_str = match self.syms.resolve(*etype) {
9673                            Some(s) => s.to_string(),
9674                            None => continue,
9675                        };
9676                        // Use key_of_historical so tombstoned nodes resolve.
9677                        let src_key = self.ids.key_of_historical(*src);
9678                        let dst_key = self.ids.key_of_historical(*dst);
9679                        let is_ab = src_key == Some(a) && dst_key == Some(b);
9680                        let is_ba = src_key == Some(b) && dst_key == Some(a);
9681                        if is_ab || is_ba {
9682                            active.insert((
9683                                etype_str,
9684                                src_key.unwrap().to_string(),
9685                                dst_key.unwrap().to_string(),
9686                            ));
9687                        }
9688                    }
9689                    WalRecord::DeleteEdge {
9690                        edge_type: et,
9691                        src_key,
9692                        dst_key,
9693                    } => {
9694                        let is_ab = Self::aliases_match(&alias_a, src_key, commit)
9695                            && Self::aliases_match(&alias_b, dst_key, commit);
9696                        let is_ba = Self::aliases_match(&alias_b, src_key, commit)
9697                            && Self::aliases_match(&alias_a, dst_key, commit);
9698                        if is_ab || is_ba {
9699                            active.remove(&(et.clone(), src_key.clone(), dst_key.clone()));
9700                        }
9701                    }
9702                    WalRecord::DeleteNode { key: k }
9703                        if Self::aliases_match(&alias_a, k, commit)
9704                            || Self::aliases_match(&alias_b, k, commit) =>
9705                    {
9706                        // All edges touching the deleted node are gone.
9707                        active.retain(|(_, s, d)| s != k && d != k);
9708                    }
9709                    WalRecord::DerivedEdgeAdded {
9710                        edge_type: et,
9711                        src_key,
9712                        dst_key,
9713                        ..
9714                    } => {
9715                        let is_ab = Self::aliases_match(&alias_a, src_key, commit)
9716                            && Self::aliases_match(&alias_b, dst_key, commit);
9717                        let is_ba = Self::aliases_match(&alias_b, src_key, commit)
9718                            && Self::aliases_match(&alias_a, dst_key, commit);
9719                        if is_ab || is_ba {
9720                            active.insert((et.clone(), src_key.clone(), dst_key.clone()));
9721                        }
9722                    }
9723                    WalRecord::DerivedEdgeRetracted {
9724                        edge_type: et,
9725                        src_key,
9726                        dst_key,
9727                        ..
9728                    } => {
9729                        let is_ab = Self::aliases_match(&alias_a, src_key, commit)
9730                            && Self::aliases_match(&alias_b, dst_key, commit);
9731                        let is_ba = Self::aliases_match(&alias_b, src_key, commit)
9732                            && Self::aliases_match(&alias_a, dst_key, commit);
9733                        if is_ab || is_ba {
9734                            active.remove(&(et.clone(), src_key.clone(), dst_key.clone()));
9735                        }
9736                    }
9737                    _ => {}
9738                }
9739            }
9740        }
9741
9742        Ok(active.iter().any(|(et, _, _)| et == edge_type))
9743    }
9744
9745    /// Every edge incident to `key` — either endpoint — that existed at WAL
9746    /// commit `commit`, from ONE scan of the WAL.
9747    ///
9748    /// This is the bulk form of [`was_linked`](GraphDb::was_linked): answering
9749    /// "what did K's relationships look like at commit C" with one call instead
9750    /// of one [`edge_history`](GraphDb::edge_history) per candidate partner.
9751    /// The two agree edge for edge.
9752    ///
9753    /// Results are sorted by `(edge_type, src_key, dst_key)`.
9754    ///
9755    /// ## Horizon
9756    ///
9757    /// Valid commit indices are `wal_horizon_floor()..wal_total_commits()`;
9758    /// anything outside is [`GraphError::CommitOutOfRange`], exactly like
9759    /// `was_linked`. An unknown key is not an error — it simply had no edges.
9760    ///
9761    /// ## Derived edges
9762    ///
9763    /// `DerivedEdgeAdded` / `DerivedEdgeRetracted` markers carry rule
9764    /// attribution, so a rule-owned edge comes back with `derived: true` and
9765    /// `rule: Some(name)`.
9766    ///
9767    /// ## Renames
9768    ///
9769    /// `key` is matched through the same commit-bounded alias intervals
9770    /// `edge_history` uses, so querying a node's *current* key surfaces edges
9771    /// written under an earlier name. Endpoint keys in the result are reported
9772    /// under the name the node carries today, so they can be fed straight back
9773    /// into `node_info`, `explain` or another `edges_at`.
9774    ///
9775    /// ## Masks
9776    ///
9777    /// Like `edge_history` and `node_history`, this reads the WAL regardless of
9778    /// any role mask. Apply masking at the caller level.
9779    pub fn edges_at(&self, key: &str, commit: u64) -> Result<Vec<EdgeAt>> {
9780        use core_storage::wal::WalRecord;
9781
9782        let (frames, _) = self.all_frames()?;
9783        let total_commits = self.wal_horizon_floor + frames.len() as u64;
9784
9785        // Horizon floor: commits in pruned archives are unreachable.
9786        if commit < self.wal_horizon_floor || commit >= total_commits {
9787            return Err(GraphError::CommitOutOfRange {
9788                commit,
9789                total: total_commits,
9790                floor: self.wal_horizon_floor,
9791            });
9792        }
9793
9794        // Commit-bounded historical names of `key` (handles RenameNode).
9795        let alias = self.build_key_alias_intervals(&frames, key);
9796
9797        // Forward rename chain, for reporting endpoints under their current
9798        // names: old key → [(commit, new key)] in ascending commit order.
9799        // Built over the whole WAL, not just the prefix up to `commit`, because
9800        // a rename after `commit` still changes what the node is called today.
9801        let mut renames: HashMap<String, Vec<(u64, String)>> = HashMap::new();
9802        for (local_i, frame) in frames.iter().enumerate() {
9803            let c = self.wal_horizon_floor + local_i as u64;
9804            let records: &[WalRecord] = match frame {
9805                WalRecord::Batch(inner) => inner.as_slice(),
9806                single => std::slice::from_ref(single),
9807            };
9808            for rec in records {
9809                if let WalRecord::RenameNode { old_key, new_key } = rec {
9810                    renames
9811                        .entry(old_key.clone())
9812                        .or_default()
9813                        .push((c, new_key.clone()));
9814                }
9815            }
9816        }
9817
9818        // The name a node written as `k` at commit `from` carries today.
9819        // Follows the first rename at or after `from`, then keeps going. The
9820        // iteration cap bounds a rename cycle inside a single batch.
9821        let canon = |k: &str, from: u64| -> String {
9822            if renames.is_empty() {
9823                return k.to_string();
9824            }
9825            let mut cur = k.to_string();
9826            let mut at = from;
9827            for _ in 0..64 {
9828                match renames
9829                    .get(&cur)
9830                    .and_then(|v| v.iter().find(|(c, _)| *c >= at))
9831                {
9832                    Some((c, new)) => {
9833                        at = *c;
9834                        cur = new.clone();
9835                    }
9836                    None => break,
9837                }
9838            }
9839            cur
9840        };
9841
9842        let local_commit = commit - self.wal_horizon_floor;
9843        // (edge_type, src_key, dst_key) → (derived, rule)
9844        let mut active: BTreeMap<(String, String, String), (bool, Option<String>)> =
9845            BTreeMap::new();
9846
9847        for (local_i, frame) in frames.iter().enumerate().take((local_commit + 1) as usize) {
9848            let c = self.wal_horizon_floor + local_i as u64;
9849            let records: &[WalRecord] = match frame {
9850                WalRecord::Batch(inner) => inner.as_slice(),
9851                single => std::slice::from_ref(single),
9852            };
9853
9854            for rec in records {
9855                match rec {
9856                    WalRecord::InsertEdge {
9857                        edge_type,
9858                        src_key,
9859                        dst_key,
9860                    } => {
9861                        if Self::aliases_match(&alias, src_key, c)
9862                            || Self::aliases_match(&alias, dst_key, c)
9863                        {
9864                            active.insert(
9865                                (edge_type.clone(), canon(src_key, c), canon(dst_key, c)),
9866                                (false, None),
9867                            );
9868                        }
9869                    }
9870                    WalRecord::InsertEdgeId { etype, src, dst } => {
9871                        let Some(etype_str) = self.syms.resolve(*etype) else {
9872                            continue;
9873                        };
9874                        // `key_of_historical` resolves tombstoned ids too, and
9875                        // already returns the node's current key — no rename
9876                        // canonicalisation needed on this arm.
9877                        let (Some(src_key), Some(dst_key)) = (
9878                            self.ids.key_of_historical(*src),
9879                            self.ids.key_of_historical(*dst),
9880                        ) else {
9881                            continue;
9882                        };
9883                        if src_key == key || dst_key == key {
9884                            active.insert(
9885                                (
9886                                    etype_str.to_string(),
9887                                    src_key.to_string(),
9888                                    dst_key.to_string(),
9889                                ),
9890                                (false, None),
9891                            );
9892                        }
9893                    }
9894                    WalRecord::DeleteEdge {
9895                        edge_type,
9896                        src_key,
9897                        dst_key,
9898                    } => {
9899                        if Self::aliases_match(&alias, src_key, c)
9900                            || Self::aliases_match(&alias, dst_key, c)
9901                        {
9902                            active.remove(&(
9903                                edge_type.clone(),
9904                                canon(src_key, c),
9905                                canon(dst_key, c),
9906                            ));
9907                        }
9908                    }
9909                    WalRecord::DeleteNode { key: k } => {
9910                        if active.is_empty() {
9911                            continue;
9912                        }
9913                        if Self::aliases_match(&alias, k, c) {
9914                            // Our node is gone; every incident edge goes with it.
9915                            active.clear();
9916                        } else {
9917                            // A partner is gone; its edges to us go with it.
9918                            let ck = canon(k, c);
9919                            active.retain(|(_, s, d), _| *s != ck && *d != ck);
9920                        }
9921                    }
9922                    WalRecord::DerivedEdgeAdded {
9923                        rule,
9924                        edge_type,
9925                        src_key,
9926                        dst_key,
9927                    } => {
9928                        if Self::aliases_match(&alias, src_key, c)
9929                            || Self::aliases_match(&alias, dst_key, c)
9930                        {
9931                            active.insert(
9932                                (edge_type.clone(), canon(src_key, c), canon(dst_key, c)),
9933                                (true, Some(rule.clone())),
9934                            );
9935                        }
9936                    }
9937                    WalRecord::DerivedEdgeRetracted {
9938                        edge_type,
9939                        src_key,
9940                        dst_key,
9941                        ..
9942                    } => {
9943                        if Self::aliases_match(&alias, src_key, c)
9944                            || Self::aliases_match(&alias, dst_key, c)
9945                        {
9946                            active.remove(&(
9947                                edge_type.clone(),
9948                                canon(src_key, c),
9949                                canon(dst_key, c),
9950                            ));
9951                        }
9952                    }
9953                    // InsertNode, SetProp, CreateRule, … do not move edges.
9954                    _ => {}
9955                }
9956            }
9957        }
9958
9959        // BTreeMap iteration is already (edge_type, src, dst) order.
9960        Ok(active
9961            .into_iter()
9962            .map(|((edge_type, src_key, dst_key), (derived, rule))| EdgeAt {
9963                edge_type,
9964                src_key,
9965                dst_key,
9966                derived,
9967                rule,
9968            })
9969            .collect())
9970    }
9971
9972    /// The derived edges that would be retracted and derived if `key.field`
9973    /// were set to `value` — computed WITHOUT writing anything.
9974    ///
9975    /// Nothing is committed and nothing on `self` is mutated: the rule engine's
9976    /// provenance, its candidate indexes, the topology and the property columns
9977    /// are all cloned first, the change is applied to the clone, and the real
9978    /// per-node re-derivation (`RuleEngine::on_node_changed` — the same call
9979    /// `set_prop` makes during apply) runs against it. The derived-edge deltas
9980    /// it emits are the answer, so rule semantics — predicates, top-k,
9981    /// via-hops, chaining, weights — are the engine's, not a re-implementation.
9982    ///
9983    /// Works on a read-only handle.
9984    ///
9985    /// Returns `Err(KeyNotFound)` for an unknown or tombstoned key and
9986    /// `Err(ViewPropReadOnly)` for a field a view owns — matching
9987    /// [`set_prop`](GraphDb::set_prop)'s validation. A change with no effect
9988    /// (the node already holds `value`, or no rule watches `field`) returns
9989    /// empty lists.
9990    ///
9991    /// ## Cost
9992    ///
9993    /// One clone of the property columns, the topology overlay, the symbol
9994    /// interner, the edge properties and the provenance map, plus one candidate
9995    /// re-index (O(nodes × rules)). That is much cheaper than copying the store
9996    /// directory, but it is not free — this is an interactive "what if", not a
9997    /// hot path.
9998    pub fn what_if_set_prop(&self, key: &str, field: &str, value: Value) -> Result<WhatIf> {
9999        // The engine's provenance, HNSW and IVF state live in the mmap'd base
10000        // until something asks for them. On a store opened cold from a snapshot
10001        // this is the first ask, and without it the clone below starts from an
10002        // empty provenance map: nothing to retract, so `lost` comes back empty.
10003        self.ensure_v8_base_sections_loaded();
10004
10005        let empty = WhatIf {
10006            lost: Vec::new(),
10007            gained: Vec::new(),
10008        };
10009
10010        if let Some(view_name) = self.view_store.view_for_prop(field) {
10011            return Err(GraphError::ViewPropReadOnly {
10012                view_name: view_name.to_string(),
10013            });
10014        }
10015        MutPreview::new(self).check_live_key(key)?;
10016        let id = self
10017            .ids
10018            .get(key)
10019            .ok_or_else(|| GraphError::KeyNotFound { key: key.into() })?;
10020
10021        let rules: Vec<RuleDef> = self.engine.rules().cloned().collect();
10022        if rules.is_empty() {
10023            return Ok(empty);
10024        }
10025
10026        // No rule watches this field → no derivation can change.
10027        if !rules.iter().any(|r| r.watched_fields().contains(field)) {
10028            return Ok(empty);
10029        }
10030
10031        let old_value = build_props_view(&self.props, &self.base)
10032            .get(id, field)
10033            .map(|vr| vr.into_value());
10034        if old_value.as_ref() == Some(&value) {
10035            return Ok(empty);
10036        }
10037
10038        // --- Clone every piece of state the re-derivation writes to. ---
10039        let mut props = self.props.clone();
10040        let mut topo = self.topo.clone();
10041        let mut syms = self.syms.clone();
10042        let mut edge_props = self.edge_props.clone();
10043
10044        let mut tripped: BTreeMap<String, bool> = BTreeMap::new();
10045        let mut fires: BTreeMap<String, u64> = BTreeMap::new();
10046        for r in &rules {
10047            tripped.insert(r.name.clone(), self.engine.is_tripped(&r.name));
10048            fires.insert(r.name.clone(), self.engine.fire_count(&r.name));
10049        }
10050        // `provenance()` decodes retained snapshot bytes on first use; the
10051        // engine clone needs the real map, not an empty one.
10052        let provenance = self.engine.provenance().clone();
10053        let mut engine = core_rules::RuleEngine::from_persist(rules, provenance, tripped, fires);
10054
10055        // Build the candidate indexes from the state BEFORE the change, exactly
10056        // as apply() sees them: `on_node_changed` withdraws the node under its
10057        // old value and refiles it under the new one, so the index must not
10058        // already reflect the change.
10059        engine.reindex_all_load_state(
10060            &self.ids,
10061            &syms,
10062            &self.labels,
10063            build_props_view(&self.props, &self.base),
10064            self.engine.export_ivf_state(),
10065            self.engine.export_hnsw_state_passthrough(),
10066        );
10067        engine.set_emit_deltas(true);
10068
10069        // --- Apply the hypothetical change and re-derive. ---
10070        props.set(id, field, value);
10071        {
10072            let mut gm = make_graph_mut(
10073                &self.ids,
10074                &mut syms,
10075                &self.labels,
10076                build_props_view(&props, &self.base),
10077                &mut topo,
10078                &self.base,
10079                &mut edge_props,
10080            );
10081            engine.on_node_changed(id, Some((field, old_value)), &mut gm);
10082        }
10083
10084        let mut lost: BTreeSet<EdgeAt> = BTreeSet::new();
10085        let mut gained: BTreeSet<EdgeAt> = BTreeSet::new();
10086        for d in engine.drain_deltas() {
10087            let edge = EdgeAt {
10088                edge_type: d.edge_type,
10089                src_key: d.src_key,
10090                dst_key: d.dst_key,
10091                derived: true,
10092                rule: Some(d.rule),
10093            };
10094            if d.fired {
10095                gained.insert(edge);
10096            } else {
10097                lost.insert(edge);
10098            }
10099        }
10100        // An edge retracted and re-derived within the same re-derivation (top-k
10101        // churn) is not a change the caller would see.
10102        let churn: Vec<EdgeAt> = lost.intersection(&gained).cloned().collect();
10103        for e in churn {
10104            lost.remove(&e);
10105            gained.remove(&e);
10106        }
10107
10108        Ok(WhatIf {
10109            lost: lost.into_iter().collect(),
10110            gained: gained.into_iter().collect(),
10111        })
10112    }
10113
10114    pub fn edge_count(&self) -> u64 {
10115        self.topo_view().edge_count()
10116    }
10117
10118    /// Live/tombstone/edge counts plus per-rule provenance size, trip latch,
10119    /// and fire counter (includes rebuild evaluations). Rules are sorted by name.
10120    pub fn stats(&self) -> Stats {
10121        self.ensure_v8_base_sections_loaded();
10122        let rules: Vec<RuleStats> = self
10123            .engine
10124            .rules()
10125            .map(|r| RuleStats {
10126                name: r.name.clone(),
10127                edges: self
10128                    .engine
10129                    .provenance()
10130                    .get(&r.name)
10131                    .map(|s| s.len() as u64)
10132                    .unwrap_or(0),
10133                tripped: self.engine.is_tripped(&r.name),
10134                fires: self.engine.fire_count(&r.name),
10135                approximate: r.approximate,
10136            })
10137            .collect();
10138        Stats {
10139            nodes_live: self.ids.live_len(),
10140            nodes_tombstoned: self.ids.len() - self.ids.live_len(),
10141            edges: self.topo_view().edge_count(),
10142            rules,
10143            chain_truncations: self.engine.chain_truncations(),
10144            history_floor: self.wal_horizon_floor,
10145        }
10146    }
10147
10148    /// On-disk size of the WAL file in bytes.
10149    ///
10150    /// Reads file metadata without loading WAL contents.  Returns `Err` for
10151    /// in-memory (`SimFs`) databases where no WAL file exists on disk.
10152    pub fn wal_size_bytes(&self) -> std::io::Result<u64> {
10153        let path = self.fs.wal_path().ok_or_else(|| {
10154            std::io::Error::new(
10155                std::io::ErrorKind::Unsupported,
10156                "wal_path not available for this Fs implementation",
10157            )
10158        })?;
10159        Ok(std::fs::metadata(path)?.len())
10160    }
10161
10162    /// Set the slow-query threshold.  Queries whose execution time equals or
10163    /// exceeds `ms` milliseconds are logged.  Pass `0` to disable.
10164    ///
10165    /// Use this setter in tests — the environment variable
10166    /// `MUSHROOMDB_SLOW_QUERY_MS` is process-global and races parallel test
10167    /// threads.
10168    pub fn set_slow_query_threshold_ms(&mut self, ms: u64) {
10169        self.slow_query_threshold_ms = ms;
10170    }
10171
10172    /// Snapshot of the slow-query ring buffer and lifetime counter.
10173    pub fn slow_query_snapshot(&self) -> SlowQuerySnapshot {
10174        let log = self.slow_queries.lock().unwrap_or_else(|e| e.into_inner());
10175        SlowQuerySnapshot {
10176            threshold_ms: self.slow_query_threshold_ms,
10177            count: log.total,
10178            last: log.entries.iter().cloned().collect(),
10179        }
10180    }
10181
10182    /// Instant the database was opened.  Used by consumers (e.g. `/metrics`)
10183    /// to compute uptime.
10184    pub fn started_at(&self) -> std::time::Instant {
10185        self.started_at
10186    }
10187
10188    /// On-disk snapshot format version this binary writes and reads.
10189    pub fn format_version() -> u16 {
10190        core_storage::snapshot::VERSION
10191    }
10192
10193    /// Test-support: total bytes appended (SimFs only usage).
10194    pub fn fs_total_appended(&self) -> usize
10195    where
10196        F: FsIntrospect,
10197    {
10198        self.fs.total_appended()
10199    }
10200
10201    /// Test-support: successful `Fs::sync` calls (SimFs / counting fs).
10202    pub fn fs_sync_count(&self) -> usize
10203    where
10204        F: FsIntrospect,
10205    {
10206        self.fs.sync_count()
10207    }
10208
10209    /// Consume the db, returning its fs (for crash simulation).
10210    pub fn into_fs(self) -> F {
10211        self.fs
10212    }
10213
10214    pub fn snapshot(&mut self) -> Result<()> {
10215        self.snapshot_with(SnapshotOptions::default())
10216    }
10217
10218    /// Snapshot with explicit options.
10219    ///
10220    /// # `keep_wal`
10221    ///
10222    /// When `keep_wal` is `false` (the default, same as [`snapshot`]):
10223    ///   - The WAL is replaced with a minimal baseline containing one
10224    ///     `EnableFulltext` record per active declaration.  All pre-snapshot
10225    ///     history is discarded; `open_at` can only reach post-snapshot commits.
10226    ///
10227    /// When `keep_wal` is `true`:
10228    ///   - The WAL is left intact.  All pre-snapshot commits remain reachable
10229    ///     via `open_at`.  The existing WAL already contains the original
10230    ///     `EnableFulltext` records, so no baseline re-write is needed; the
10231    ///     recovery guards in `apply()` silently skip any duplicate records on
10232    ///     replay.
10233    ///   - Crash window: a crash after the snapshot write but before the next
10234    ///     WAL write leaves the full pre-snapshot WAL intact.  On reopen the
10235    ///     snapshot is loaded and the WAL replayed idempotently over it — safe
10236    ///     because every `apply()` arm is idempotent when replayed over an
10237    ///     already-current snapshot.
10238    pub fn snapshot_with(&mut self, opts: SnapshotOptions) -> Result<()> {
10239        if self.read_only {
10240            return Err(GraphError::ReadOnly);
10241        }
10242        // A snapshot rewrites `wal.bin` through a tmp+rename, so a peer that is
10243        // appending ends up holding a descriptor on an unlinked inode and loses
10244        // commits it believes durable. Snapshotting therefore requires the
10245        // cross-process write lock, exactly as appending does. Unlike the WAL
10246        // append path this does not go through `log_then_apply_with`, so both
10247        // guards are repeated here.
10248        if self.degraded {
10249            return Err(GraphError::Io(std::io::Error::other(
10250                "database degraded after group-commit fsync failure; reopen required",
10251            )));
10252        }
10253        if self.lock_denied {
10254            return Err(GraphError::Busy { holder: None });
10255        }
10256        // Capture whether snapshot.bin already existed BEFORE this snapshot write.
10257        // Used by the archive path's conservative genesis-chain check: if a prior
10258        // snapshot exists but wal.truncated does not, we cannot distinguish a
10259        // legacy store (may have been truncated in an older code version) from a
10260        // new store that only used keep_wal=true.  Conservative: refuse genesis in
10261        // both cases.  Must be sampled here, before the snapshot write below.
10262        let had_prior_snapshot = self.fs.snapshot_path().map(|p| p.exists()).unwrap_or(false);
10263        self.ensure_v8_base_sections_loaded();
10264        // Ensure provenance is decoded before to_persist() clones it.
10265        self.engine.ensure_provenance_loaded_mut();
10266        let (rule_defs_typed, provenance, rule_tripped, rule_fires) = self.engine.to_persist();
10267        let rule_defs = rule_defs_typed
10268            .iter()
10269            .map(|r| bincode::serialize(r).expect("RuleDef serialize cannot fail"))
10270            .collect();
10271        // Collect HNSW state and IVF state.  When indexes are not yet
10272        // populated (clean open, no mutation since open), pass the retained
10273        // raw bytes through directly so that migrate/snapshot does not
10274        // silently discard fitted approximate-rule indexes.
10275        let hnsw_state = self.engine.export_hnsw_state_passthrough();
10276        let ivf_bytes = if !self.engine.indexes_populated() {
10277            // Pass retained IVF bytes through unchanged (no re-encode).
10278            self.engine.retained_ivf_bytes_clone().unwrap_or_default()
10279        } else {
10280            // Indexes live: encode from current state.
10281            let raw_ivf = self.engine.export_ivf_state();
10282            let ivf_state_map: BTreeMap<String, core_storage::snapshot::PerRuleIvfState> = raw_ivf
10283                .into_iter()
10284                .map(|(name, ((sc, sa, sd), (dc, da, dd)))| {
10285                    (
10286                        name,
10287                        core_storage::snapshot::PerRuleIvfState {
10288                            src: core_storage::snapshot::SideIvfState {
10289                                centroids: sc,
10290                                clusters: sa,
10291                                drift: sd,
10292                            },
10293                            dst: core_storage::snapshot::SideIvfState {
10294                                centroids: dc,
10295                                clusters: da,
10296                                drift: dd,
10297                            },
10298                        },
10299                    )
10300                })
10301                .collect();
10302            if ivf_state_map.is_empty() {
10303                Vec::new()
10304            } else {
10305                bincode::serialize(&ivf_state_map).expect("IVF state serialize cannot fail")
10306            }
10307        };
10308        let view_defs: Vec<Vec<u8>> = self
10309            .view_store
10310            .views()
10311            .map(|v| bincode::serialize(v).expect("ViewDef serialize cannot fail"))
10312            .collect();
10313        if self.base.is_some() {
10314            // V8 merge-snapshot path: encode base+overlay into a new V8 snapshot,
10315            // write it atomically, remap it as the new base, then clear the overlay.
10316            let meta = V8Meta {
10317                labels: self.labels.clone(),
10318                edge_props: self.edge_props.clone(),
10319                rule_defs,
10320                provenance,
10321                rule_tripped,
10322                rule_fires,
10323                ivf_bytes,
10324                view_defs,
10325                wal_truncated: !opts.keep_wal,
10326                hnsw: hnsw_state,
10327                last_change: self.last_change.clone(),
10328            };
10329            let mut buf: Vec<u8> = Vec::new();
10330            {
10331                // Clone the Arc so the old base stays alive while we encode.
10332                // The borrow of archived_csr (into old_base's mmap) is released
10333                // at the end of this block, before we replace self.base.
10334                let old_base = self.base.clone().expect("is_some checked above");
10335                let archived_csr = old_base.topology().map_err(|e| GraphError::Corrupt {
10336                    detail: format!("v8 snapshot: topology section: {e:?}"),
10337                })?;
10338                let archived_cols = old_base.columns().map_err(|e| GraphError::Corrupt {
10339                    detail: format!("v8 snapshot: columns section: {e:?}"),
10340                })?;
10341                // `None` when the base predates V9 — the migration path: its
10342                // string columns still carry their own tables and this snapshot
10343                // is the rewrite that collapses them into section 12.
10344                let archived_strings =
10345                    old_base
10346                        .string_table()
10347                        .transpose()
10348                        .map_err(|e| GraphError::Corrupt {
10349                            detail: format!("v8 snapshot: strings section: {e:?}"),
10350                        })?;
10351                let archived_edge_props =
10352                    old_base
10353                        .edge_props_section()
10354                        .map_err(|e| GraphError::Corrupt {
10355                            detail: format!("v8 snapshot: edge_props section: {e:?}"),
10356                        })?;
10357                let edge_props_raw =
10358                    old_base
10359                        .edge_props_raw_bytes()
10360                        .map_err(|e| GraphError::Corrupt {
10361                            detail: format!("v8 snapshot: edge_props raw bytes: {e:?}"),
10362                        })?;
10363                let prov_raw =
10364                    old_base
10365                        .provenance_raw_bytes()
10366                        .map_err(|e| GraphError::Corrupt {
10367                            detail: format!("v8 snapshot: provenance raw bytes: {e:?}"),
10368                        })?;
10369                encode_v8(
10370                    Some(archived_csr),
10371                    Some(archived_cols),
10372                    archived_strings,
10373                    Some((archived_edge_props, edge_props_raw)),
10374                    Some(prov_raw),
10375                    &self.topo,
10376                    &self.props,
10377                    &self.ids,
10378                    &self.syms,
10379                    &meta,
10380                    &mut buf,
10381                )?;
10382            }
10383            self.fs.write_atomic(FileId::Snapshot, &buf)?;
10384            // Remap the freshly-written snapshot as the new base.
10385            // C2: use file mmap on RealFs; fall back to from_bytes on SimFs.
10386            let new_base = if let Some(snap_path) = self.fs.snapshot_path() {
10387                core_storage::v8::MappedBase::map(&snap_path)
10388            } else {
10389                core_storage::v8::MappedBase::from_bytes(buf)
10390            }
10391            .map_err(|e| GraphError::Corrupt {
10392                detail: format!("v8 snapshot: remap new base: {e:?}"),
10393            })?;
10394            self.base = Some(Arc::new(new_base));
10395            // Clear the overlay and prop tombstones — all data is now in the new base.
10396            self.topo = Topology::new();
10397            self.props = core_storage::columns::ColumnStore::new();
10398        } else {
10399            // Legacy path (V5–V7 stores without a V8 base).
10400            //
10401            // Memory-diet path: build V8Meta directly from &self — no SnapshotState
10402            // clone and no encode_v8_from_state intermediate clones.  The big
10403            // structures (self.topo, self.props) are borrowed, not cloned.
10404            // self.edge_props is moved (not cloned) because we immediately clear it
10405            // when we remap the new V8 snapshot as self.base (see below).
10406            //
10407            // Eliminates from peak RSS vs. the old SnapshotState path:
10408            //   • self.topo.clone()      (~topology HashMap footprint)
10409            //   • self.props.clone()     (~column-store footprint)
10410            //   • encode_v8_from_state V8Meta secondary clones (labels, edge_props, …)
10411            let meta = V8Meta {
10412                labels: self.labels.clone(),
10413                wal_truncated: !opts.keep_wal,
10414                // Move edge_props out so the large overlay is freed when meta
10415                // drops at end of this block (self.edge_props is now empty; reads
10416                // after base assignment go through the mmap'd base section).
10417                edge_props: std::mem::take(&mut self.edge_props),
10418                rule_defs,
10419                provenance,
10420                rule_tripped,
10421                rule_fires,
10422                ivf_bytes,
10423                view_defs,
10424                hnsw: hnsw_state,
10425                last_change: self.last_change.clone(),
10426            };
10427            let mut buf = Vec::new();
10428            encode_v8(
10429                None,
10430                None,
10431                None,
10432                None,
10433                None,
10434                &self.topo,
10435                &self.props,
10436                &self.ids,
10437                &self.syms,
10438                &meta,
10439                &mut buf,
10440            )?;
10441            // meta (and the moved edge_props inside it) is no longer needed;
10442            // drop it before the write to keep the peak window narrow.
10443            drop(meta);
10444            self.fs.write_atomic(FileId::Snapshot, &buf)?;
10445            // Remap the freshly-written V8 snapshot as self.base.
10446            // On RealFs: drop the encode buffer before mmap to recover ~1.9 GiB.
10447            // On SimFs (tests): pass buf to from_bytes.
10448            let new_base = if let Some(snap_path) = self.fs.snapshot_path() {
10449                drop(buf);
10450                core_storage::v8::MappedBase::map(&snap_path)
10451            } else {
10452                core_storage::v8::MappedBase::from_bytes(buf)
10453            }
10454            .map_err(|e| GraphError::Corrupt {
10455                detail: format!("v8 snapshot: remap new base (legacy path): {e:?}"),
10456            })?;
10457            self.base = Some(Arc::new(new_base));
10458            // Free the large heap-allocated decoded state — all data is now in the
10459            // mmap'd base.  Mirrors the V8 merge-snapshot path (see above).
10460            // self.edge_props was already moved into meta and is effectively empty.
10461            self.topo = Topology::new();
10462            self.props = core_storage::columns::ColumnStore::new();
10463        }
10464
10465        if opts.archive_wal {
10466            // History-preserving snapshot (Task 4):
10467            //   1. Snapshot already written above (write_atomic → fsynced).
10468            //   2. Rename WAL → wal.<commit_seq>.archive  (atomic, same fs).
10469            //      Crash window B: crash here leaves archive present, WAL
10470            //      absent.  Reopen: snapshot loaded (full state), no WAL
10471            //      replay.  Archive is NOT replayed into live state — it is
10472            //      pre-snapshot by construction.  Safe.
10473            //   3. Optionally write genesis marker (first archive only, no
10474            //      prior WAL truncation).
10475            //   4. Prune old archives (retention), update horizon floor.
10476            //      Pruning invalidates the genesis chain; delete marker.
10477            //   5. Write new minimal baseline WAL (write_atomic).
10478            //      Crash window C: crash here leaves new archive plus no live
10479            //      WAL.  Same as window B — handled above.
10480            //
10481            // Sample existing archives BEFORE the rename so we can detect
10482            // whether this is the first archive.
10483            let existing_archives = self.fs.list_archives()?;
10484            let is_first_archive = existing_archives.is_empty();
10485
10486            // Compute a globally-monotonic archive name: the name equals the
10487            // cumulative end-frame index of the archive in global commit space.
10488            //
10489            // Using `commit_seq` directly is UNSOUND across sessions: on reopen
10490            // commit_seq is seeded from max(last_change), which underestimates
10491            // the WAL depth when trailing commits (e.g. insert_edge) do not
10492            // update last_change.  A session-2 archive could then receive a name
10493            // ≤ the session-1 archive, causing incorrect sort order or collision.
10494            //
10495            // Instead: read and decode the live WAL here (before the rename) to
10496            // get its exact frame count, then add it to the last known global
10497            // end-frame index (the name of the most recent existing archive, or
10498            // wal_horizon_floor if no archives exist).  This is O(WAL size) but
10499            // snapshot is already serialising the full graph state, so the cost
10500            // is dominated.
10501            let live_wal_bytes_for_name = self.fs.read(FileId::Wal)?;
10502            let (live_frames_for_name, _) = decode_all(&live_wal_bytes_for_name);
10503            let archive_n = existing_archives
10504                .last()
10505                .copied()
10506                .unwrap_or(self.wal_horizon_floor)
10507                + live_frames_for_name.len() as u64;
10508            self.fs.archive_wal(archive_n)?;
10509
10510            // Genesis marker: written once when the first archive is taken
10511            // from a store that has never undergone a WAL-truncating snapshot.
10512            // When present, `open_at` may replay archive-resident commits from
10513            // empty state (the archive chain covers from global index 0).
10514            //
10515            // Two conditions must ALL hold:
10516            //   1. This is the first archive (existing_archives was empty).
10517            //   2. No snapshot.bin existed before this operation (had_prior_snapshot=false).
10518            //      A WAL-truncating snapshot (keep_wal=false) always writes snapshot.bin
10519            //      before truncating the WAL, so if any prior truncating snapshot was taken
10520            //      — even in a previous session — snapshot.bin is present and this condition
10521            //      is false.  This subsumes the cross-session truncation case without
10522            //      requiring a separate wal.truncated sidecar file.
10523            //      For legacy stores (snapshot.bin written by an older code version that
10524            //      may have truncated the WAL), the same conservative refusal applies:
10525            //      we cannot prove the chain is complete, so we refuse genesis (cost =
10526            //      no as-of-through-archives; never silent wrong data).
10527            //      On SimFs (snapshot_path() == None) had_prior_snapshot is always false,
10528            //      so SimFs always passes this check.
10529            if is_first_archive && !had_prior_snapshot {
10530                self.fs.write_genesis_marker()?;
10531                self.archive_genesis_chain = true;
10532            }
10533
10534            // Retention pruning: keep newest `keep` archives; delete oldest.
10535            // Pruning is the ONLY deletion site for archives.
10536            //
10537            // Crash-safety ordering (C1 fix):
10538            //   1. Count frames in surplus archives (reads only — no mutation).
10539            //   2. Advance and PERSIST the horizon floor FIRST via write-then-
10540            //      rename (atomic).  A crash after this point leaves orphaned
10541            //      archives on disk, but the floor is correct.  The opening
10542            //      cleanup sweep (`cleanup_orphaned_archives`) removes them on
10543            //      the next open, so the store is always safe to reopen.
10544            //   3. Delete the genesis marker (floor > 0 already blocks open_at
10545            //      via the conjunctive gate; marker cleanup is belt-and-suspenders).
10546            //   4. Delete surplus archives.  A crash between any two deletes
10547            //      leaves the floor committed and orphaned archives cleaned at
10548            //      next open — never a stale floor with a missing archive prefix.
10549            if let Some(keep) = self.wal_archive_retention {
10550                if keep > 0 {
10551                    let archives = self.fs.list_archives()?;
10552                    // archives is sorted ascending (oldest first)
10553                    if archives.len() as u32 > keep {
10554                        let surplus = archives.len() - keep as usize;
10555                        // Step 1: count pruned frames (reads, no mutation).
10556                        let mut pruned_frames = 0u64;
10557                        for &n in &archives[..surplus] {
10558                            let bytes = self.fs.read_archive(n)?;
10559                            let (frames, _) = decode_all(&bytes);
10560                            pruned_frames += frames.len() as u64;
10561                        }
10562                        // Step 2: advance and persist floor FIRST.
10563                        self.wal_horizon_floor += pruned_frames;
10564                        self.fs.write_horizon_floor(self.wal_horizon_floor)?;
10565                        // Step 3: delete genesis marker (floor > 0 already
10566                        // blocks open_at; this is belt-and-suspenders cleanup).
10567                        if pruned_frames > 0 && self.archive_genesis_chain {
10568                            self.fs.delete_genesis_marker()?;
10569                            self.archive_genesis_chain = false;
10570                        }
10571                        // Step 4: delete surplus archives.  Crash here →
10572                        // orphaned archives; cleaned at next open.
10573                        for &n in &archives[..surplus] {
10574                            self.fs.delete_archive(n)?;
10575                        }
10576                    }
10577                }
10578            }
10579
10580            // Write new minimal baseline WAL (mirrors the keep_wal=false path).
10581            let mut baseline_wal: Vec<u8> = Vec::new();
10582            for (label, field) in self.fulltext.enabled_pairs() {
10583                let rec = WalRecord::EnableFulltext {
10584                    label: label.clone(),
10585                    field: field.clone(),
10586                };
10587                baseline_wal.extend_from_slice(&encode_record(&rec));
10588            }
10589            for (label, field) in self.prop_index.enabled_pairs() {
10590                let rec = WalRecord::EnableIndex {
10591                    label: label.clone(),
10592                    field: field.clone(),
10593                };
10594                baseline_wal.extend_from_slice(&encode_record(&rec));
10595            }
10596            self.fs.write_atomic(FileId::Wal, &baseline_wal)?;
10597        } else if opts.keep_wal {
10598            // keep_wal=true: WAL is left untouched.  The existing WAL already
10599            // contains the EnableFulltext records from the original enable calls;
10600            // replay is idempotent (guards in apply() skip already-live entries).
10601            // No baseline re-write is needed or safe here — the full WAL history
10602            // must remain intact for open_at to reach pre-snapshot commits.
10603        } else {
10604            // keep_wal=false (default): truncate by replacing the WAL with a
10605            // minimal baseline of one EnableFulltext record per active pair.
10606            //
10607            // Crash-ordering: write_atomic is atomic.
10608            //   • Crash before snapshot write  → WAL unchanged.  Safe.
10609            //   • Crash after snapshot write but before this WAL write → full
10610            //     pre-snapshot WAL still present; open_with replays idempotently.
10611            //   • Crash after both writes → normal post-snapshot state.
10612            //
10613            // Genesis chain: a WAL-truncating snapshot breaks the archive chain
10614            // for any archives taken AFTER this point (their WAL slices would
10615            // not start at genesis).  Delete any existing genesis marker so that
10616            // open_at refuses archive-resident commits.  Future sessions are
10617            // covered by had_prior_snapshot: snapshot.bin written here persists
10618            // across sessions and prevents a later archiving session from
10619            // incorrectly claiming a complete genesis chain.
10620            if self.archive_genesis_chain {
10621                self.fs.delete_genesis_marker()?;
10622                self.archive_genesis_chain = false;
10623            }
10624            let mut baseline_wal: Vec<u8> = Vec::new();
10625            for (label, field) in self.fulltext.enabled_pairs() {
10626                let rec = WalRecord::EnableFulltext {
10627                    label: label.clone(),
10628                    field: field.clone(),
10629                };
10630                baseline_wal.extend_from_slice(&encode_record(&rec));
10631            }
10632            for (label, field) in self.prop_index.enabled_pairs() {
10633                let rec = WalRecord::EnableIndex {
10634                    label: label.clone(),
10635                    field: field.clone(),
10636                };
10637                baseline_wal.extend_from_slice(&encode_record(&rec));
10638            }
10639            self.fs.write_atomic(FileId::Wal, &baseline_wal)?;
10640        }
10641        // After snapshot the overlay may have changed (V8 merge path clears
10642        // self.topo and self.props). Refresh the MVCC fold so future readers
10643        // see the post-snapshot state rather than stale overlay data.
10644        self.fold_now();
10645        // We wrote the snapshot and (unless keep_wal) replaced the WAL, so both
10646        // markers this handle uses to detect other processes' work must be
10647        // re-taken from disk. Skipping this would make our own snapshot look
10648        // like a peer's on the next staleness check and force a needless
10649        // reload.
10650        self.wal_consumed = self.fs.wal_len().map_err(GraphError::Io)?;
10651        self.snapshot_ident = self.fs.snapshot_ident().map_err(GraphError::Io)?;
10652        Ok(())
10653    }
10654}
10655
10656/// Queued mutation for a [`BatchBuilder`] or [`GraphDb::commit_group`].
10657///
10658/// The `submit_batch` / `commit_group` APIs accept `Vec<BatchOp>` so that
10659/// callers can build a set of mutations without holding `&mut GraphDb` and
10660/// hand them off to the group-committing writer for durable, batched I/O.
10661pub enum BatchOp {
10662    InsertNode {
10663        label: String,
10664        key: String,
10665        props: Vec<(String, Value)>,
10666    },
10667    InsertEdge {
10668        edge_type: String,
10669        src_key: String,
10670        dst_key: String,
10671    },
10672    SetProp {
10673        key: String,
10674        field: String,
10675        value: Value,
10676    },
10677    RemoveProp {
10678        key: String,
10679        field: String,
10680    },
10681    DeleteEdge {
10682        edge_type: String,
10683        src_key: String,
10684        dst_key: String,
10685    },
10686    DeleteNode {
10687        key: String,
10688    },
10689    CreateRule(RuleDef),
10690    DeleteRule {
10691        name: String,
10692    },
10693    /// Rename a node's key. Validated: old must exist, new must not.
10694    RenameNode {
10695        old_key: String,
10696        new_key: String,
10697    },
10698    /// Insert an edge, auto-creating any missing endpoint as a plain node with
10699    /// `placeholder_label` and no props. Rules fire and last-change is updated
10700    /// for each created endpoint (normal InsertNode semantics in the batch frame).
10701    InsertEdgeUpsert {
10702        edge_type: String,
10703        src_key: String,
10704        dst_key: String,
10705        placeholder_label: String,
10706    },
10707}
10708
10709/// Three-way node visibility status used by `check_single_op_authz`.
10710enum NodeAuthzStatus {
10711    /// Node exists in the store and is in the role's read mask.
10712    Visible(String), // carries the node's label
10713    /// Node exists in the store but is NOT in the role's read mask.
10714    Hidden,
10715    /// Node does not exist in the store.
10716    Absent,
10717}
10718
10719/// Overlay of ops already accepted earlier in the same batch. Never written
10720/// back to the database — validation only.
10721#[derive(Default)]
10722struct Overlay {
10723    extra_keys: BTreeSet<String>,
10724    deleted_keys: BTreeSet<String>,
10725    extra_props: BTreeMap<(String, String), Value>,
10726    removed_props: BTreeSet<(String, String)>,
10727    extra_edges: BTreeSet<(String, String, String)>,
10728    deleted_edges: BTreeSet<(String, String, String)>,
10729    extra_rules: BTreeSet<String>,
10730    deleted_rules: BTreeSet<String>,
10731    /// `rule name → (via_edge, edge_type)` for every via-hop rule accepted
10732    /// earlier in this batch. Feeds the rule-chain cycle check, which otherwise
10733    /// sees only the rules already committed to the engine. Keyed by name so a
10734    /// later `DeleteRule` in the same batch drops the arc with the rule.
10735    extra_rule_arcs: BTreeMap<String, (String, String)>,
10736}
10737
10738/// Read-only view of live db state plus a batch overlay. Shared by single-op
10739/// public methods (empty overlay) and `commit_batch`.
10740struct MutPreview<'a, F: Fs> {
10741    db: &'a GraphDb<F>,
10742    overlay: Overlay,
10743}
10744
10745/// Shortest path from `start` to `target` following `arcs` (`from → to`), or
10746/// `None` if `target` is unreachable.
10747///
10748/// Used for rule-chain cycle detection, where an arc is "a rule hops over
10749/// `from` and writes `to`". Breadth-first over BTree-ordered adjacency, so the
10750/// reported path is stable for a given rule set, and iterative so a pathological
10751/// rule graph cannot overflow the stack.
10752fn find_cycle_through(arcs: &[(String, String)], start: &str, target: &str) -> Option<Vec<String>> {
10753    let mut adj: BTreeMap<&str, BTreeSet<&str>> = BTreeMap::new();
10754    for (from, to) in arcs {
10755        adj.entry(from.as_str()).or_default().insert(to.as_str());
10756    }
10757    let mut parent: BTreeMap<&str, &str> = BTreeMap::new();
10758    let mut visited: BTreeSet<&str> = BTreeSet::new();
10759    let mut queue: std::collections::VecDeque<&str> = std::collections::VecDeque::new();
10760    visited.insert(start);
10761    queue.push_back(start);
10762    while let Some(node) = queue.pop_front() {
10763        if node == target {
10764            let mut path = vec![node.to_string()];
10765            let mut cur = node;
10766            while let Some(&p) = parent.get(cur) {
10767                path.push(p.to_string());
10768                cur = p;
10769            }
10770            path.reverse();
10771            return Some(path);
10772        }
10773        for &next in adj.get(node).into_iter().flatten() {
10774            if visited.insert(next) {
10775                parent.insert(next, node);
10776                queue.push_back(next);
10777            }
10778        }
10779    }
10780    None
10781}
10782
10783impl<'a, F: Fs> MutPreview<'a, F> {
10784    fn new(db: &'a GraphDb<F>) -> Self {
10785        Self {
10786            db,
10787            overlay: Overlay::default(),
10788        }
10789    }
10790
10791    fn has_key(&self, key: &str) -> bool {
10792        if self.overlay.extra_keys.contains(key) {
10793            return true;
10794        }
10795        if self.overlay.deleted_keys.contains(key) {
10796            return false;
10797        }
10798        self.db.ids.get(key).is_some()
10799    }
10800
10801    fn has_prop(&self, key: &str, field: &str) -> bool {
10802        if !self.has_key(key) {
10803            return false;
10804        }
10805        let k = (key.to_string(), field.to_string());
10806        if self.overlay.removed_props.contains(&k) {
10807            return false;
10808        }
10809        if self.overlay.extra_props.contains_key(&k) {
10810            return true;
10811        }
10812        // Fresh identity (first insert in this batch, or delete+reinsert):
10813        // ignore props still sitting on the soon-to-be-tombstoned slot.
10814        if self.overlay.extra_keys.contains(key) {
10815            return false;
10816        }
10817        self.db.get_prop(key, field).is_some()
10818    }
10819
10820    fn has_edge(&self, edge_type: &str, src_key: &str, dst_key: &str) -> bool {
10821        let k = (
10822            edge_type.to_string(),
10823            src_key.to_string(),
10824            dst_key.to_string(),
10825        );
10826        if self.overlay.deleted_edges.contains(&k) {
10827            return false;
10828        }
10829        if self.overlay.extra_edges.contains(&k) {
10830            return true;
10831        }
10832        // A key created in this batch (including reinsert) has no db edges.
10833        if self.overlay.extra_keys.contains(src_key) || self.overlay.extra_keys.contains(dst_key) {
10834            return false;
10835        }
10836        if self.overlay.deleted_keys.contains(src_key)
10837            || self.overlay.deleted_keys.contains(dst_key)
10838        {
10839            return false;
10840        }
10841        let Some(src) = self.db.ids.get(src_key) else {
10842            return false;
10843        };
10844        let Some(dst) = self.db.ids.get(dst_key) else {
10845            return false;
10846        };
10847        let Some(sym) = self.db.syms.get(edge_type) else {
10848            return false;
10849        };
10850        self.db
10851            .topo_view()
10852            .neighbors(sym, Direction::Out, src)
10853            .binary_search(&dst)
10854            .is_ok()
10855    }
10856
10857    fn has_rule(&self, name: &str) -> bool {
10858        if self.overlay.extra_rules.contains(name) {
10859            return true;
10860        }
10861        if self.overlay.deleted_rules.contains(name) {
10862            return false;
10863        }
10864        self.db.engine.rules().any(|r| r.name == name)
10865    }
10866
10867    fn is_rule_owned(&self, edge_type: &str, src_key: &str, dst_key: &str) -> bool {
10868        if self.overlay.extra_keys.contains(src_key) || self.overlay.extra_keys.contains(dst_key) {
10869            return false;
10870        }
10871        if self.overlay.deleted_keys.contains(src_key)
10872            || self.overlay.deleted_keys.contains(dst_key)
10873        {
10874            return false;
10875        }
10876        let Some(src) = self.db.ids.get(src_key) else {
10877            return false;
10878        };
10879        let Some(dst) = self.db.ids.get(dst_key) else {
10880            return false;
10881        };
10882        let Some(et) = self.db.syms.get(edge_type) else {
10883            return false;
10884        };
10885        // extra_rules is deliberately not consulted: a CreateRule earlier in
10886        // this batch has not fired, so it contributes no provenance. That is
10887        // the documented rule-window gap (see GraphDb::batch).
10888        if self.overlay.deleted_rules.is_empty() {
10889            return self.db.engine.is_owned(et, src, dst);
10890        }
10891        for (rule, triples) in self.db.engine.provenance() {
10892            if self.overlay.deleted_rules.contains(rule) {
10893                continue;
10894            }
10895            if triples.contains(&(et, src, dst)) {
10896                return true;
10897            }
10898        }
10899        false
10900    }
10901
10902    fn check_insert_node(&self, key: &str) -> Result<()> {
10903        if self.has_key(key) {
10904            Err(GraphError::DuplicateKey { key: key.into() })
10905        } else {
10906            Ok(())
10907        }
10908    }
10909
10910    fn check_live_key(&self, key: &str) -> Result<()> {
10911        if self.has_key(key) {
10912            Ok(())
10913        } else {
10914            Err(GraphError::KeyNotFound { key: key.into() })
10915        }
10916    }
10917
10918    fn prepare_insert_edge(&self, edge_type: &str, src_key: &str, dst_key: &str) -> Result<bool> {
10919        for k in [src_key, dst_key] {
10920            if !self.has_key(k) {
10921                return Err(GraphError::KeyNotFound { key: k.into() });
10922            }
10923        }
10924        if self.is_rule_owned(edge_type, src_key, dst_key) {
10925            return Err(GraphError::RuleOwned {
10926                detail: format!("edge {edge_type} {src_key}→{dst_key} is rule-owned"),
10927            });
10928        }
10929        Ok(!self.has_edge(edge_type, src_key, dst_key))
10930    }
10931
10932    fn prepare_remove_prop(&self, key: &str, field: &str) -> Result<bool> {
10933        self.check_live_key(key)?;
10934        Ok(self.has_prop(key, field))
10935    }
10936
10937    fn prepare_delete_edge(&self, edge_type: &str, src_key: &str, dst_key: &str) -> Result<bool> {
10938        for k in [src_key, dst_key] {
10939            if !self.has_key(k) {
10940                return Err(GraphError::KeyNotFound { key: k.into() });
10941            }
10942        }
10943        // Provenance-owned OR a live rule would derive this pair. User-first
10944        // edges that a later rule matches are not in `owned`, but deleting
10945        // them would leave a hole `rebuild_rule` immediately fills.
10946        if self.is_rule_owned(edge_type, src_key, dst_key) {
10947            return Err(GraphError::RuleOwned {
10948                detail: format!(
10949                    "edge {edge_type} {src_key}→{dst_key} is rule-owned; \
10950                     delete or change the owning rule"
10951                ),
10952            });
10953        }
10954        if self.would_derive(edge_type, src_key, dst_key) {
10955            return Err(GraphError::RuleOwned {
10956                detail: format!(
10957                    "edge {edge_type} {src_key}→{dst_key} is rule-owned; \
10958                     delete or change the owning rule, or a live rule would re-derive it"
10959                ),
10960            });
10961        }
10962        Ok(self.has_edge(edge_type, src_key, dst_key))
10963    }
10964
10965    /// True if any live rule (minus overlay-deleted names) would derive
10966    /// `(edge_type, src, dst)` from current overlay-visible props/labels.
10967    /// CreateRule names in `extra_rules` are ignored — same documented
10968    /// same-batch rule-window as [`Self::is_rule_owned`].
10969    fn would_derive(&self, edge_type: &str, src_key: &str, dst_key: &str) -> bool {
10970        if src_key == dst_key {
10971            return false;
10972        }
10973        let Some(src_label) = self.label_of(src_key) else {
10974            return false;
10975        };
10976        let Some(dst_label) = self.label_of(dst_key) else {
10977            return false;
10978        };
10979        for rule in self.db.engine.rules() {
10980            if self.overlay.deleted_rules.contains(&rule.name) {
10981                continue;
10982            }
10983            if rule.edge_type != edge_type {
10984                continue;
10985            }
10986            if rule.src_label != src_label || rule.dst_label != dst_label {
10987                continue;
10988            }
10989            let src_props = |f: &str| self.prop_value(src_key, f);
10990            let dst_props = |f: &str| self.prop_value(dst_key, f);
10991            let src_view = NodeView {
10992                key: src_key,
10993                props: &src_props,
10994            };
10995            let dst_view = NodeView {
10996                key: dst_key,
10997                props: &dst_props,
10998            };
10999            if evaluate(&rule.predicate, &src_view, &dst_view).is_some() {
11000                return true;
11001            }
11002        }
11003        false
11004    }
11005
11006    fn label_of(&self, key: &str) -> Option<String> {
11007        if self.overlay.deleted_keys.contains(key) {
11008            return None;
11009        }
11010        // Fresh identities created in this batch have no stored label in the
11011        // overlay; they cannot be provenance-owned yet either.
11012        let id = self.db.ids.get(key)?;
11013        let sym = self.db.labels.get(id as usize).copied()?;
11014        if sym == u32::MAX {
11015            return None;
11016        }
11017        self.db.syms.resolve(sym).map(str::to_string)
11018    }
11019
11020    fn prop_value(&self, key: &str, field: &str) -> Option<Value> {
11021        if !self.has_key(key) {
11022            return None;
11023        }
11024        let k = (key.to_string(), field.to_string());
11025        if self.overlay.removed_props.contains(&k) {
11026            return None;
11027        }
11028        if let Some(v) = self.overlay.extra_props.get(&k) {
11029            return Some(v.clone());
11030        }
11031        if self.overlay.extra_keys.contains(key) {
11032            return None;
11033        }
11034        self.db.get_prop(key, field)
11035    }
11036
11037    fn check_create_rule(&self, def: &RuleDef) -> Result<()> {
11038        def.validate()
11039            .map_err(|e| GraphError::RuleInvalid { detail: e })?;
11040        if self.has_rule(&def.name) {
11041            return Err(GraphError::RuleInvalid {
11042                detail: format!("rule {:?} already exists", def.name),
11043            });
11044        }
11045        // Rule-chain cycle rejection. Derived edges feed via-hop rules, so a
11046        // rule set forms a graph whose arcs are "hops over `via_edge`, writes
11047        // `edge_type`". A cycle in that graph is a rule set that would re-fire
11048        // itself forever; the engine's depth cap would silently truncate it
11049        // instead, leaving an arbitrary partial result. Reject it here, the one
11050        // place that sees the whole rule set.
11051        //
11052        // Rules accepted earlier in the same batch count too: the overlay
11053        // carries their arcs, so a cycle cannot be assembled one op at a time.
11054        if let Some(via) = def.via_edge.as_deref() {
11055            if via == def.edge_type {
11056                return Err(GraphError::RuleInvalid {
11057                    detail: format!("rule chain cycle: {} -> {}", via, def.edge_type),
11058                });
11059            }
11060            let mut arcs: Vec<(String, String)> = self
11061                .db
11062                .engine
11063                .rules()
11064                .filter(|r| !self.overlay.deleted_rules.contains(&r.name))
11065                .filter_map(|r| r.via_edge.clone().map(|v| (v, r.edge_type.clone())))
11066                .collect();
11067            arcs.extend(self.overlay.extra_rule_arcs.values().cloned());
11068            arcs.push((via.to_string(), def.edge_type.clone()));
11069            if let Some(path) = find_cycle_through(&arcs, &def.edge_type, via) {
11070                return Err(GraphError::RuleInvalid {
11071                    detail: format!("rule chain cycle: {} -> {}", via, path.join(" -> ")),
11072                });
11073            }
11074        }
11075        Ok(())
11076    }
11077
11078    fn check_delete_rule(&self, name: &str) -> Result<()> {
11079        if self.has_rule(name) {
11080            Ok(())
11081        } else {
11082            Err(GraphError::RuleNotFound { name: name.into() })
11083        }
11084    }
11085
11086    fn note_insert_node(&mut self, key: &str, props: &[(String, Value)]) {
11087        self.overlay.deleted_keys.remove(key);
11088        self.overlay.extra_keys.insert(key.to_string());
11089        self.overlay.extra_props.retain(|(k, _), _| k != key);
11090        self.overlay.removed_props.retain(|(k, _)| k != key);
11091        for (field, value) in props {
11092            self.overlay
11093                .extra_props
11094                .insert((key.to_string(), field.clone()), value.clone());
11095        }
11096    }
11097
11098    fn note_insert_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) {
11099        let k = (
11100            edge_type.to_string(),
11101            src_key.to_string(),
11102            dst_key.to_string(),
11103        );
11104        self.overlay.deleted_edges.remove(&k);
11105        self.overlay.extra_edges.insert(k);
11106    }
11107
11108    fn note_set_prop(&mut self, key: &str, field: &str, value: &Value) {
11109        let k = (key.to_string(), field.to_string());
11110        self.overlay.removed_props.remove(&k);
11111        self.overlay.extra_props.insert(k, value.clone());
11112    }
11113
11114    fn note_remove_prop(&mut self, key: &str, field: &str) {
11115        let k = (key.to_string(), field.to_string());
11116        self.overlay.extra_props.remove(&k);
11117        self.overlay.removed_props.insert(k);
11118    }
11119
11120    fn note_delete_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) {
11121        let k = (
11122            edge_type.to_string(),
11123            src_key.to_string(),
11124            dst_key.to_string(),
11125        );
11126        self.overlay.extra_edges.remove(&k);
11127        self.overlay.deleted_edges.insert(k);
11128    }
11129
11130    fn note_delete_node(&mut self, key: &str) {
11131        self.overlay.extra_keys.remove(key);
11132        self.overlay.deleted_keys.insert(key.to_string());
11133        self.overlay.extra_props.retain(|(k, _), _| k != key);
11134        self.overlay.removed_props.retain(|(k, _)| k != key);
11135        self.overlay
11136            .extra_edges
11137            .retain(|(_, s, d)| s != key && d != key);
11138        self.overlay
11139            .deleted_edges
11140            .retain(|(_, s, d)| s != key && d != key);
11141    }
11142
11143    fn note_create_rule(&mut self, def: &RuleDef) {
11144        self.overlay.deleted_rules.remove(&def.name);
11145        self.overlay.extra_rules.insert(def.name.clone());
11146        // Rules accepted earlier in this batch are not in the engine yet, so
11147        // the cycle check would not see their arcs. Keep the arc, not just the
11148        // name, so a batch cannot smuggle in a cycle one op at a time.
11149        if let Some(via) = def.via_edge.clone() {
11150            self.overlay
11151                .extra_rule_arcs
11152                .insert(def.name.clone(), (via, def.edge_type.clone()));
11153        }
11154    }
11155
11156    fn check_rename_node(&self, old: &str, new: &str) -> Result<()> {
11157        if !self.has_key(old) {
11158            return Err(GraphError::KeyNotFound { key: old.into() });
11159        }
11160        if self.has_key(new) {
11161            return Err(GraphError::DuplicateKey { key: new.into() });
11162        }
11163        Ok(())
11164    }
11165
11166    fn note_rename_node(&mut self, old: &str, new: &str) {
11167        // Mark old as deleted so subsequent batch ops cannot reference it.
11168        self.overlay.extra_keys.remove(old);
11169        self.overlay.deleted_keys.insert(old.to_string());
11170        // Mark new as extra so subsequent batch ops can reference it.
11171        self.overlay.deleted_keys.remove(new);
11172        self.overlay.extra_keys.insert(new.to_string());
11173        // Migrate any overlay props from old key to new key.
11174        let new_str = new.to_string();
11175        let transferred: Vec<((String, String), Value)> = self
11176            .overlay
11177            .extra_props
11178            .iter()
11179            .filter(|((k, _), _)| k.as_str() == old)
11180            .map(|((_, f), v)| ((new_str.clone(), f.clone()), v.clone()))
11181            .collect();
11182        self.overlay
11183            .extra_props
11184            .retain(|(k, _), _| k.as_str() != old);
11185        for (k, v) in transferred {
11186            self.overlay.extra_props.insert(k, v);
11187        }
11188        // Migrate removed_props.
11189        let transferred_removed: Vec<(String, String)> = self
11190            .overlay
11191            .removed_props
11192            .iter()
11193            .filter(|(k, _)| k.as_str() == old)
11194            .map(|(_, f)| (new_str.clone(), f.clone()))
11195            .collect();
11196        self.overlay
11197            .removed_props
11198            .retain(|(k, _)| k.as_str() != old);
11199        for k in transferred_removed {
11200            self.overlay.removed_props.insert(k);
11201        }
11202    }
11203
11204    fn note_delete_rule(&mut self, name: &str) {
11205        self.overlay.extra_rules.remove(name);
11206        // Drop its chain arc too: a rule created and then deleted in the same
11207        // batch must not make a later, legal rule look like a cycle.
11208        self.overlay.extra_rule_arcs.remove(name);
11209        self.overlay.deleted_rules.insert(name.to_string());
11210        // Treat the deleted rule's current provenance as gone so a later
11211        // delete_edge of those triples is a no-op (matches sequential).
11212        if let Some(triples) = self.db.engine.provenance().get(name) {
11213            for &(et, s, d) in triples {
11214                let Some(etype) = self.db.syms.resolve(et) else {
11215                    continue;
11216                };
11217                let Some(src) = self.db.ids.key_of(s) else {
11218                    continue;
11219                };
11220                let Some(dst) = self.db.ids.key_of(d) else {
11221                    continue;
11222                };
11223                let k = (etype.to_string(), src.to_string(), dst.to_string());
11224                self.overlay.extra_edges.remove(&k);
11225                self.overlay.deleted_edges.insert(k);
11226            }
11227        }
11228    }
11229}
11230
11231/// Collects mutations and commits them as one WAL `Batch` frame.
11232///
11233/// Holds `&mut GraphDb` for its lifetime. Queue with the same method names
11234/// as [`GraphDb`]; call [`commit`](Self::commit) to validate, log, and apply.
11235/// See [`GraphDb::batch`] for validation and atomicity rules.
11236pub struct BatchBuilder<'a, F: Fs> {
11237    db: &'a mut GraphDb<F>,
11238    ops: Vec<BatchOp>,
11239}
11240
11241impl<'a, F: Fs> BatchBuilder<'a, F> {
11242    pub fn insert_node(
11243        &mut self,
11244        label: &str,
11245        key: &str,
11246        props: Vec<(String, Value)>,
11247    ) -> &mut Self {
11248        self.ops.push(BatchOp::InsertNode {
11249            label: label.into(),
11250            key: key.into(),
11251            props,
11252        });
11253        self
11254    }
11255
11256    pub fn insert_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) -> &mut Self {
11257        self.ops.push(BatchOp::InsertEdge {
11258            edge_type: edge_type.into(),
11259            src_key: src_key.into(),
11260            dst_key: dst_key.into(),
11261        });
11262        self
11263    }
11264
11265    pub fn set_prop(&mut self, key: &str, field: &str, value: Value) -> &mut Self {
11266        self.ops.push(BatchOp::SetProp {
11267            key: key.into(),
11268            field: field.into(),
11269            value,
11270        });
11271        self
11272    }
11273
11274    pub fn remove_prop(&mut self, key: &str, field: &str) -> &mut Self {
11275        self.ops.push(BatchOp::RemoveProp {
11276            key: key.into(),
11277            field: field.into(),
11278        });
11279        self
11280    }
11281
11282    pub fn delete_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) -> &mut Self {
11283        self.ops.push(BatchOp::DeleteEdge {
11284            edge_type: edge_type.into(),
11285            src_key: src_key.into(),
11286            dst_key: dst_key.into(),
11287        });
11288        self
11289    }
11290
11291    pub fn delete_node(&mut self, key: &str) -> &mut Self {
11292        self.ops.push(BatchOp::DeleteNode { key: key.into() });
11293        self
11294    }
11295
11296    pub fn create_rule(&mut self, def: RuleDef) -> &mut Self {
11297        self.ops.push(BatchOp::CreateRule(def));
11298        self
11299    }
11300
11301    pub fn delete_rule(&mut self, name: &str) -> &mut Self {
11302        self.ops.push(BatchOp::DeleteRule { name: name.into() });
11303        self
11304    }
11305
11306    /// Queue a node-rename in this batch.
11307    ///
11308    /// Validation (old exists, new not taken) runs at commit time.
11309    pub fn rename_node(&mut self, old_key: &str, new_key: &str) -> &mut Self {
11310        self.ops.push(BatchOp::RenameNode {
11311            old_key: old_key.into(),
11312            new_key: new_key.into(),
11313        });
11314        self
11315    }
11316
11317    /// Queue an edge insert with endpoint auto-creation.
11318    ///
11319    /// Any missing endpoint is created as a plain node `{key, label:
11320    /// placeholder_label, no props}` inside this batch frame. Rules fire and
11321    /// last-change is updated for each auto-created node.
11322    pub fn insert_edge_upsert(
11323        &mut self,
11324        edge_type: &str,
11325        src_key: &str,
11326        dst_key: &str,
11327        placeholder_label: &str,
11328    ) -> &mut Self {
11329        self.ops.push(BatchOp::InsertEdgeUpsert {
11330            edge_type: edge_type.into(),
11331            src_key: src_key.into(),
11332            dst_key: dst_key.into(),
11333            placeholder_label: placeholder_label.into(),
11334        });
11335        self
11336    }
11337
11338    /// Validate every queued op, then log one `Batch` frame and apply.
11339    /// Empty / all-noop batches return `Ok(())` without writing the WAL.
11340    /// A second `commit()` after a successful one is an empty-batch no-op
11341    /// (queued ops were taken).
11342    /// Takes `&mut self` so it chains after the queue methods (`b.insert_node(..).commit()`)
11343    /// and also works as `let mut b = db.batch(); b.insert_node(..); b.commit()`.
11344    ///
11345    /// **Rule-window limitation:** batch validation cannot see edges that a
11346    /// rule created earlier in the *same* batch will derive at apply time, so
11347    /// a `delete_edge` / `insert_edge` in that window is silently no-oped
11348    /// where sequential calls would return `Err(RuleOwned)`. State integrity
11349    /// is unaffected (idempotent apply, provenance intact). Create rules in
11350    /// their own batch, or sequentially, when later ops may touch derived
11351    /// edges.
11352    /// Validate every queued op and commit atomically.
11353    ///
11354    /// Returns `(nodes_inserted, edges_inserted)` — the counts of node and edge
11355    /// WAL records actually written (duplicate edges are silent no-ops and are
11356    /// NOT counted). Both are 0 when the batch is empty or all-noop.
11357    pub fn commit(&mut self) -> Result<(usize, usize)> {
11358        let ops = std::mem::take(&mut self.ops);
11359        self.db.commit_batch(ops)
11360    }
11361
11362    /// Same as [`commit`](Self::commit) but tail the inner events with
11363    /// [`MutationEvent::Ingested`] instead of [`MutationEvent::BatchApplied`].
11364    pub(crate) fn commit_ingest(&mut self, label: &str, inserted: usize) -> Result<(usize, usize)> {
11365        let ops = std::mem::take(&mut self.ops);
11366        self.db
11367            .commit_logged_batch(ops, Some((label.to_string(), inserted)), None)
11368    }
11369}
11370
11371pub struct NodeRef<'a, F: Fs> {
11372    db: &'a GraphDb<F>,
11373    id: u32,
11374}
11375
11376impl<'a, F: Fs> NodeRef<'a, F> {
11377    pub fn key(&self) -> &str {
11378        self.db.ids.key_of(self.id).expect("dense ids")
11379    }
11380
11381    pub fn label(&self) -> &str {
11382        let sym = self
11383            .db
11384            .labels
11385            .get(self.id as usize)
11386            .copied()
11387            .filter(|&s| s != u32::MAX)
11388            .expect("real nodes always have a label; u32::MAX sentinel cannot occur");
11389        self.db.syms.resolve(sym).expect("interned label symbol")
11390    }
11391
11392    pub fn prop(&self, field: &str) -> Option<Value> {
11393        self.db
11394            .props_view()
11395            .get(self.id, field)
11396            .map(|vr| vr.into_value())
11397    }
11398
11399    /// All stored fields for this node, sorted by field name.
11400    ///
11401    /// Reads from the full base+overlay view so that props stored only in the
11402    /// V8 snapshot base (i.e. before any post-snapshot WAL writes) are visible.
11403    pub fn props(&self) -> BTreeMap<String, Value> {
11404        let mut out = BTreeMap::new();
11405        let pv = self.db.props_view();
11406        for field in pv.field_names() {
11407            if let Some(vr) = pv.get(self.id, &field) {
11408                out.insert(field, vr.into_value());
11409            }
11410        }
11411        out
11412    }
11413
11414    /// depth-N BFS as a ResultSet: columns ["key","label","depth"], BFS order.
11415    pub fn neighborhood(&self, depth: u32, edge_types: Option<&[&str]>, dir: Dir) -> ResultSet {
11416        let view = self.db.view();
11417        let resolved: Option<Vec<u32>> = edge_types.map(|names| {
11418            names
11419                .iter()
11420                .filter_map(|name| view.syms.get(name))
11421                .collect()
11422        });
11423        let nb = neighborhood(&view, self.id, depth, resolved.as_deref(), dir);
11424        let mut rs = ResultSet::new(vec!["key".into(), "label".into(), "depth".into()]);
11425        for (nid, d) in nb.nodes {
11426            let key = view.key_of(nid);
11427            let label = view
11428                .label_of(nid)
11429                .expect("real nodes always have a label; u32::MAX sentinel cannot occur");
11430            rs.push_row(vec![
11431                Some(Value::Str(key.to_string())),
11432                Some(Value::Str(label.to_string())),
11433                Some(Value::Int(d as i64)),
11434            ]);
11435        }
11436        rs
11437    }
11438
11439    /// 1-hop, Both directions: edge-type name → sorted unique neighbor keys.
11440    pub fn grouped_by_edge_type(&self) -> BTreeMap<String, Vec<String>> {
11441        let view = self.db.view();
11442        let mut groups: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
11443        for e in expand(&view, self.id, None, Dir::Both) {
11444            // Skip edges with unknown etypes (only possible from corrupt large
11445            // TOPOLOGY section; function returns BTreeMap not Result).
11446            let Some(etype) = view.syms.resolve(e.etype) else {
11447                continue;
11448            };
11449            let etype = etype.to_string();
11450            let nbr = if e.src == self.id { e.dst } else { e.src };
11451            groups
11452                .entry(etype)
11453                .or_default()
11454                .insert(view.key_of(nbr).to_string());
11455        }
11456        groups
11457            .into_iter()
11458            .map(|(k, v)| (k, v.into_iter().collect()))
11459            .collect()
11460    }
11461}
11462
11463#[cfg(test)]
11464mod tests {
11465    use super::*;
11466    use core_rules::Predicate;
11467
11468    fn tmp_dir(name: &str) -> std::path::PathBuf {
11469        let d =
11470            std::env::temp_dir().join(format!("graphdb-db-unit-{}-{}", name, std::process::id()));
11471        let _ = std::fs::remove_dir_all(&d);
11472        d
11473    }
11474
11475    fn fk_rule() -> RuleDef {
11476        RuleDef {
11477            name: "works_at".into(),
11478            src_label: "Person".into(),
11479            dst_label: "Org".into(),
11480            predicate: Predicate::KeyMatch {
11481                field: "org_id".into(),
11482            },
11483            edge_type: "WORKS_AT".into(),
11484            weight_prop: None,
11485            max_edges: None,
11486            approximate: false,
11487            via_label: None,
11488            via_edge: None,
11489            via_dir: None,
11490        }
11491    }
11492
11493    /// Regression guard for the no-views delta-copy fast path.
11494    ///
11495    /// When no views are defined, `pending_deltas_since().to_vec()` must never
11496    /// be called — even during a large CreateRule backfill. The DELTA_COPY_COUNT
11497    /// thread-local is incremented inside every `if !view_store.is_empty()` block;
11498    /// a count of 0 after the entire sequence proves the guard fires correctly.
11499    #[test]
11500    fn no_delta_copy_when_no_views() {
11501        DELTA_COPY_COUNT.with(|c| c.set(0));
11502        let dir = tmp_dir("no-delta-copy");
11503        {
11504            let mut db = GraphDb::open(&dir).unwrap();
11505            // Insert 50 Org + 50 Person nodes with FK links.
11506            for i in 0..50u32 {
11507                db.insert_node("Org", &format!("o{i}"), vec![]).unwrap();
11508            }
11509            for i in 0..50u32 {
11510                db.insert_node(
11511                    "Person",
11512                    &format!("p{i}"),
11513                    vec![("org_id".into(), Value::Str(format!("o{i}")))],
11514                )
11515                .unwrap();
11516            }
11517            // CreateRule backfill should NOT invoke to_vec() when no views are defined.
11518            db.create_rule(fk_rule()).unwrap();
11519
11520            // Counter must stay 0 — no views, no copies.
11521            let copies = DELTA_COPY_COUNT.with(|c| c.get());
11522            assert_eq!(
11523                copies, 0,
11524                "pending_deltas_since().to_vec() called despite no views"
11525            );
11526
11527            // Derived edges must still be correct (the guard skips only the
11528            // empty delta propagation loop, not the rule application itself).
11529            let nbrs = db.neighbors("p0", "WORKS_AT", Direction::Out).unwrap();
11530            assert_eq!(
11531                nbrs,
11532                vec!["o0"],
11533                "rule must derive edges even with no views"
11534            );
11535        }
11536        let _ = std::fs::remove_dir_all(&dir);
11537    }
11538
11539    /// Gating regression: subscribe AFTER a backfill must see no stale events.
11540    /// subscribe BEFORE a backfill must see every edge-fire event.
11541    #[test]
11542    fn subscribe_after_backfill_no_stale_events() {
11543        let dir = tmp_dir("sub-after-backfill");
11544        {
11545            let mut db = GraphDb::open(&dir).unwrap();
11546            for i in 0..10u32 {
11547                db.insert_node("Org", &format!("o{i}"), vec![]).unwrap();
11548                db.insert_node(
11549                    "Person",
11550                    &format!("p{i}"),
11551                    vec![("org_id".into(), Value::Str(format!("o{i}")))],
11552                )
11553                .unwrap();
11554            }
11555            // Create rule BEFORE subscribing — emit_deltas is false during backfill.
11556            db.create_rule(fk_rule()).unwrap();
11557
11558            // Subscribe AFTER the backfill — queue must be empty (no stale events).
11559            let sub = db.subscribe_all_rules().unwrap();
11560            // No events should have queued for the prior backfill.
11561            assert!(
11562                sub.try_recv().is_none(),
11563                "subscribe after backfill must see no stale events"
11564            );
11565
11566            // Inserting a new node now should fire an event (emit_deltas is now true).
11567            db.insert_node("Org", "o_new", vec![]).unwrap();
11568            db.insert_node(
11569                "Person",
11570                "p_new",
11571                vec![("org_id".into(), Value::Str("o_new".into()))],
11572            )
11573            .unwrap();
11574            let ev = sub.recv_timeout(std::time::Duration::from_millis(200));
11575            assert!(
11576                ev.is_some(),
11577                "edge-fire event must arrive after subscribe (emit_deltas=true)"
11578            );
11579        }
11580        let _ = std::fs::remove_dir_all(&dir);
11581    }
11582
11583    /// Gating regression: subscribe BEFORE a backfill → events flow.
11584    #[test]
11585    fn subscribe_before_backfill_events_flow() {
11586        let dir = tmp_dir("sub-before-backfill");
11587        {
11588            let mut db = GraphDb::open(&dir).unwrap();
11589            // Subscribe FIRST — emit_deltas becomes true.
11590            let sub = db.subscribe_all_rules().unwrap();
11591
11592            for i in 0..5u32 {
11593                db.insert_node("Org", &format!("o{i}"), vec![]).unwrap();
11594                db.insert_node(
11595                    "Person",
11596                    &format!("p{i}"),
11597                    vec![("org_id".into(), Value::Str(format!("o{i}")))],
11598                )
11599                .unwrap();
11600            }
11601            // Backfill fires with emit_deltas=true → events queued.
11602            db.create_rule(fk_rule()).unwrap();
11603
11604            // Should receive at least one edge-fired event from the backfill.
11605            let mut received = 0usize;
11606            while sub.try_recv().is_some() {
11607                received += 1;
11608            }
11609            assert!(
11610                received > 0,
11611                "subscribe before backfill must receive edge-fire events (got 0)"
11612            );
11613        }
11614        let _ = std::fs::remove_dir_all(&dir);
11615    }
11616
11617    /// Companion: when a view IS defined, the delta path fires and view values update.
11618    #[test]
11619    fn delta_copy_fires_when_view_exists() {
11620        use core_rules::ViewSource;
11621        DELTA_COPY_COUNT.with(|c| c.set(0));
11622        let dir = tmp_dir("delta-copy-with-view");
11623        {
11624            let mut db = GraphDb::open(&dir).unwrap();
11625            db.insert_node("Org", "o1", vec![]).unwrap();
11626            db.insert_node(
11627                "Person",
11628                "p1",
11629                vec![("org_id".into(), Value::Str("o1".into()))],
11630            )
11631            .unwrap();
11632            // Declare a Degree view so is_empty() returns false.
11633            db.create_view(ViewDef {
11634                name: "degree_out".into(),
11635                label: "Person".into(),
11636                view_prop: "degree_out".into(),
11637                source: ViewSource::Degree {
11638                    edge_type: "WORKS_AT".into(),
11639                    direction: Direction::Out,
11640                },
11641            })
11642            .unwrap();
11643            db.create_rule(fk_rule()).unwrap();
11644
11645            // At least one delta copy should have happened (CreateRule backfill).
11646            let copies = DELTA_COPY_COUNT.with(|c| c.get());
11647            assert!(
11648                copies > 0,
11649                "expected delta copy to fire when a view is defined"
11650            );
11651
11652            // View value should be computed: p1 has one WORKS_AT out-edge.
11653            let info = db.node_info("p1").unwrap();
11654            let degree = info.props.get("degree_out");
11655            assert!(
11656                degree.is_some(),
11657                "view prop should be written to node props"
11658            );
11659        }
11660        let _ = std::fs::remove_dir_all(&dir);
11661    }
11662
11663    /// Regression: `open_at_with` must call `rebuild_all` after WAL replay so
11664    /// derived-edge-driven view values reflect the as-of state rather than just
11665    /// the initial backfill written at `CreateView` time.
11666    ///
11667    /// Base WAL frames (indices 0..=5 before history markers):
11668    ///   0: insert Org "o1"
11669    ///   1: create_view "employee_count" (Degree / WORKS_AT / In) on Org
11670    ///   2: create_rule fk_rule (WORKS_AT, Person→Org via org_id)
11671    ///   3: insert Person "p1" → rule fires WORKS_AT p1→o1 (degree = 1)  ← mid
11672    ///   4: insert Person "p2" → rule fires WORKS_AT p2→o1 (degree = 2)
11673    ///   5: insert Person "p3" → rule fires WORKS_AT p3→o1 (degree = 3)  ← latest
11674    ///
11675    /// Each rule-fire also appends a DerivedEdgeAdded history-marker frame (state
11676    /// no-op), so the total commit count is higher than the base frame count.
11677    /// The "latest" open_at commit is computed dynamically via `wal_commit_count_at`.
11678    ///
11679    /// Without `rebuild_all`, the as-of instance's "emp" view stays at the
11680    /// initial backfill value (0) instead of reflecting the replayed derived edges.
11681    #[test]
11682    fn open_at_derived_edge_view_values_correct() {
11683        use core_rules::ViewSource;
11684        let dir = tmp_dir("open-at-view-rebuild");
11685        {
11686            let mut db = GraphDb::open(&dir).unwrap();
11687            // frame 0
11688            db.insert_node("Org", "o1", vec![]).unwrap();
11689            // frame 1: create view — initial backfill sees 0 derived edges (none fired yet)
11690            db.create_view(ViewDef {
11691                name: "employee_count".into(),
11692                label: "Org".into(),
11693                view_prop: "emp".into(),
11694                source: ViewSource::Degree {
11695                    edge_type: "WORKS_AT".into(),
11696                    direction: Direction::In,
11697                },
11698            })
11699            .unwrap();
11700            // frame 2: create rule — no Persons yet; backfill is a no-op
11701            db.create_rule(fk_rule()).unwrap();
11702            // frame 3: p1 — rule fires WORKS_AT p1→o1; degree = 1
11703            db.insert_node(
11704                "Person",
11705                "p1",
11706                vec![("org_id".into(), Value::Str("o1".into()))],
11707            )
11708            .unwrap();
11709            // frame 4: p2 — degree = 2
11710            db.insert_node(
11711                "Person",
11712                "p2",
11713                vec![("org_id".into(), Value::Str("o1".into()))],
11714            )
11715            .unwrap();
11716            // frame 5: p3 — degree = 3
11717            db.insert_node(
11718                "Person",
11719                "p3",
11720                vec![("org_id".into(), Value::Str("o1".into()))],
11721            )
11722            .unwrap();
11723            // Sanity: normal open sees degree = 3.
11724            assert_eq!(
11725                db.get_view_prop("o1", "emp"),
11726                Some(Value::Int(3)),
11727                "normal db must show degree 3 after 3 derived edges"
11728            );
11729        } // WAL flushed
11730
11731        // Re-open normally to get the authoritative reference value.
11732        let normal_db = GraphDb::open(&dir).unwrap();
11733        let normal_emp = normal_db.get_view_prop("o1", "emp");
11734        assert_eq!(
11735            normal_emp,
11736            Some(Value::Int(3)),
11737            "re-opened normal db must show degree 3"
11738        );
11739
11740        // Latest as-of (last WAL commit): must match the normal open.
11741        // History-marker frames are appended after each rule-fire, so the total
11742        // commit count is computed dynamically rather than hardcoded.
11743        let total = crate::wal_commit_count_at(&dir).unwrap();
11744        let aof_latest = GraphDb::open_at(&dir, total - 1).unwrap();
11745        assert_eq!(
11746            aof_latest.get_view_prop("o1", "emp"),
11747            normal_emp,
11748            "open_at latest: derived-edge view must equal normal open (rebuild_all required)"
11749        );
11750
11751        // Mid-history as-of (commit 3 = p1 insert Batch frame): only p1; degree = 1.
11752        // The DerivedEdgeAdded marker for p1 is at frame 4 (state no-op on replay),
11753        // so replaying 0..=3 correctly re-derives only the p1→o1 edge.
11754        let aof_mid = GraphDb::open_at(&dir, 3).unwrap();
11755        assert_eq!(
11756            aof_mid.get_view_prop("o1", "emp"),
11757            Some(Value::Int(1)),
11758            "open_at mid-history: only p1 exists at frame 3, degree must be 1"
11759        );
11760
11761        let _ = std::fs::remove_dir_all(&dir);
11762    }
11763
11764    /// Pin: subscribe_* on an as-of instance must return Err(ReadOnly) —
11765    /// as-of instances never commit, so distribute_events never runs and any
11766    /// subscription would wait forever.
11767    #[test]
11768    fn subscribe_on_as_of_returns_read_only_error() {
11769        let dir = tmp_dir("sub-as-of-read-only");
11770        {
11771            let mut db = GraphDb::open(&dir).unwrap();
11772            db.insert_node("Org", "o1", vec![]).unwrap();
11773            db.create_rule(fk_rule()).unwrap();
11774        }
11775        let mut aof = GraphDb::open_at(&dir, 0).unwrap();
11776
11777        assert!(
11778            matches!(
11779                aof.subscribe_all_rules(),
11780                Err(core_storage::GraphError::ReadOnly)
11781            ),
11782            "subscribe_all_rules on as-of must return ReadOnly"
11783        );
11784        assert!(
11785            matches!(
11786                aof.subscribe_writes(),
11787                Err(core_storage::GraphError::ReadOnly)
11788            ),
11789            "subscribe_writes on as-of must return ReadOnly"
11790        );
11791        assert!(
11792            matches!(
11793                aof.subscribe_rule("works_at"),
11794                Err(core_storage::GraphError::ReadOnly)
11795            ),
11796            "subscribe_rule on as-of must return ReadOnly"
11797        );
11798        let _ = std::fs::remove_dir_all(&dir);
11799    }
11800
11801    /// Regression: a failed dense WAL rewrite must not leave speculative
11802    /// interns in `syms`. If it does, the next successful mutation logs an
11803    /// `Intern` record with an inflated id; replay (which never saw the
11804    /// orphans) assigns a smaller id and the WAL becomes unreplayable.
11805    #[test]
11806    fn dense_rewrite_error_rolls_back_speculative_interns() {
11807        let dir = tmp_dir("dense-rewrite-rollback");
11808        {
11809            let mut db = GraphDb::open(&dir).unwrap();
11810            db.insert_node("Person", "a", vec![]).unwrap();
11811
11812            // Bypass MutPreview validation to hit the rewrite's own error path
11813            // (same shape as an id-exhaustion failure mid-rewrite). The
11814            // InsertEdge arm interns the edge type before it resolves keys.
11815            let err = db.rewrite_wal_dense(vec![WalRecord::InsertEdge {
11816                edge_type: "ORPHAN_TYPE".into(),
11817                src_key: "missing".into(),
11818                dst_key: "a".into(),
11819            }]);
11820            assert!(err.is_err(), "rewrite of a missing src key must fail");
11821            assert_eq!(
11822                db.syms.get("ORPHAN_TYPE"),
11823                None,
11824                "failed rewrite must roll back speculative interns"
11825            );
11826
11827            // A later successful mutation must produce a replayable WAL.
11828            db.set_prop("a", "later_field", Value::Int(2)).unwrap();
11829        }
11830        let db = GraphDb::open(&dir).expect("WAL must replay after failed rewrite");
11831        assert_eq!(db.get_prop("a", "later_field"), Some(Value::Int(2)));
11832        let _ = std::fs::remove_dir_all(&dir);
11833    }
11834}