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, BuildProgress, EngineEdgeDelta, GraphMut, NodeView, Predicate,
15    RuleDef, RuleEngine, 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    namespace_of_value, ColumnStore, Direction, EdgeProps, GraphError, IdMap, Interner, Result,
29    Topology, Value,
30};
31pub use core_storage::{valid_namespace, NS_DEFAULT, NS_MAX_LEN, NS_PROP};
32
33/// Index of [`NS_DEFAULT`] in `GraphDb::ns_names` — always zero, so the
34/// open-time pass over a store with no `ns` column fills `node_ns` with one
35/// constant and allocates no names.
36const NS_DEFAULT_IDX: u32 = 0;
37use serde::{Deserialize, Serialize};
38use std::collections::{BTreeMap, BTreeSet, HashMap};
39use std::sync::Arc;
40
41/// Print a timing checkpoint when MUSHROOMDB_TRACE_OPEN is set.
42/// Zero-cost when the env var is absent (the var check is O(1) after first call).
43macro_rules! trace_open {
44    ($phase:literal, $t:expr) => {
45        if std::env::var("MUSHROOMDB_TRACE_OPEN").is_ok() {
46            eprintln!(
47                "[MUSHROOMDB_TRACE_OPEN] {:40} {:>9.3?}",
48                $phase,
49                $t.elapsed()
50            );
51        }
52    };
53}
54
55/// Print a migration phase checkpoint when MUSHROOMDB_TRACE_MIGRATE is set.
56/// Zero-cost when the env var is absent (the var check is O(1) after first call).
57macro_rules! trace_migrate {
58    ($phase:literal, $t:expr) => {
59        if std::env::var("MUSHROOMDB_TRACE_MIGRATE").is_ok() {
60            eprintln!(
61                "[MUSHROOMDB_TRACE_MIGRATE] {:40} {:>9.3?}",
62                $phase,
63                $t.elapsed()
64            );
65        }
66    };
67}
68
69// Test-only: counts how many times `pending_deltas_since().to_vec()` actually
70// executes (i.e., at least one view is defined). Used to verify the fast-path
71// guard skips the allocation when `view_store.is_empty()`.
72#[cfg(test)]
73thread_local! {
74    static DELTA_COPY_COUNT: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
75}
76
77// Per-thread count of query-subscription `execute` calls in `distribute_events`.
78//
79// Incremented each time a query subscription actually runs its plan (i.e.,
80// the label-skip fast-path did not fire). Because `distribute_events` is
81// called synchronously on the writer thread, this thread-local correctly
82// isolates each test thread's count even when integration tests run in
83// parallel. Read via [`query_sub_exec_count`].
84thread_local! {
85    static QUERY_SUB_EXECS_TL: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
86}
87
88/// Return the number of query-subscription re-executions logged on this
89/// thread since the process started (or since last reset via
90/// [`reset_query_sub_exec_count`]).
91///
92/// Primarily for integration tests that verify the label-skip fast-path.
93#[doc(hidden)]
94pub fn query_sub_exec_count() -> usize {
95    QUERY_SUB_EXECS_TL.with(|c| c.get())
96}
97
98/// Reset the per-thread query-subscription execution counter to zero.
99#[doc(hidden)]
100pub fn reset_query_sub_exec_count() {
101    QUERY_SUB_EXECS_TL.with(|c| c.set(0));
102}
103
104/// Internal state for a single `subscribe_query` subscription.
105///
106/// On every commit, `distribute_events` re-executes `ops` against the current
107/// graph state, diffs the result against `prev_rows`, and pushes
108/// `DbEvent::QueryRowAdded` / `QueryRowRemoved` events to `inner`.
109///
110/// **Full re-run per commit; use LIMIT to bound execution cost.**
111/// (Differential evaluation is roadmap / Phase 5.)
112pub(crate) struct QuerySubEntry {
113    /// Compiled plan for the subscribed Cypher query.
114    ops: Vec<PlanOp>,
115    /// Column names from the first execution (fixed for the subscription lifetime).
116    columns: Vec<String>,
117    /// Serialized (JSON) row key → row data, representing the result set at
118    /// the end of the last commit. Used to diff against the new result.
119    prev_row_map: std::collections::HashMap<String, Vec<Option<Value>>>,
120    /// Weak pointer to the subscriber queue; dead Weak → subscription dropped.
121    inner: std::sync::Weak<SubInner>,
122    /// Interned label sym captured at subscribe time from the plan's leading scan
123    /// (`ScanLabel`, `IndexScan`, or `IndexIntersect` with a concrete label).
124    ///
125    /// `None` means the plan has an `Expand` op (or no recognizable leading scan
126    /// with a concrete label), and this subscription must re-execute on every
127    /// commit without skipping. This is the conservative v0.4.3 boundary: Expand
128    /// queries are never skipped because edges can alter join results regardless
129    /// of which node labels were written.
130    scan_label: Option<u32>,
131}
132
133/// A post-commit mutation notification.
134///
135/// Emitted from `log_then_apply` after the WAL append, fsync, and
136/// in-memory `apply` all succeed. Never emitted for rejected operations
137/// (validation errors, [`GraphError::RuleOwned`], duplicate keys, no-op
138/// deletes/removes). Event payloads carry user keys and rule names, never
139/// internal ids.
140///
141/// **Replay:** [`GraphDb::open`] / [`GraphDb::open_with`] replay the WAL via
142/// `apply` only. Emission lives exclusively in `log_then_apply`, so
143/// recovery is silent even if a sink were installed (it cannot be: the
144/// sink is in-memory and set after open).
145///
146/// **Ordering:** a `Batch` WAL frame emits one event per inner record, then
147/// [`MutationEvent::BatchApplied`]. An ingest commit emits those same inner
148/// events, then [`MutationEvent::Ingested`] (not `BatchApplied`). An empty
149/// or all-noop batch writes no WAL and emits nothing (including no summary).
150///
151/// **Derived edges:** rule-created or retracted edges are not individually
152/// evented — they are recoverable from the triggering mutation plus the live
153/// rule set. Only the triggering record is emitted.
154///
155/// **Wire form:** externally tagged snake_case JSON
156/// (`{"node_inserted":{"label":"A","key":"k"}}`).
157#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
158#[serde(rename_all = "snake_case")]
159pub enum MutationEvent {
160    NodeInserted {
161        label: String,
162        key: String,
163    },
164    PropSet {
165        key: String,
166        field: String,
167    },
168    PropRemoved {
169        key: String,
170        field: String,
171    },
172    EdgeInserted {
173        edge_type: String,
174        src: String,
175        dst: String,
176    },
177    EdgeDeleted {
178        edge_type: String,
179        src: String,
180        dst: String,
181    },
182    NodeDeleted {
183        key: String,
184    },
185    RuleCreated {
186        name: String,
187    },
188    RuleDeleted {
189        name: String,
190    },
191    RuleRebuilt {
192        name: String,
193    },
194    BatchApplied {
195        ops: usize,
196    },
197    Ingested {
198        label: String,
199        inserted: usize,
200    },
201}
202
203fn event_from_record(rec: &WalRecord, intern: &Interner, ids: &IdMap) -> Option<MutationEvent> {
204    match rec {
205        WalRecord::InsertNode { label, key, .. } => Some(MutationEvent::NodeInserted {
206            label: label.clone(),
207            key: key.clone(),
208        }),
209        WalRecord::InsertNodeId { label, key, .. } => Some(MutationEvent::NodeInserted {
210            label: intern.resolve(*label)?.to_string(),
211            key: key.clone(),
212        }),
213        WalRecord::SetProp { key, field, .. } => Some(MutationEvent::PropSet {
214            key: key.clone(),
215            field: field.clone(),
216        }),
217        WalRecord::SetPropId { id, field, .. } => Some(MutationEvent::PropSet {
218            key: ids.key_of(*id)?.to_string(),
219            field: intern.resolve(*field)?.to_string(),
220        }),
221        WalRecord::RemoveProp { key, field } => Some(MutationEvent::PropRemoved {
222            key: key.clone(),
223            field: field.clone(),
224        }),
225        WalRecord::InsertEdge {
226            edge_type,
227            src_key,
228            dst_key,
229        } => Some(MutationEvent::EdgeInserted {
230            edge_type: edge_type.clone(),
231            src: src_key.clone(),
232            dst: dst_key.clone(),
233        }),
234        WalRecord::InsertEdgeId { etype, src, dst } => Some(MutationEvent::EdgeInserted {
235            edge_type: intern.resolve(*etype)?.to_string(),
236            src: ids.key_of(*src)?.to_string(),
237            dst: ids.key_of(*dst)?.to_string(),
238        }),
239        WalRecord::DeleteEdge {
240            edge_type,
241            src_key,
242            dst_key,
243        } => Some(MutationEvent::EdgeDeleted {
244            edge_type: edge_type.clone(),
245            src: src_key.clone(),
246            dst: dst_key.clone(),
247        }),
248        WalRecord::DeleteNode { key } => Some(MutationEvent::NodeDeleted { key: key.clone() }),
249        WalRecord::CreateRule { def_bytes } => {
250            let def: RuleDef = decode_rule_def(def_bytes).ok()?;
251            Some(MutationEvent::RuleCreated { name: def.name })
252        }
253        WalRecord::DeleteRule { name } => Some(MutationEvent::RuleDeleted { name: name.clone() }),
254        WalRecord::RebuildRule { name } => Some(MutationEvent::RuleRebuilt { name: name.clone() }),
255        WalRecord::Batch(_)
256        | WalRecord::CreateView { .. }
257        | WalRecord::DeleteView { .. }
258        | WalRecord::EnableFulltext { .. }
259        | WalRecord::DisableFulltext { .. }
260        | WalRecord::EnableIndex { .. }
261        | WalRecord::DisableIndex { .. }
262        | WalRecord::Intern { .. }
263        // History markers are no-ops for mutation events — they carry no new
264        // state and rules re-derive deterministically on replay.
265        | WalRecord::DerivedEdgeAdded { .. }
266        | WalRecord::DerivedEdgeRetracted { .. }
267        // RenameNode carries no node/edge count change; no special event.
268        | WalRecord::RenameNode { .. } => None,
269    }
270}
271
272/// Database-wide counters plus per-rule budget/fire stats.
273#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
274pub struct Stats {
275    pub nodes_live: usize,
276    pub nodes_tombstoned: usize,
277    pub edges: u64,
278    pub rules: Vec<RuleStats>,
279    /// How many writes hit the rule-chaining depth cap with work still pending,
280    /// since this handle was opened. Non-zero means some derived edges beyond
281    /// the cap are stale and no single later write will repair them: split the
282    /// rule chain or shorten it. Never persisted, so it resets on reopen.
283    #[serde(default)]
284    pub chain_truncations: u64,
285    /// The oldest commit index history still reaches (the WAL horizon floor).
286    /// `0` means nothing has been pruned and history is complete; a non-zero
287    /// value means events before that commit were pruned and are gone.
288    #[serde(default)]
289    pub history_floor: u64,
290    /// Live node counts per namespace, in name order. Always carries
291    /// `default` — a store is at least its default namespace — so a
292    /// single-tenant store reads `[{"name":"default", …}]` and a reader can
293    /// tell "no namespaces in use" from one entry.
294    #[serde(default)]
295    pub namespaces: Vec<NamespaceStats>,
296}
297
298/// Live node count for one namespace; one entry of [`Stats::namespaces`].
299#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
300pub struct NamespaceStats {
301    pub name: String,
302    pub nodes_live: usize,
303}
304
305/// One rule's provenance size, trip latch, and fire counter.
306///
307/// `tripped` is a one-way latch: once set, the engine adds no new edges for
308/// that rule until [`GraphDb::rebuild_rule`] (and only if the full desired
309/// set then fits). `fires` counts `on_node_changed` evaluations plus
310/// backfill/rebuild participant ticks (rebuild counts even when it is a
311/// provenance no-op).
312#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
313pub struct RuleStats {
314    pub name: String,
315    pub edges: u64,
316    pub tripped: bool,
317    pub fires: u64,
318    /// Whether this rule uses the approximate IVF-Flat candidate path.
319    pub approximate: bool,
320    /// `Some` while this rule's vector index is still being built.
321    ///
322    /// The rule derives **no** edges until it is `None`: the backfill is one
323    /// commit that runs after the index is whole, so a caller never sees a
324    /// partial edge set. Absent from the JSON when the rule is not building,
325    /// which is every rule created over a corpus at or below
326    /// [`core_rules::HNSW_BUILD_BATCH`] vectors.
327    #[serde(default, skip_serializing_if = "Option::is_none")]
328    pub building: Option<BuildProgress>,
329}
330
331/// One entry in the slow-query ring buffer.
332#[derive(Debug, Clone, Serialize)]
333pub struct SlowQueryEntry {
334    /// Execution time in whole milliseconds.
335    pub ms: u64,
336    /// The Cypher query string that was slow.
337    pub query: String,
338    /// The commit sequence number at the time the query ran.
339    pub at_commit: u64,
340}
341
342/// Snapshot of the slow-query log returned by [`GraphDb::slow_query_snapshot`].
343#[derive(Debug, Clone, Serialize)]
344pub struct SlowQuerySnapshot {
345    /// Current threshold in milliseconds (0 = disabled).
346    pub threshold_ms: u64,
347    /// Total number of slow queries ever recorded (not capped by ring size).
348    pub count: u64,
349    /// Most-recent slow queries (up to 16), oldest first.
350    pub last: Vec<SlowQueryEntry>,
351}
352
353/// Internal ring-buffer state protected by a `Mutex` so `query(&self)` can
354/// write to it without a mutable borrow.
355struct SlowQueryLog {
356    entries: std::collections::VecDeque<SlowQueryEntry>,
357    total: u64,
358}
359
360/// Maximum number of entries kept in the slow-query ring buffer.
361const SLOW_QUERY_RING_CAP: usize = 16;
362
363/// Wire summary of a [`Predicate`]. JSON only — `Explanation` is never
364/// bincode-persisted (WAL/snapshots store `RuleDef` bytes, not this type).
365#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
366pub struct PredicateSummary {
367    pub kind: String,
368    pub fields: Vec<String>,
369    pub min: Option<f64>,
370    pub tolerance: Option<f64>,
371    pub km: Option<f64>,
372    pub parts: Option<Vec<PredicateSummary>>,
373    /// True when the owning rule has `approximate=true` (IVF-Flat candidate path).
374    /// Always false for predicates reported without rule context (sub-predicates in `parts`).
375    #[serde(default)]
376    pub approximate: bool,
377}
378
379impl From<&Predicate> for PredicateSummary {
380    fn from(p: &Predicate) -> Self {
381        match p {
382            Predicate::KeyMatch { field } => PredicateSummary {
383                kind: "key_match".into(),
384                fields: vec![field.clone()],
385                min: None,
386                tolerance: None,
387                km: None,
388                parts: None,
389                approximate: false,
390            },
391            Predicate::FieldEqual { field } => PredicateSummary {
392                kind: "field_equal".into(),
393                fields: vec![field.clone()],
394                min: None,
395                tolerance: None,
396                km: None,
397                parts: None,
398                approximate: false,
399            },
400            Predicate::Overlap { field, min } => PredicateSummary {
401                kind: "overlap".into(),
402                fields: vec![field.clone()],
403                min: Some(*min),
404                tolerance: None,
405                km: None,
406                parts: None,
407                approximate: false,
408            },
409            Predicate::NumericWithin { field, tolerance } => PredicateSummary {
410                kind: "numeric_within".into(),
411                fields: vec![field.clone()],
412                min: None,
413                tolerance: Some(*tolerance),
414                km: None,
415                parts: None,
416                approximate: false,
417            },
418            Predicate::GeoRadius { field, km } => PredicateSummary {
419                kind: "geo_radius".into(),
420                fields: vec![field.clone()],
421                min: None,
422                tolerance: None,
423                km: Some(*km),
424                parts: None,
425                approximate: false,
426            },
427            Predicate::VectorSimilar { field, min } => PredicateSummary {
428                kind: "vector_similar".into(),
429                fields: vec![field.clone()],
430                min: Some(*min),
431                tolerance: None,
432                km: None,
433                parts: None,
434                approximate: false,
435            },
436            Predicate::All(inner) => {
437                let parts: Vec<PredicateSummary> = inner.iter().map(Self::from).collect();
438                let mut fields = Vec::new();
439                for part in &parts {
440                    for f in &part.fields {
441                        if !fields.contains(f) {
442                            fields.push(f.clone());
443                        }
444                    }
445                }
446                PredicateSummary {
447                    kind: "all".into(),
448                    fields,
449                    min: None,
450                    tolerance: None,
451                    km: None,
452                    parts: Some(parts),
453                    approximate: false,
454                }
455            }
456            Predicate::Any(inner) => {
457                let parts: Vec<PredicateSummary> = inner.iter().map(Self::from).collect();
458                let mut fields = Vec::new();
459                for part in &parts {
460                    for f in &part.fields {
461                        if !fields.contains(f) {
462                            fields.push(f.clone());
463                        }
464                    }
465                }
466                PredicateSummary {
467                    kind: "any".into(),
468                    fields,
469                    min: None,
470                    tolerance: None,
471                    km: None,
472                    parts: Some(parts),
473                    approximate: false,
474                }
475            }
476        }
477    }
478}
479
480/// Snapshot of a live node's key, label, and columnar properties.
481///
482/// `props` is a [`BTreeMap`] so field order is deterministic (sorted by name)
483/// regardless of insert order or the columnar store's `HashMap` iteration.
484///
485/// Deliberately does not derive `Serialize`: `Value`'s serde form is
486/// internally tagged. Wire JSON is built by `value_to_json` in the server.
487#[derive(Debug, Clone, PartialEq)]
488pub struct NodeInfo {
489    pub key: String,
490    pub label: String,
491    pub props: BTreeMap<String, Value>,
492}
493
494/// Counts returned by [`GraphDb::delete_node`].
495#[derive(Debug, Clone, PartialEq, Eq, Default)]
496pub struct DeleteReport {
497    /// Number of manual (user-inserted) edges removed.
498    pub manual_edges: u64,
499    /// Number of derived (rule-owned) edges retracted.
500    pub derived_edges: u64,
501}
502
503/// One directed edge incident on a node, with provenance membership.
504///
505/// `derived` is true iff `(edge_type, src, dst)` is in the rule engine's
506/// Plan-8 `by_node` provenance index.
507#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
508pub struct EdgeInfo {
509    pub edge_type: String,
510    pub src_key: String,
511    pub dst_key: String,
512    pub derived: bool,
513}
514
515/// One directed edge incident on a node at a point in WAL history, with the
516/// rule that derived it when it is rule-owned.
517///
518/// Returned by [`GraphDb::edges_at`] (sorted by `(edge_type, src_key, dst_key)`)
519/// and by [`GraphDb::what_if_set_prop`].
520#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)]
521pub struct EdgeAt {
522    pub edge_type: String,
523    pub src_key: String,
524    pub dst_key: String,
525    /// `true` when a rule wrote the edge (`DerivedEdgeAdded` in the WAL, or a
526    /// live provenance entry).
527    pub derived: bool,
528    /// The rule that derived the edge. `None` for a manual edge.
529    pub rule: Option<String>,
530}
531
532/// The derived edges a hypothetical property change would retract and derive.
533///
534/// Returned by [`GraphDb::what_if_set_prop`]. Both lists are sorted by
535/// `(edge_type, src_key, dst_key)` and every entry is rule-derived.
536#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
537pub struct WhatIf {
538    /// Derived edges that exist now and would be retracted.
539    pub lost: Vec<EdgeAt>,
540    /// Derived edges that do not exist now and would be derived.
541    pub gained: Vec<EdgeAt>,
542}
543
544/// An edge with mask-aware endpoint visibility.
545///
546/// Returned by [`GraphDb::node_edges_masked`] in [`crate::mask::MaskMode::Stub`]
547/// mode — hidden endpoints carry `*_restricted: true`.
548#[derive(Debug, Clone, PartialEq, Eq)]
549pub struct MaskedEdge {
550    pub edge_type: String,
551    pub src_key: String,
552    /// `true` when `src_key` is in the DB but hidden from the mask.
553    pub src_restricted: bool,
554    pub dst_key: String,
555    /// `true` when `dst_key` is in the DB but hidden from the mask.
556    pub dst_restricted: bool,
557    pub derived: bool,
558}
559
560/// Result of a mask-aware node lookup via [`GraphDb::node_info_masked`].
561///
562/// `None` from that method means the key does not exist (→ 404).
563/// `Some(Restricted)` is only produced when `mask.mode() == MaskMode::Stub`.
564#[derive(Debug, PartialEq)]
565pub enum MaskedNodeResult {
566    Visible(NodeInfo),
567    /// Node exists in the DB but is hidden from this mask.
568    Restricted,
569}
570
571/// One rule-owned edge between two nodes, with the rule name, edge type,
572/// direction (src_key → dst_key), and weight if the rule stores one.
573#[derive(Debug, Clone, PartialEq, Serialize)]
574pub struct Explanation {
575    pub rule: String,
576    pub edge_type: String,
577    pub src_key: String,
578    pub dst_key: String,
579    pub weight: Option<f64>,
580    pub predicate: PredicateSummary,
581    /// For a via-hop rule, the edge type the rule hops over to reach its
582    /// candidates. `None` for a plain two-node rule. A via-hop rule whose
583    /// `via_edge` is itself rule-derived is the chaining case: the hop edge
584    /// was written by another rule in the same commit.
585    #[serde(default)]
586    pub via_edge: Option<String>,
587}
588
589/// Report returned by [`GraphDb::backup_to`].
590#[derive(Debug, Clone)]
591pub struct BackupReport {
592    /// Filenames copied into the destination directory (sorted ascending).
593    pub files: Vec<String>,
594    /// Total bytes written across all copied files.
595    pub bytes: u64,
596    /// `true` when the destination opened cleanly and passed post-copy checks.
597    ///
598    /// For stores that have a `snapshot.bin` this means: all V8 section CRCs
599    /// matched **and** the destination opened without error.
600    ///
601    /// For WAL-only stores (no `snapshot.bin`) there is no snapshot to
602    /// CRC-check; `verified` is `true` when the destination opened and
603    /// replayed the WAL without error (record-level checksums in the WAL
604    /// provide the integrity signal, not section CRCs).
605    pub verified: bool,
606}
607
608/// One directed edge in export form, with optional rule attribution for derived edges.
609///
610/// Returned by [`GraphDb::all_edges_for_export`].
611///
612/// Does not derive `Eq`/`Ord`: `weight` is an `f64` and NaN breaks a total
613/// order. Callers that need a stable edge ordering already sort by
614/// `(edge_type, src, dst)` explicitly (see `all_edges_for_export`).
615#[derive(Debug, Clone, PartialEq, PartialOrd)]
616pub struct ExportEdge {
617    pub edge_type: String,
618    pub src: String,
619    pub dst: String,
620    pub derived: bool,
621    /// Rule name that created this edge, if derived. `None` for manual edges.
622    pub rule: Option<String>,
623    /// The creating rule's declared `weight_prop`, read off this edge, when
624    /// derived and numeric (`Int`/`Float`). `None` for manual edges, derived
625    /// edges whose rule declares no `weight_prop`, or a non-numeric value.
626    pub weight: Option<f64>,
627}
628
629/// One edge type's shape, as [`GraphDb::edge_type_census`] counts it.
630///
631/// Deliberately per *type* and not per edge: everything here is a summary a
632/// caller can print in one line, and none of it costs a record per edge.
633#[derive(Debug, Clone, PartialEq, Eq)]
634pub struct EdgeTypeCensus {
635    pub edge_type: String,
636    /// Directed edges of this type. Counted the way
637    /// [`GraphDb::edge_count`] counts: each edge once, from its source.
638    pub edges: u64,
639    /// Every label seen on a source of this type, sorted.
640    pub src_labels: Vec<String>,
641    /// Every label seen on a destination of this type, sorted.
642    pub dst_labels: Vec<String>,
643    /// The rules that declare this `edge_type`, sorted. Empty for a type
644    /// written by hand.
645    pub rules: Vec<String>,
646    /// `(src key, dst key)` of the first edge of this type in the store's own
647    /// id order — a real pair to quote in an example.
648    pub sample: Option<(String, String)>,
649}
650
651/// Construct the standard write-query result set (columns: created, properties_set, deleted).
652fn write_result_set() -> ResultSet {
653    ResultSet::new(vec![
654        "created".into(),
655        "properties_set".into(),
656        "deleted".into(),
657    ])
658}
659
660fn resolve_merge_set_value(op: &Operand, params: &BTreeMap<String, Value>) -> Result<Value> {
661    match op {
662        Operand::Lit(v) => Ok(v.clone()),
663        Operand::Param(name) => params
664            .get(name)
665            .cloned()
666            .ok_or_else(|| GraphError::QueryError {
667                detail: format!("missing parameter `{name}`"),
668            }),
669        _ => Err(GraphError::QueryError {
670            detail: "ON CREATE/ON MATCH SET value must be a literal or $parameter".into(),
671        }),
672    }
673}
674
675fn operand_node_vars(op: &Operand, out: &mut Vec<String>) {
676    match op {
677        Operand::Prop { var, .. } | Operand::Var(var) => {
678            if !out.contains(var) {
679                out.push(var.clone());
680            }
681        }
682        Operand::FuncCall { args, .. } => {
683            for arg in args {
684                operand_node_vars(arg, out);
685            }
686        }
687        Operand::BinArith { left, right, .. } => {
688            operand_node_vars(left, out);
689            operand_node_vars(right, out);
690        }
691        Operand::Case { branches, default } => {
692            // Branch conditions reference vars already bound (and mask-filtered)
693            // by the MATCH phase, so collecting from the value operands + ELSE
694            // is sufficient for RETURN-projection var discovery.
695            for (_, value) in branches {
696                operand_node_vars(value, out);
697            }
698            if let Some(d) = default {
699                operand_node_vars(d, out);
700            }
701        }
702        Operand::Index { base, index } => {
703            operand_node_vars(base, out);
704            operand_node_vars(index, out);
705        }
706        Operand::Lit(_) | Operand::Param(_) => {}
707    }
708}
709
710fn ret_node_vars(items: &[RetItem]) -> Vec<String> {
711    let mut out = Vec::new();
712    for item in items {
713        match &item.value {
714            RetVal::Var(v) | RetVal::Prop { var: v, .. } => {
715                if !out.contains(v) {
716                    out.push(v.clone());
717                }
718            }
719            RetVal::FuncCall { args, .. } => {
720                for arg in args {
721                    operand_node_vars(arg, &mut out);
722                }
723            }
724            RetVal::ScalarExpr(op) => operand_node_vars(op, &mut out),
725            RetVal::Agg { .. } => {}
726        }
727    }
728    out
729}
730
731fn add_var(out: &mut Vec<String>, v: &str) {
732    if !out.iter().any(|x| x == v) {
733        out.push(v.to_string());
734    }
735}
736
737fn pattern_node_vars(pats: &[Pattern]) -> Vec<String> {
738    let mut out = Vec::new();
739    for p in pats {
740        if let Some(v) = &p.start.var {
741            add_var(&mut out, v);
742        }
743        for (_, dest) in &p.chain {
744            if let Some(v) = &dest.var {
745                add_var(&mut out, v);
746            }
747        }
748    }
749    out
750}
751
752fn pattern_rel_vars(pats: &[Pattern]) -> Vec<String> {
753    let mut out = Vec::new();
754    for p in pats {
755        for (rel, _) in &p.chain {
756            if rel.hops.is_none() {
757                if let Some(v) = &rel.var {
758                    add_var(&mut out, v);
759                }
760            }
761        }
762    }
763    out
764}
765
766fn rel_type_alias(var: &str) -> String {
767    format!("__rt_{var}")
768}
769
770fn ret_column_name(item: &RetItem) -> String {
771    if let Some(alias) = &item.alias {
772        return alias.clone();
773    }
774    // The same naming rule the planner and the executor use, so a
775    // write-statement RETURN names its columns exactly as a read query does.
776    // An aggregate is not legal in a write-statement RETURN; it keeps the
777    // placeholder it always had.
778    ret_val_label(&item.value).unwrap_or_else(|| "<agg>".to_string())
779}
780
781fn eval_set_return_operand<F: Fs>(
782    db: &GraphDb<F>,
783    match_rs: &ResultSet,
784    row: usize,
785    rel_vars: &[String],
786    op: &Operand,
787    params: &BTreeMap<String, Value>,
788) -> Result<Option<Value>> {
789    match op {
790        Operand::Lit(v) => Ok(Some(v.clone())),
791        Operand::Param(name) => params.get(name).cloned().ok_or_else(|| GraphError::QueryError {
792            detail: format!("missing parameter `{name}`"),
793        }).map(Some),
794        Operand::Var(name) if rel_vars.iter().any(|r| r == name) => Err(GraphError::QueryError {
795            detail: format!(
796                "cannot return relationship variable '{name}' bare; return its properties ({name}.field) instead"
797            ),
798        }),
799        Operand::Var(name) => Ok(match_rs.get(row, name).cloned()),
800        Operand::Prop { var, field } => {
801            if rel_vars.iter().any(|r| r == var) {
802                return Ok(None);
803            }
804            let Some(Value::Str(key)) = match_rs.get(row, var) else {
805                return Ok(None);
806            };
807            Ok(db.get_prop(key, field))
808        }
809        Operand::FuncCall { name, args } => {
810            eval_set_return_func(db, match_rs, row, rel_vars, name, args, params)
811        }
812        Operand::BinArith { op, left, right } => {
813            let lv = eval_set_return_operand(db, match_rs, row, rel_vars, left, params)?;
814            let rv = eval_set_return_operand(db, match_rs, row, rel_vars, right, params)?;
815            eval_set_return_arith(op, lv, rv)
816        }
817        // CASE is supported in read-query RETURN; in a write-statement RETURN
818        // projection (CREATE/MERGE/SET … RETURN) it is not yet wired.
819        Operand::Case { .. } => Err(GraphError::QueryError {
820            detail: "CASE is not supported in a write-statement RETURN projection; \
821                     use a read query"
822                .into(),
823        }),
824        // Same as CASE: a list subscript is supported in a read-query RETURN
825        // but not yet in a write-statement RETURN projection.
826        Operand::Index { .. } => Err(GraphError::QueryError {
827            detail: "a list subscript is not supported in a write-statement RETURN \
828                     projection; use a read query"
829                .into(),
830        }),
831    }
832}
833
834fn eval_set_return_arith(
835    op: &ArithOp,
836    lv: Option<Value>,
837    rv: Option<Value>,
838) -> Result<Option<Value>> {
839    match (lv, rv) {
840        (None, _) | (_, None) => Ok(None),
841        (Some(Value::Int(a)), Some(Value::Int(b))) => {
842            let result = match op {
843                ArithOp::Sub => a.saturating_sub(b),
844                ArithOp::Mul => a.saturating_mul(b),
845                ArithOp::Add => a.saturating_add(b),
846                ArithOp::Div => {
847                    if b == 0 {
848                        return Err(GraphError::QueryError {
849                            detail: "division by zero".into(),
850                        });
851                    }
852                    a.checked_div(b).unwrap_or(i64::MAX)
853                }
854            };
855            Ok(Some(Value::Int(result)))
856        }
857        (Some(lv), Some(rv)) => {
858            let a = match &lv {
859                Value::Float(f) => *f,
860                Value::Int(i) => *i as f64,
861                _ => {
862                    return Err(GraphError::QueryError {
863                        detail: format!("arithmetic operand must be numeric, got {lv:?}"),
864                    })
865                }
866            };
867            let b = match &rv {
868                Value::Float(f) => *f,
869                Value::Int(i) => *i as f64,
870                _ => {
871                    return Err(GraphError::QueryError {
872                        detail: format!("arithmetic operand must be numeric, got {rv:?}"),
873                    })
874                }
875            };
876            let result = match op {
877                ArithOp::Sub => a - b,
878                ArithOp::Mul => a * b,
879                ArithOp::Add => a + b,
880                ArithOp::Div => {
881                    if b == 0.0 {
882                        return Err(GraphError::QueryError {
883                            detail: "division by zero".into(),
884                        });
885                    }
886                    a / b
887                }
888            };
889            Ok(Some(Value::Float(result)))
890        }
891    }
892}
893
894fn eval_set_return_func<F: Fs>(
895    db: &GraphDb<F>,
896    match_rs: &ResultSet,
897    row: usize,
898    rel_vars: &[String],
899    name: &str,
900    args: &[Operand],
901    params: &BTreeMap<String, Value>,
902) -> Result<Option<Value>> {
903    let norm = name.to_ascii_lowercase();
904    if norm == "type" {
905        if args.len() != 1 {
906            return Err(GraphError::QueryError {
907                detail: format!("type() requires exactly 1 argument, got {}", args.len()),
908            });
909        }
910        let Operand::Var(rel) = &args[0] else {
911            return Err(GraphError::QueryError {
912                detail: "type() argument must be a relationship variable (e.g. type(r))".into(),
913            });
914        };
915        return Ok(match_rs.get(row, &rel_type_alias(rel)).cloned());
916    }
917    if norm == "key" {
918        if args.len() != 1 {
919            return Err(GraphError::QueryError {
920                detail: format!("key() requires exactly 1 argument, got {}", args.len()),
921            });
922        }
923        let Operand::Var(var) = &args[0] else {
924            return Err(GraphError::QueryError {
925                detail: "key() argument must be a node variable (e.g. key(n))".into(),
926            });
927        };
928        if rel_vars.iter().any(|r| r == var) {
929            return Err(GraphError::QueryError {
930                detail: format!("key() argument `{var}` is a relationship, not a node"),
931            });
932        }
933        // MATCH rows bind node variables to their key string, so the column
934        // value *is* the key.
935        return Ok(match_rs.get(row, var).cloned());
936    }
937    let mut vals = Vec::with_capacity(args.len());
938    for arg in args {
939        vals.push(eval_set_return_operand(
940            db, match_rs, row, rel_vars, arg, params,
941        )?);
942    }
943    match norm.as_str() {
944        "tolower" => {
945            if vals.len() != 1 {
946                return Err(GraphError::QueryError {
947                    detail: format!("toLower() requires exactly 1 argument, got {}", vals.len()),
948                });
949            }
950            Ok(vals[0].clone().map(|val| match val {
951                Value::Str(s) => Value::Str(s.to_ascii_lowercase()),
952                other => other,
953            }))
954        }
955        "toupper" => {
956            if vals.len() != 1 {
957                return Err(GraphError::QueryError {
958                    detail: format!("toUpper() requires exactly 1 argument, got {}", vals.len()),
959                });
960            }
961            Ok(vals[0].clone().map(|val| match val {
962                Value::Str(s) => Value::Str(s.to_ascii_uppercase()),
963                other => other,
964            }))
965        }
966        "size" => match vals.first().cloned().flatten() {
967            None => Ok(None),
968            Some(Value::Str(s)) => Ok(Some(Value::Int(s.len() as i64))),
969            Some(Value::List(items)) => Ok(Some(Value::Int(items.len() as i64))),
970            Some(_) => Ok(None),
971        },
972        "coalesce" => Ok(vals.into_iter().flatten().next()),
973        "abs" => match vals.first().cloned().flatten() {
974            None => Ok(None),
975            Some(Value::Int(n)) => Ok(Some(Value::Int(n.saturating_abs()))),
976            Some(Value::Float(f)) => Ok(Some(Value::Float(f.abs()))),
977            Some(_) => Ok(None),
978        },
979        "round" => match vals.first().cloned().flatten() {
980            None => Ok(None),
981            Some(Value::Float(f)) => Ok(Some(Value::Float(f.round()))),
982            Some(Value::Int(n)) => Ok(Some(Value::Int(n))),
983            Some(_) => Ok(None),
984        },
985        "decay" => {
986            if vals.len() != 3 {
987                return Err(GraphError::QueryError {
988                    detail: format!("decay() requires exactly 3 arguments, got {}", vals.len()),
989                });
990            }
991            match (vals[0].clone(), vals[1].clone(), vals[2].clone()) {
992                (None, _, _) | (_, None, _) | (_, _, None) => Ok(None),
993                (Some(b), Some(a), Some(h)) => {
994                    let numeric = |v: Value| -> Result<f64> {
995                        match v {
996                            Value::Int(n) => Ok(n as f64),
997                            Value::Float(f) => Ok(f),
998                            other => Err(GraphError::QueryError {
999                                detail: format!(
1000                                    "decay() requires numeric arguments, got {other:?}"
1001                                ),
1002                            }),
1003                        }
1004                    };
1005                    let b = numeric(b)?;
1006                    let a = numeric(a)?;
1007                    let h = numeric(h)?;
1008                    if h <= 0.0 {
1009                        return Err(GraphError::QueryError {
1010                            detail: "decay() requires halflife > 0".into(),
1011                        });
1012                    }
1013                    Ok(Some(Value::Float(b * 0.5f64.powf(a / h))))
1014                }
1015            }
1016        }
1017        _ => Err(GraphError::QueryError {
1018            detail: format!(
1019                "unknown function `{name}`; supported: toLower, toUpper, size, coalesce, type, abs, round, decay, key"
1020            ),
1021        }),
1022    }
1023}
1024
1025fn eval_set_return_item<F: Fs>(
1026    db: &GraphDb<F>,
1027    match_rs: &ResultSet,
1028    row: usize,
1029    rel_vars: &[String],
1030    item: &RetItem,
1031    params: &BTreeMap<String, Value>,
1032) -> Result<Option<Value>> {
1033    match &item.value {
1034        RetVal::Var(v) => eval_set_return_operand(
1035            db,
1036            match_rs,
1037            row,
1038            rel_vars,
1039            &Operand::Var(v.clone()),
1040            params,
1041        ),
1042        RetVal::Prop { var, field } => eval_set_return_operand(
1043            db,
1044            match_rs,
1045            row,
1046            rel_vars,
1047            &Operand::Prop {
1048                var: var.clone(),
1049                field: field.clone(),
1050            },
1051            params,
1052        ),
1053        RetVal::FuncCall { name, args } => {
1054            eval_set_return_func(db, match_rs, row, rel_vars, name, args, params)
1055        }
1056        RetVal::ScalarExpr(op) => eval_set_return_operand(db, match_rs, row, rel_vars, op, params),
1057        RetVal::Agg { .. } => Err(GraphError::QueryError {
1058            detail: "aggregates are not supported in MATCH … SET … RETURN".into(),
1059        }),
1060    }
1061}
1062
1063/// Project user RETURN from original MATCH rows after SET. No rematch.
1064fn project_set_return_rows<F: Fs>(
1065    db: &GraphDb<F>,
1066    rel_vars: &[String],
1067    match_rs: &ResultSet,
1068    returns: &[RetItem],
1069    params: &BTreeMap<String, Value>,
1070) -> Result<ResultSet> {
1071    let columns: Vec<String> = returns.iter().map(ret_column_name).collect();
1072    let mut out = ResultSet::new(columns);
1073    for row in 0..match_rs.len() {
1074        let mut cells = Vec::with_capacity(returns.len());
1075        for item in returns {
1076            cells.push(eval_set_return_item(
1077                db, match_rs, row, rel_vars, item, params,
1078            )?);
1079        }
1080        out.push_row(cells);
1081    }
1082    Ok(out)
1083}
1084
1085/// Single construction point for a `GraphMut` view over the split-borrowed graph fields.
1086/// Callers use `std::mem::take` on the engine before calling this, then restore it after.
1087/// Extract a `Vec<f64>` from a `Value::List` whose items are all numeric.
1088/// Returns `None` for non-list values or lists with non-numeric elements.
1089/// Extra candidates pulled from an approximate index before re-scoring, over and
1090/// above the `k` asked for.
1091///
1092/// The index orders candidates by `f32` distances, which agree with the exact
1093/// `f64` cosine to about 1e-6. Re-scoring can therefore only reshuffle
1094/// candidates inside a band that narrow — it cannot move a hit past one that is
1095/// further away by more than 1e-6 — so the only way a true top-`k` member can be
1096/// lost is if the index ranked it just outside `k` on the `f32` order. Fetching
1097/// `k + 16` covers any such band up to 16 members wide, which at 1e-6 means 16
1098/// vectors within a millionth of each other in cosine: a duplicate cluster, and
1099/// then the members are interchangeable anyway. `min` is applied to the exact
1100/// score, never to the index's, so a hit sitting on the threshold is decided
1101/// exactly.
1102const VECTOR_RESCORE_MARGIN: usize = 16;
1103
1104/// Cosine similarity between an already-unit query and node `id`'s `field`
1105/// vector, read from the **`f64`** properties. `None` when the node has no
1106/// numeric-list vector there, or its norm is zero.
1107///
1108/// The single definition of the score this API reports. Both the brute-force
1109/// scan and the re-scoring step that follows an index lookup go through it, so
1110/// the two paths cannot disagree — which is the property
1111/// `index_and_brute_force_agree_on_scores` pins.
1112fn exact_vector_similarity(
1113    view: &GraphView<'_>,
1114    id: u32,
1115    field: &str,
1116    q_unit: &[f64],
1117) -> Option<f64> {
1118    let v = view.prop(id, field)?;
1119    let xs = value_as_float_list(&v.into_value())?;
1120    let v_norm: f64 = xs.iter().map(|x| x * x).sum::<f64>().sqrt();
1121    if v_norm == 0.0 {
1122        return None;
1123    }
1124    Some(
1125        q_unit
1126            .iter()
1127            .zip(xs.iter())
1128            .map(|(a, b)| a * (b / v_norm))
1129            .sum(),
1130    )
1131}
1132
1133fn value_as_float_list(v: &Value) -> Option<Vec<f64>> {
1134    match v {
1135        Value::List(items) => items
1136            .iter()
1137            .map(|item| match item {
1138                Value::Float(f) => Some(*f),
1139                Value::Int(i) => Some(*i as f64),
1140                _ => None,
1141            })
1142            .collect(),
1143        _ => None,
1144    }
1145}
1146
1147fn make_graph_mut<'a>(
1148    ids: &'a IdMap,
1149    syms: &'a mut Interner,
1150    labels: &'a [u32],
1151    props: core_storage::v8::seam::ColumnsView<'a>,
1152    topo: &'a mut Topology,
1153    base: &'a Option<std::sync::Arc<core_storage::v8::MappedBase>>,
1154    edge_props: &'a mut EdgeProps,
1155) -> GraphMut<'a> {
1156    GraphMut {
1157        ids,
1158        syms,
1159        labels,
1160        props,
1161        topo,
1162        base_topo: base_csr(base),
1163        edge_props,
1164    }
1165}
1166
1167/// The archived CSR of an open V8 snapshot, for the rule engine's graph reads.
1168///
1169/// A store opened from a snapshot keeps its edges in the mapping and its
1170/// overlay empty, so a rule that reads the graph's shape has to see both.
1171fn base_csr(
1172    base: &Option<std::sync::Arc<core_storage::v8::MappedBase>>,
1173) -> Option<&core_storage::v8::layout::ArchivedCsr> {
1174    base.as_ref().map(|b| {
1175        b.topology()
1176            .expect("base topology section bounds validated at open")
1177    })
1178}
1179
1180/// Build a `ColumnsView` from the disjoint `props` overlay and optional V8 base.
1181///
1182/// Takes explicit field references rather than `&self` so the caller can hold
1183/// simultaneous mutable borrows of other fields (e.g. `syms`, `topo`).
1184fn build_props_view<'a>(
1185    props: &'a ColumnStore,
1186    base: &'a Option<std::sync::Arc<core_storage::v8::MappedBase>>,
1187) -> core_storage::v8::seam::ColumnsView<'a> {
1188    match base {
1189        None => core_storage::v8::seam::ColumnsView::owned(props),
1190        Some(b) => {
1191            let archived = b
1192                .columns()
1193                .expect("base columns section bounds validated at open");
1194            core_storage::v8::seam::ColumnsView::with_base_cached(props, archived, b.mixed_cache())
1195                .with_shared_strings(base_string_table(b))
1196        }
1197    }
1198}
1199
1200/// The base columns section paired with the string table that resolves its
1201/// string ids — what `ViewStore` needs to read a neighbour's string property
1202/// out of a V9 snapshot.
1203fn base_columns(
1204    base: &Option<std::sync::Arc<core_storage::v8::MappedBase>>,
1205) -> Option<core_storage::v8::seam::BaseColumns<'_>> {
1206    base.as_ref().map(|b| core_storage::v8::seam::BaseColumns {
1207        cols: b
1208            .columns()
1209            .expect("base columns section bounds validated at open"),
1210        strings: base_string_table(b),
1211    })
1212}
1213
1214/// The shared string table of a V9 base, or `None` for a pre-V9 one.
1215///
1216/// Every `ColumnsView` built over a base must carry it: without it a V9
1217/// snapshot's string columns, whose own tables are empty, read back as absent.
1218fn base_string_table(
1219    base: &core_storage::v8::MappedBase,
1220) -> Option<&core_storage::v8::layout::ArchivedStringTable> {
1221    base.string_table()
1222        .transpose()
1223        .expect("base strings section bounds validated at open")
1224}
1225
1226fn build_topo_view<'a>(
1227    overlay: &'a Topology,
1228    base: &'a Option<std::sync::Arc<core_storage::v8::MappedBase>>,
1229) -> core_storage::v8::seam::TopologyView<'a> {
1230    match base {
1231        None => core_storage::v8::seam::TopologyView::owned(overlay),
1232        Some(b) => {
1233            let archived_csr = b
1234                .topology()
1235                .expect("base topology section bounds validated at open");
1236            core_storage::v8::seam::TopologyView::with_base(overlay, archived_csr)
1237        }
1238    }
1239}
1240
1241/// When [`GraphDb`] calls `Fs::sync` after a WAL append.
1242///
1243/// Default is [`Strict`](FsyncPolicy::Strict): every `log_then_apply_with`
1244/// fsyncs (single `insert_node` / `set_prop`). Ingest and `write_batch`
1245/// emit one `WalRecord::Batch` and fsync once at that frame (Batched).
1246/// [`Relaxed`](FsyncPolicy::Relaxed) skips WAL sync; [`GraphDb::snapshot`]
1247/// is still durable via `write_atomic`. Crash-recovery DST stays Strict.
1248#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
1249pub enum FsyncPolicy {
1250    /// Every WAL commit calls `fs.sync` (today's behavior).
1251    #[default]
1252    Strict,
1253    /// Sync only at a `Batch` frame end. Single-op path stays Strict unless
1254    /// this policy is set on the database.
1255    Batched,
1256    /// Never call `fs.sync`. [`GraphDb::snapshot`] still syncs via `write_atomic`.
1257    Relaxed,
1258}
1259
1260/// A precondition for a compare-and-set batch write.
1261///
1262/// All preconditions in a [`GraphDb::write_batch_cas`] or
1263/// [`crate::SharedDb::submit_batch_cas`] call are checked atomically before
1264/// any operation in the batch is applied.  If any precondition fails, the
1265/// entire batch is rejected with [`GraphError::CasConflict`] and no WAL frame
1266/// is written.
1267///
1268/// # Touch definition
1269///
1270/// A node's last-change commit (`last_changed`) is updated when any of the
1271/// following state-changing WAL records touch it:
1272///
1273/// - `InsertNode` / `InsertNodeId` — the newly-inserted node.
1274/// - `SetProp` / `SetPropId` / `RemoveProp` — the property-bearing node.
1275/// - `InsertEdge` / `InsertEdgeId` / `DeleteEdge` — **both** src and dst
1276///   endpoints (an edge change touches both sides).
1277/// - `DeleteNode` — the node is tombstoned; `last_changed` returns `None`
1278///   for deleted keys so the pre-deletion entry is never observed.
1279///
1280/// History markers (`DerivedEdgeAdded` / `DerivedEdgeRetracted`) are
1281/// state no-ops.  The underlying mutation that triggered rule firing already
1282/// updated the relevant nodes' last-change entries.  Rule-management records
1283/// (`CreateRule`, `DeleteRule`, `RebuildRule`) and view/full-text declarations
1284/// do not touch any node's last-change.
1285#[derive(Debug, Clone, PartialEq, Eq)]
1286pub enum Precondition {
1287    /// The node's last-change commit must equal `expected`.
1288    ///
1289    /// Fails with [`GraphError::CasConflict`] when:
1290    /// - The node does not exist (`last_changed` returns `None`), or
1291    /// - The recorded commit seq does not match `expected`.
1292    NodeUnchangedSince { key: String, expected: u64 },
1293    /// The node must not exist (not inserted, or already deleted).
1294    ///
1295    /// Fails with [`GraphError::CasConflict`] (expected=`u64::MAX`,
1296    /// actual=`last_changed(key).unwrap_or(0)`) when the node is live.
1297    NodeAbsent { key: String },
1298}
1299
1300pub struct GraphDb<F: Fs> {
1301    fs: F,
1302    ids: IdMap,
1303    syms: Interner,
1304    topo: Topology,
1305    props: ColumnStore,
1306    labels: Vec<u32>, // node id -> label symbol
1307    /// Namespace names by index; index [`NS_DEFAULT_IDX`] is always
1308    /// [`NS_DEFAULT`]. Derived beside [`Self::node_ns`], never persisted.
1309    ///
1310    /// A private table rather than the shared [`Interner`]: interning
1311    /// `"default"` at open would add a symbol to the store's symbol table and
1312    /// change the bytes of the next snapshot of a store that has no namespaces
1313    /// at all.
1314    ns_names: Vec<String>,
1315    /// Namespace index per dense node id, into [`Self::ns_names`];
1316    /// [`NS_DEFAULT_IDX`] for a node with no `ns` property.
1317    ///
1318    /// Derived: built by one pass over the `ns` column at open (which reads
1319    /// nothing when the column does not exist) and maintained at every node
1320    /// insert. Never written to a snapshot or the WAL, because the property it
1321    /// mirrors already is. A namespace cannot change, so no other record shape
1322    /// can move a node between namespaces.
1323    node_ns: Vec<u32>,
1324    edge_props: EdgeProps,
1325    engine: RuleEngine,
1326    view_store: ViewStore,
1327    /// Incremental inverted index for full-text-lite search.
1328    /// Rebuild-on-open: populated from WAL replay + rebuild_all at open end.
1329    fulltext: FulltextIndex,
1330    /// Opt-in equality index over scalar node properties.
1331    /// Rebuild-on-open: declarations replay from the WAL, postings rebuild at
1332    /// open end (mirrors `fulltext`).
1333    prop_index: PropertyIndex,
1334    event_sink: Option<Box<dyn Fn(MutationEvent) + Send + Sync>>,
1335    /// WAL fsync cadence. Default [`FsyncPolicy::Strict`].
1336    fsync: FsyncPolicy,
1337    /// Monotonically increasing per-commit counter.  A single `log_then_apply_with`
1338    /// call increments this once; all events emitted from that call share the same
1339    /// `commit_seq` value.
1340    commit_seq: u64,
1341    /// RBAC role definitions loaded from `roles.json` at open.
1342    ///
1343    /// `Some(roles)` — loaded successfully (may be empty when no roles are defined).
1344    /// `None` — `roles.json` was present but corrupt; `mask_for_role` returns
1345    /// `Err` for any request (fail-loud, never silently grant empty visibility).
1346    roles: Option<Vec<RoleDef>>,
1347    /// Memo for [`mask_for_role`](GraphDb::mask_for_role), keyed by
1348    /// `(role, commit_seq)` — a scoped reader between two writes resolves once.
1349    ///
1350    /// Shared by `Arc` with every [`ReaderSnapshot`](crate::reader::ReaderSnapshot)
1351    /// taken from this handle. Replaced (not cleared) whenever the role
1352    /// definitions change or the store is reloaded, which `commit_seq` does not
1353    /// record; see [`RoleMaskCache`](crate::mask::RoleMaskCache).
1354    role_masks: Arc<crate::mask::RoleMaskCache>,
1355    /// Live subscriptions.  Entries with a dead `Weak` are pruned on the next
1356    /// distribute_events call.
1357    subscriptions: Vec<SubEntry>,
1358    /// Live query subscriptions. Re-executed on every commit when non-empty.
1359    /// Dead `Weak` entries are pruned inside `distribute_events`.
1360    query_subscriptions: Vec<QuerySubEntry>,
1361    /// Queue capacity for new subscriptions created by this db.  Default is
1362    /// [`DEFAULT_SUB_CAPACITY`]; can be overridden via [`set_sub_capacity`]
1363    /// to test Lagged behaviour with small queues.
1364    sub_capacity: usize,
1365    /// True for as-of instances opened via [`GraphDb::open_at`].
1366    /// Every mutation method and `snapshot()` returns [`GraphError::ReadOnly`]
1367    /// when this flag is set.
1368    read_only: bool,
1369    /// Total WAL commit count at the time [`open_at`] was called.
1370    /// 0 for normal (non-as-of) instances.
1371    total_wal_commits: u64,
1372    /// Immutable mmap-backed base snapshot (V8).  When `Some`, `self.topo` is
1373    /// the WAL-replay overlay (empty at open time, populated by apply()) and
1374    /// reads go through a merged `TopologyView`.  `self.props` is always
1375    /// fully materialized (base + WAL replay) for HNSW/IVF and view compat.
1376    base: Option<Arc<core_storage::v8::MappedBase>>,
1377    // ── MVCC epoch reader state ───────────────────────────────────────────────
1378    /// Most-recent full overlay clone.  Initialized at end of `open_with` /
1379    /// `open_at_with`; refreshed every `FOLD_EVERY_K` commits.
1380    /// `None` only between struct creation and the first fold.
1381    fold_overlay: Option<Arc<crate::reader::FrozenOverlay>>,
1382    /// Per-commit deltas accumulated since the last fold.
1383    delta_tail: Vec<Arc<crate::reader::CommitDelta>>,
1384    /// How many commits have occurred since the last fold.
1385    commits_since_fold: usize,
1386    /// When true, `log_then_apply_with` buffers event notifications instead of
1387    /// firing them immediately.  Used by the group-commit drain thread to defer
1388    /// events until after the group fsync (R2: durability before notification).
1389    /// Cleared to false once the drain thread flushes or discards the buffer.
1390    defer_events: bool,
1391    /// Buffered events accumulated while `defer_events` is true.
1392    deferred_events: Vec<DeferredEvent>,
1393    /// Set to true by the group-commit drain thread when a group fsync fails
1394    /// after WAL truncation.  All subsequent mutation attempts return an IO
1395    /// error until the database is reopened.
1396    degraded: bool,
1397    /// Set to `true` after `ensure_v8_base_sections_loaded` has read provenance,
1398    /// HNSW, and IVF sections from the mmap base into the engine's retained
1399    /// fields.  `false` on all opens until first use; always `true` for non-V8
1400    /// opens (base is None, fast-path sets flag immediately).
1401    v8_sections_loaded: std::sync::atomic::AtomicBool,
1402    /// Serializes the one-time section population in `ensure_v8_base_sections_loaded`.
1403    v8_sections_mutex: std::sync::Mutex<()>,
1404    /// Per-node last-change commit sequence.  `last_change[node_id] = seq` means
1405    /// the node was last modified by commit `seq`.
1406    ///
1407    /// Loaded from V8 section 11 at open; updated on every state-changing commit
1408    /// and WAL replay frame.  V5-V7 stores start with an empty map; pre-WAL-horizon
1409    /// nodes return `None` from `last_changed` until they are next mutated.
1410    ///
1411    /// See [`Precondition`] for the full touch definition.
1412    last_change: HashMap<u32, u64>,
1413    /// WAL archive retention policy set by [`set_wal_archive_retention`].
1414    /// `None` = unlimited (keep all archives); `Some(N)` = keep N newest archives,
1415    /// pruning older ones at snapshot time.  0 is treated as unlimited.
1416    wal_archive_retention: Option<u32>,
1417    /// Global frame index of the first commit that is still reachable through
1418    /// surviving archives.  Persisted to `wal.floor` sidecar when pruning occurs.
1419    /// Default 0 = all history reachable.
1420    wal_horizon_floor: u64,
1421    /// True when the surviving archive chain forms a continuous WAL history
1422    /// starting from the store's first commit (the genesis chain).
1423    ///
1424    /// `open_at` may replay archive-resident commits from empty state only when
1425    /// this flag is true AND `wal_horizon_floor == 0`.  Cleared whenever:
1426    ///   - a WAL-truncating snapshot (`keep_wal=false`) is taken after archives
1427    ///     already exist (breaks the chain for subsequent archives), or
1428    ///   - any archive is pruned (floor advances past zero).
1429    ///
1430    /// Persisted via the `wal.genesis` marker file; loaded from it at open.
1431    archive_genesis_chain: bool,
1432    /// Transient write-authz context set by `write_batch_authz` /
1433    /// `query_write_authz` for the duration of ONE mutation call.
1434    /// Always `None` at rest.  Never serialized, never WAL-replayed.
1435    pending_write_authz: Option<WriteAuthz>,
1436    /// Slow-query threshold in milliseconds.  0 = disabled.
1437    /// Seeded from `MUSHROOMDB_SLOW_QUERY_MS` at open; override via
1438    /// [`GraphDb::set_slow_query_threshold_ms`] (tests must use the setter
1439    /// — env vars are process-global and race parallel test threads).
1440    slow_query_threshold_ms: u64,
1441    /// Ring buffer of recent slow queries (interior-mutable so `query(&self)`
1442    /// can record entries without requiring `&mut self`).
1443    slow_queries: std::sync::Mutex<SlowQueryLog>,
1444    /// Instant at which the database was opened (used by `/metrics` uptime).
1445    started_at: std::time::Instant,
1446    // ── Multi-process state (cross-process lock + WAL tailing) ────────────────
1447    /// Byte offset of the WAL prefix already applied to in-memory state.
1448    ///
1449    /// Advanced by exactly the encoded length of every frame this handle
1450    /// appends, and by the decoded byte count of every tail
1451    /// [`refresh`](GraphDb::refresh) absorbs. Rewound by
1452    /// [`set_wal_consumed`](GraphDb::set_wal_consumed) when the group-commit
1453    /// drain thread truncates a failed group. Compared against the WAL's
1454    /// on-disk length to decide staleness.
1455    wal_consumed: u64,
1456    /// Identity of the snapshot this handle's base state came from, as
1457    /// `(len, mtime_nanos)`. A different value means another process replaced
1458    /// the snapshot and the WAL no longer continues our state: refresh reloads.
1459    snapshot_ident: Option<(u64, u64)>,
1460    /// The options this handle was opened with. Replayed verbatim when
1461    /// `refresh` has to rebuild from disk.
1462    open_opts: OpenOptions,
1463    /// True when this handle holds the cross-process write lock for its whole
1464    /// lifetime (a plain read-write open). Per-write lock acquisition is a
1465    /// no-op on such a handle, and never releases the lock.
1466    holds_lifetime_lock: bool,
1467    /// True between a failed lock acquisition and the end of the write scope
1468    /// that failed. Makes every WAL-appending mutation in that scope return
1469    /// [`GraphError::Busy`] instead of writing.
1470    lock_denied: bool,
1471    /// True for an as-of view opened via [`GraphDb::open_at`]. Such a view is
1472    /// pinned to one commit, so it is never stale and never refreshes — later
1473    /// commits by any process are deliberately invisible to it.
1474    pinned: bool,
1475}
1476
1477/// One group of deferred event notifications, held until the group fsync
1478/// completes.  Replayed by [`GraphDb::flush_deferred_events`].
1479struct DeferredEvent {
1480    rec: core_storage::WalRecord,
1481    engine_deltas: Vec<EngineEdgeDelta>,
1482    seq: u64,
1483    ingest: Option<(String, usize)>,
1484}
1485
1486/// Options for [`GraphDb::open_with_options`].
1487#[derive(Clone, Copy, Debug)]
1488pub struct OpenOptions {
1489    /// Rewrite an old-format snapshot to the current VERSION after a
1490    /// successful load (default `true`). The old snapshot is kept as
1491    /// `snapshot.bin.bak` until the next clean open at the current version,
1492    /// at which point the `.bak` is deleted.
1493    ///
1494    /// Set to `false` to open a store without touching any on-disk files
1495    /// (useful for read-only inspection of a store at an older format).
1496    pub auto_migrate: bool,
1497
1498    /// Write the valid WAL prefix back over a torn tail on open (default
1499    /// `true`). Truncating a genuinely torn tail is correct crash recovery.
1500    ///
1501    /// Set to `false` for an unattended reader. The valid prefix is still
1502    /// decoded and replayed in memory, but nothing is written: a reader that
1503    /// opens while another process is mid-append would otherwise discard a
1504    /// frame that writer believes durable. `mushroomdb recall`, which runs on
1505    /// every prompt, passes `false` for exactly this reason.
1506    pub repair_wal: bool,
1507
1508    /// Open without ever writing to the store (default `false`).
1509    ///
1510    /// A read-only handle:
1511    /// - returns [`GraphError::ReadOnly`] from every mutation and from
1512    ///   `snapshot()`;
1513    /// - performs no disk write at open — no WAL repair write-back and no
1514    ///   auto-migration rewrite, whatever the other two flags say;
1515    /// - never takes the cross-process write lock, so it opens immediately even
1516    ///   while another process is writing, and never makes a writer wait.
1517    ///
1518    /// [`refresh`](GraphDb::refresh) and [`is_stale`](GraphDb::is_stale) work
1519    /// normally, so a read-only handle can follow another process's commits.
1520    pub read_only: bool,
1521}
1522
1523impl Default for OpenOptions {
1524    fn default() -> Self {
1525        Self {
1526            auto_migrate: true,
1527            repair_wal: true,
1528            read_only: false,
1529        }
1530    }
1531}
1532
1533/// How long a writer polls for the cross-process write lock before giving up
1534/// with [`GraphError::Busy`].
1535///
1536/// Long enough to ride out another process's commit (a batch apply plus one
1537/// fsync), short enough that a stuck peer surfaces as an error rather than a
1538/// hang.
1539pub const WRITE_LOCK_WAIT: std::time::Duration = std::time::Duration::from_secs(2);
1540
1541/// Interval between poll attempts while waiting for the cross-process lock.
1542pub(crate) const LOCK_POLL_INTERVAL: std::time::Duration = std::time::Duration::from_millis(10);
1543
1544/// Why `load_from_disk` is running, which decides whether it may repair.
1545#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1546enum LoadOrigin {
1547    /// A fresh open. Crash recovery is this handle's job: a torn WAL tail is
1548    /// the signature of a crash and truncating it is correct, and archives
1549    /// orphaned by an interrupted prune can be swept.
1550    Open,
1551    /// A reload driven by [`GraphDb::refresh`], because another process
1552    /// replaced the snapshot. Nothing here is crash recovery — the store is
1553    /// live and someone else is writing it — so this origin writes nothing.
1554    Reload,
1555}
1556
1557/// Authorization context carried by `write_batch_authz` / `query_write_authz`.
1558///
1559/// `None` at the call site = full authority (today's zero-cost behavior).
1560/// `Some(WriteAuthz)` = role-scoped: the decision table (plan §"authz decision
1561/// table") is evaluated per-op inside `commit_logged_batch` BEFORE any WAL
1562/// record is built.  A denial returns an error with no WAL frame written.
1563///
1564/// The mask is ALWAYS `Omit`-mode: role-token paths must never acknowledge
1565/// hidden-node existence to callers.
1566#[derive(Clone, Debug)]
1567pub struct WriteAuthz {
1568    pub role: String,
1569    pub scope: WriteScope,
1570    /// Resolved by `mask_for_role` under the same write guard as the mutation.
1571    /// Always `Omit`-mode — never `Stub`.
1572    pub mask: crate::mask::NodeMask,
1573}
1574
1575/// Write `bytes` to `snapshot.bin.bak` atomically with full fsync.
1576///
1577/// Uses [`RealFs::write_atomic`] which applies `F_FULLFSYNC` on macOS and
1578/// `sync_all` on other platforms, then renames the `.tmp` file into place and
1579/// syncs the directory entry. This is the only correct path for writing the
1580/// `.bak` — plain `std::fs::write + sync_all` misses both `F_FULLFSYNC` and
1581/// the directory sync.
1582pub fn write_snapshot_bak(dir: &std::path::Path, bytes: &[u8]) -> crate::Result<()> {
1583    use core_storage::fs::{FileId, Fs as _};
1584    RealFs::new(dir)
1585        .map_err(core_storage::GraphError::Io)?
1586        .write_atomic(FileId::SnapshotBak, bytes)
1587        .map_err(core_storage::GraphError::Io)
1588}
1589
1590/// Return the on-disk snapshot format version without decoding the full snapshot.
1591///
1592/// Reads only the 6-byte header (magic + version LE). Returns `None` when no
1593/// snapshot file exists (WAL-only store). Returns an error if the header is
1594/// malformed.
1595pub fn snapshot_version_at(dir: &std::path::Path) -> crate::Result<Option<u16>> {
1596    use std::io::Read as _;
1597    let path = dir.join("snapshot.bin");
1598    let mut header = [0u8; 6];
1599    let n = match std::fs::File::open(&path) {
1600        Ok(mut f) => f.read(&mut header).map_err(core_storage::GraphError::Io)?,
1601        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
1602        Err(e) => return Err(core_storage::GraphError::Io(e)),
1603    };
1604    core_storage::snapshot::peek_version(&header[..n])
1605}
1606
1607/// Options for [`GraphDb::snapshot_with`].
1608#[derive(Debug, Clone, Default)]
1609pub struct SnapshotOptions {
1610    /// When `true`, the WAL is preserved after the snapshot write.
1611    /// Pre-snapshot commits remain reachable via [`GraphDb::open_at`].
1612    /// When `false` (the default), the WAL is truncated to a minimal
1613    /// baseline so cold-start replay stays fast.
1614    pub keep_wal: bool,
1615    /// When `true`, the current WAL is renamed to `wal.<commit_seq>.archive`
1616    /// before a fresh WAL baseline is written (history-preserving snapshot).
1617    ///
1618    /// This is the feature opt-in: `false` (the default) leaves the existing
1619    /// truncation / keep-wal behaviour byte-identical.  `archive_wal` takes
1620    /// precedence over `keep_wal` when both are set.
1621    ///
1622    /// Archives can be scanned by [`GraphDb::node_history`],
1623    /// [`GraphDb::edge_history`], [`GraphDb::was_linked`], and
1624    /// [`GraphDb::open_at`], extending the reachable history horizon across
1625    /// snapshot boundaries.
1626    pub archive_wal: bool,
1627}
1628
1629/// Derive the scan-label sym for the commit-skip fast-path.
1630///
1631/// Walks `ops` to find the plan's leading scan op (`ScanLabel`, `IndexScan`,
1632/// or `IndexIntersect`) with a concrete label string, then interns it.
1633///
1634/// Returns `None` in all cases where skipping is unsafe:
1635/// - Any `Expand` op is present (edge traversal; edges change results regardless
1636///   of node labels).
1637/// - The leading scan has no label (`ScanLabel { label: None }` — full scan).
1638/// - No recognizable leading scan op is found.
1639///
1640/// This is the conservative v0.4.3 boundary. The caller stores the result in
1641/// [`QuerySubEntry::scan_label`] at subscribe time; `None` means always execute.
1642fn extract_scan_label(ops: &[PlanOp], syms: &mut Interner) -> Option<u32> {
1643    // Any Expand → must always re-execute (edges can change join results).
1644    if ops.iter().any(|op| matches!(op, PlanOp::Expand { .. })) {
1645        return None;
1646    }
1647    for op in ops {
1648        match op {
1649            PlanOp::ScanLabel {
1650                label: Some(label), ..
1651            } => return Some(syms.intern(label)),
1652            PlanOp::IndexScan {
1653                label: Some(label), ..
1654            } => return Some(syms.intern(label)),
1655            PlanOp::IndexIntersect {
1656                label: Some(label), ..
1657            } => return Some(syms.intern(label)),
1658            _ => {}
1659        }
1660    }
1661    None
1662}
1663
1664/// How an as-of read is restricted — the argument to
1665/// [`GraphDb::query_at_scoped`].
1666///
1667/// Every variant is resolved against the graph **as it was at the requested
1668/// commit**, not against the current graph.
1669#[derive(Debug, Clone, Copy)]
1670pub enum AsOfScope<'a> {
1671    /// Everything the named role may see. The role *definition* is the current
1672    /// one — `roles.json` is a sidecar and has no past version — but its
1673    /// `keys` and `labels` are resolved against the as-of graph.
1674    Role(&'a str),
1675    /// An explicit node-key allow-list. Keys that did not exist at that commit
1676    /// resolve to nothing.
1677    Keys(&'a [String]),
1678    /// A role intersected with a client-supplied allow-list. The intersection
1679    /// is the never-widen rule: a client mask can only narrow a role.
1680    RoleAndKeys(&'a str, &'a [String]),
1681    /// Every live node in one namespace, as the graph was at that commit.
1682    ///
1683    /// A namespace cannot change — it is set at insert and immutable — so the
1684    /// answer is simply "the nodes that existed then and are in this
1685    /// namespace". A name no node uses resolves to nothing, never to
1686    /// everything.
1687    Namespace(&'a str),
1688}
1689
1690impl GraphDb<RealFs> {
1691    /// Open the database at `dir` with default options.
1692    ///
1693    /// Equivalent to `open_with_options(dir, OpenOptions::default())`.
1694    /// Old-format snapshots (V5, V6) are automatically migrated to the
1695    /// current version on a successful load (see [`OpenOptions::auto_migrate`]).
1696    pub fn open(dir: &std::path::Path) -> Result<Self> {
1697        Self::open_with_options(dir, OpenOptions::default())
1698    }
1699
1700    /// Open the database at `dir` with explicit options.
1701    ///
1702    /// When `opts.auto_migrate` is `true` (the default) and the on-disk
1703    /// snapshot is an older format version, this function:
1704    ///   1. Copies the current `snapshot.bin` to `snapshot.bin.bak` (atomic
1705    ///      + fsynced) before any modification.
1706    ///   2. Rewrites `snapshot.bin` at the current format version via
1707    ///      [`GraphDb::snapshot_with`] with `keep_wal: true` (WAL preserved).
1708    ///
1709    /// If migration fails the error is returned and the original files are
1710    /// intact (the `.bak` was written before the new snapshot was attempted).
1711    ///
1712    /// A clean open that finds the snapshot already at the current version
1713    /// deletes any leftover `.bak` file.
1714    ///
1715    /// WAL-only stores (no snapshot) are never auto-migrated on open.
1716    ///
1717    /// `opts.repair_wal` controls the other write this function can make; see
1718    /// [`OpenOptions::repair_wal`]. With both flags `false` the open touches
1719    /// no file on disk.
1720    pub fn open_with_options(dir: &std::path::Path, opts: OpenOptions) -> Result<Self> {
1721        Self::open_dir(dir, opts, true)
1722    }
1723
1724    /// Open without taking the cross-process write lock for the handle's
1725    /// lifetime.
1726    ///
1727    /// Only [`SharedDb`](crate::SharedDb) uses this: a long-lived server holds
1728    /// its handle open indefinitely, so it takes the lock per write instead of
1729    /// keeping every other process out of the store for as long as it runs.
1730    pub(crate) fn open_unlocked(dir: &std::path::Path) -> Result<Self> {
1731        Self::open_dir(dir, OpenOptions::default(), false)
1732    }
1733
1734    fn open_dir(dir: &std::path::Path, opts: OpenOptions, hold_lock: bool) -> Result<Self> {
1735        // Header-only peek — 6 bytes, no full decode.
1736        let snap_version = snapshot_version_at(dir)?;
1737
1738        // Full load: decode snapshot + replay WAL + rebuild indexes.
1739        let mut db = Self::open_generic(RealFs::new(dir)?, opts, hold_lock)?;
1740
1741        // A read-only handle writes nothing at open, so it never migrates —
1742        // the old-format snapshot is loaded and left exactly as it is.
1743        if opts.auto_migrate && !opts.read_only {
1744            match snap_version {
1745                Some(ver) if ver < core_storage::snapshot::VERSION => {
1746                    let _tm = std::time::Instant::now();
1747                    // Copy the original snapshot to .bak at OS level — no in-memory
1748                    // buffer required for a 2+ GiB file.
1749                    //
1750                    // Crash-safety: snapshot.bin remains intact (write_atomic inside
1751                    // snapshot_with uses a .tmp+rename) until the V8 write succeeds.
1752                    // A torn .bak on crash is acceptable because the original
1753                    // snapshot.bin is the authoritative source until after the rename.
1754                    std::fs::copy(dir.join("snapshot.bin"), dir.join("snapshot.bin.bak"))
1755                        .map_err(core_storage::GraphError::Io)?;
1756                    trace_migrate!("bak copy done", _tm);
1757                    // Rewrite snapshot at current version; keep WAL intact.
1758                    db.snapshot_with(SnapshotOptions {
1759                        keep_wal: true,
1760                        ..SnapshotOptions::default()
1761                    })?;
1762                    trace_migrate!("snapshot_with done", _tm);
1763                }
1764                Some(_) => {
1765                    // Already current version: remove any leftover .bak.
1766                    let bak = dir.join("snapshot.bin.bak");
1767                    if bak.exists() {
1768                        std::fs::remove_file(&bak).map_err(core_storage::GraphError::Io)?;
1769                    }
1770                }
1771                None => {
1772                    // WAL-only store — nothing to migrate on open.
1773                }
1774            }
1775        }
1776
1777        Ok(db)
1778    }
1779
1780    /// Open a read-only view of the database as it existed after `commit`.
1781    ///
1782    /// Commit indices are 0-based over the current WAL: commit 0 is the state
1783    /// after the first WAL frame, commit N-1 is the state after the N-th (most
1784    /// recent) frame.  Call [`GraphDb::open`] to read the full current state.
1785    ///
1786    /// **Replay base.** [`GraphDb::snapshot`] truncates the WAL when it runs,
1787    /// so as-of can only reach commits recorded in the current WAL (those
1788    /// written after the most recent snapshot, or all commits if no snapshot
1789    /// was ever taken).  Commit 0 in `open_at` always refers to the first
1790    /// frame in the WAL that exists on disk, not the first ever write to the
1791    /// database.  When the on-disk snapshot recorded that it truncated the
1792    /// WAL (V7, default `keep_wal: false`), it is loaded as the base state
1793    /// before frame replay, so the as-of view includes all pre-snapshot data.
1794    /// Snapshots written with `keep_wal: true` (and legacy V5/V6 snapshots)
1795    /// are ignored and replay is WAL-only, as before.
1796    ///
1797    /// **Read-only.** Every mutation method and `snapshot()` on the returned
1798    /// instance returns [`GraphError::ReadOnly`].  Queries, `explain()`, and
1799    /// `stats()` work normally.
1800    ///
1801    /// # Errors
1802    /// - [`GraphError::CommitOutOfRange`] if `commit >= wal_commit_count` (including
1803    ///   when the WAL is empty after a snapshot).
1804    pub fn open_at(dir: &std::path::Path, commit: u64) -> Result<Self> {
1805        Self::open_at_with(RealFs::new(dir)?, commit)
1806    }
1807
1808    /// Run a **read-only** Cypher query against the graph as it existed at
1809    /// `commit` — the "time-travel" / agent-replay query. Opens a temporal view
1810    /// of this store's directory at that commit and executes the read there.
1811    ///
1812    /// The current instance is unaffected. Write statements are rejected (the
1813    /// temporal view is read-only). `commit` is a 0-based WAL commit index;
1814    /// `commit == wal_commit_count` (or `open_at`'s range) yields the newest
1815    /// state. Prefer this over holding many historical instances open.
1816    ///
1817    /// # Errors
1818    /// - [`GraphError::CommitOutOfRange`] if `commit` is past the WAL horizon.
1819    /// - A query error for a malformed or write query.
1820    pub fn query_at(
1821        &self,
1822        commit: u64,
1823        cypher: &str,
1824        params: &std::collections::BTreeMap<String, Value>,
1825    ) -> Result<ResultSet> {
1826        let temporal = self.open_at_for_read(commit, cypher)?;
1827        temporal.query(cypher, params)
1828    }
1829
1830    /// Run a **read-only** Cypher query at `commit`, restricted by `scope`.
1831    ///
1832    /// The **graph** is as of `commit`; the **role definition** is as it is
1833    /// now, because `roles.json` is a sidecar and is never a WAL record — it
1834    /// has no past version to read. A role's `keys` and `labels` are resolved
1835    /// against the commit-`commit` graph, so a role that may see a label sees
1836    /// exactly the nodes that carried it then, and an explicit key that did
1837    /// not exist yet resolves to nothing.
1838    ///
1839    /// [`AsOfScope::RoleAndKeys`] intersects the two: a client allow-list can
1840    /// only narrow what a role may see, never widen it.
1841    ///
1842    /// Write statements are rejected, exactly as [`GraphDb::query_at`] rejects
1843    /// them.
1844    ///
1845    /// # Errors
1846    /// - [`GraphError::CommitOutOfRange`] if `commit` is outside the retained
1847    ///   range; the error carries that range.
1848    /// - [`GraphError::KeyNotFound`] with a `role:` prefix for an unknown role,
1849    ///   or [`GraphError::Corrupt`] when `roles.json` was corrupt at open.
1850    /// - A query error for a malformed or write query.
1851    pub fn query_at_scoped(
1852        &self,
1853        commit: u64,
1854        cypher: &str,
1855        params: &std::collections::BTreeMap<String, Value>,
1856        scope: AsOfScope<'_>,
1857    ) -> Result<ResultSet> {
1858        let temporal = self.open_at_for_read(commit, cypher)?;
1859        let mask = temporal.mask_at_scope(scope)?;
1860        temporal.query_masked(cypher, params, &mask)
1861    }
1862
1863    /// As [`GraphDb::query_at_scoped`], with `namespace` intersected into
1864    /// whatever `scope` resolves to.
1865    ///
1866    /// This is what a surface needs when a caller passes `namespace` beside a
1867    /// `role` or a client mask on a time-travel read: [`AsOfScope`] names one
1868    /// restriction, and the namespace is a second one that composes with it
1869    /// rather than replacing it. The intersection is the never-widen rule — a
1870    /// namespace can only narrow what the scope already allows — and both legs
1871    /// are resolved against the graph as it was at `commit`.
1872    ///
1873    /// `AsOfScope::Namespace(ns)` is still the way to ask for a namespace alone.
1874    pub fn query_at_scoped_in_namespace(
1875        &self,
1876        commit: u64,
1877        cypher: &str,
1878        params: &std::collections::BTreeMap<String, Value>,
1879        scope: AsOfScope<'_>,
1880        namespace: &str,
1881    ) -> Result<ResultSet> {
1882        let temporal = self.open_at_for_read(commit, cypher)?;
1883        let mask = temporal
1884            .mask_at_scope(scope)?
1885            .intersect(&temporal.mask_for_namespace(namespace));
1886        temporal.query_masked(cypher, params, &mask)
1887    }
1888
1889    /// Open the temporal view for a time-travel read and refuse write Cypher.
1890    ///
1891    /// Shared by [`GraphDb::query_at`] and [`GraphDb::query_at_scoped`] so both
1892    /// resolve the commit and reject writes identically.
1893    fn open_at_for_read(&self, commit: u64, cypher: &str) -> Result<Self> {
1894        let dir = self.fs.dir().to_path_buf();
1895        let temporal = Self::open_at(&dir, commit)?;
1896        if is_write_tokens(&lex(cypher).map_err(|e| GraphError::QueryError {
1897            detail: format!("lex: {e}"),
1898        })?) {
1899            return Err(GraphError::QueryError {
1900                detail: "query_at is read-only: write statements are not permitted in a \
1901                         time-travel query"
1902                    .into(),
1903            });
1904        }
1905        Ok(temporal)
1906    }
1907}
1908
1909impl<F: Fs> GraphDb<F> {
1910    /// Open over an arbitrary [`Fs`], repairing a torn WAL tail as usual.
1911    pub fn open_with(fs: F) -> Result<Self> {
1912        Self::open_with_repair(fs, true)
1913    }
1914
1915    /// As [`GraphDb::open_with`], but `repair_wal: false` decodes the valid WAL
1916    /// prefix without writing the truncation back. See
1917    /// [`OpenOptions::repair_wal`].
1918    pub fn open_with_repair(fs: F, repair_wal: bool) -> Result<Self> {
1919        Self::open_generic(
1920            fs,
1921            OpenOptions {
1922                repair_wal,
1923                ..OpenOptions::default()
1924            },
1925            true,
1926        )
1927    }
1928
1929    /// Shared open path.
1930    ///
1931    /// `hold_lock` requests the cross-process write lock for the whole handle
1932    /// lifetime — the right behaviour for a plain read-write `GraphDb`, whose
1933    /// owner writes through it directly. [`SharedDb`](crate::SharedDb) passes
1934    /// `false` and takes the lock per write instead, so that a long-lived
1935    /// server does not keep every other process out of the store.
1936    ///
1937    /// A read-only open never takes the lock regardless of `hold_lock`.
1938    fn open_generic(fs: F, opts: OpenOptions, hold_lock: bool) -> Result<Self> {
1939        let mut db = Self::new_empty(fs, opts);
1940        db.read_only = opts.read_only;
1941        if hold_lock && !opts.read_only {
1942            if !db.poll_lock(WRITE_LOCK_WAIT)? {
1943                return Err(GraphError::Busy { holder: None });
1944            }
1945            db.holds_lifetime_lock = true;
1946        }
1947        db.load_from_disk(LoadOrigin::Open)?;
1948        Ok(db)
1949    }
1950
1951    /// A handle with no state loaded: every field at its empty value, the
1952    /// filesystem and options in place. Only [`load_from_disk`] makes it
1953    /// usable.
1954    fn new_empty(fs: F, opts: OpenOptions) -> Self {
1955        Self {
1956            fs,
1957            ids: IdMap::new(),
1958            syms: Interner::new(),
1959            topo: Topology::new(),
1960            props: ColumnStore::new(),
1961            labels: Vec::new(),
1962            ns_names: vec![NS_DEFAULT.to_string()],
1963            node_ns: Vec::new(),
1964            edge_props: EdgeProps::new(),
1965            engine: RuleEngine::new(),
1966            view_store: ViewStore::new(),
1967            fulltext: FulltextIndex::new(),
1968            prop_index: PropertyIndex::new(),
1969            event_sink: None,
1970            fsync: FsyncPolicy::Strict,
1971            commit_seq: 0,
1972            roles: Some(vec![]),
1973            role_masks: Arc::new(crate::mask::RoleMaskCache::new()),
1974            subscriptions: Vec::new(),
1975            query_subscriptions: Vec::new(),
1976            sub_capacity: DEFAULT_SUB_CAPACITY,
1977            read_only: false,
1978            total_wal_commits: 0,
1979            base: None,
1980            fold_overlay: None,
1981            delta_tail: Vec::new(),
1982            commits_since_fold: 0,
1983            defer_events: false,
1984            deferred_events: Vec::new(),
1985            degraded: false,
1986            v8_sections_loaded: std::sync::atomic::AtomicBool::new(false),
1987            v8_sections_mutex: std::sync::Mutex::new(()),
1988            last_change: HashMap::new(),
1989            wal_archive_retention: None,
1990            wal_horizon_floor: 0,
1991            archive_genesis_chain: false,
1992            pending_write_authz: None,
1993            slow_query_threshold_ms: std::env::var("MUSHROOMDB_SLOW_QUERY_MS")
1994                .ok()
1995                .and_then(|v| v.parse().ok())
1996                .unwrap_or(100),
1997            slow_queries: std::sync::Mutex::new(SlowQueryLog {
1998                entries: std::collections::VecDeque::new(),
1999                total: 0,
2000            }),
2001            started_at: std::time::Instant::now(),
2002            wal_consumed: 0,
2003            snapshot_ident: None,
2004            open_opts: opts,
2005            holds_lifetime_lock: false,
2006            lock_denied: false,
2007            pinned: false,
2008        }
2009    }
2010
2011    /// Return every field describing stored graph state to its empty value,
2012    /// leaving this handle's own identity alone.
2013    ///
2014    /// Preserved on purpose: the filesystem, open options, lock ownership, the
2015    /// event sink and subscriptions, fsync policy, degraded flag, and the
2016    /// slow-query configuration and log. A caller that registered a sink or a
2017    /// subscription keeps it across a reload.
2018    fn reset_for_reload(&mut self) {
2019        self.ids = IdMap::new();
2020        self.syms = Interner::new();
2021        self.topo = Topology::new();
2022        self.props = ColumnStore::new();
2023        self.labels = Vec::new();
2024        self.ns_names = vec![NS_DEFAULT.to_string()];
2025        self.node_ns = Vec::new();
2026        self.edge_props = EdgeProps::new();
2027        self.engine = RuleEngine::new();
2028        self.view_store = ViewStore::new();
2029        self.fulltext = FulltextIndex::new();
2030        self.prop_index = PropertyIndex::new();
2031        self.commit_seq = 0;
2032        self.roles = Some(vec![]);
2033        // A fresh cache, not a cleared one: any reader snapshot still holding
2034        // the old `Arc` keeps it to itself, so nothing it memoised against the
2035        // pre-reload store can be read back through this handle.
2036        self.role_masks = Arc::new(crate::mask::RoleMaskCache::new());
2037        self.total_wal_commits = 0;
2038        self.base = None;
2039        self.fold_overlay = None;
2040        self.delta_tail = Vec::new();
2041        self.commits_since_fold = 0;
2042        self.deferred_events = Vec::new();
2043        self.v8_sections_loaded
2044            .store(false, std::sync::atomic::Ordering::Release);
2045        self.last_change = HashMap::new();
2046        self.wal_horizon_floor = 0;
2047        self.archive_genesis_chain = false;
2048        self.pending_write_authz = None;
2049        self.wal_consumed = 0;
2050        self.snapshot_ident = None;
2051    }
2052
2053    /// Load the snapshot base and replay the WAL into an empty handle — the
2054    /// whole of what opening a store does after the struct exists.
2055    ///
2056    /// Split out of the open path so that [`refresh`](GraphDb::refresh) can
2057    /// rebuild a handle in place, without ownership of `F`, when another
2058    /// process replaces the snapshot underneath it.
2059    ///
2060    /// `origin` decides whether the two repair writes this function can make
2061    /// are appropriate; see [`LoadOrigin`].
2062    fn load_from_disk(&mut self, origin: LoadOrigin) -> Result<usize> {
2063        // Both writes below are crash recovery, and only an open is entitled to
2064        // perform them. A read-only handle promises to touch nothing, and a
2065        // reload driven by `refresh` is looking at a store another process is
2066        // actively writing: what looks like a torn tail there is a peer
2067        // mid-append, and what looks like an orphaned archive may be one that
2068        // peer is about to reference.
2069        let may_repair = origin == LoadOrigin::Open && !self.open_opts.read_only;
2070        let repair_wal = self.open_opts.repair_wal && may_repair;
2071        let db = self;
2072        db.wal_horizon_floor = db.fs.read_horizon_floor()?;
2073        db.archive_genesis_chain = db.fs.has_genesis_marker();
2074        // Opening cleanup: remove orphaned archives — archives whose frames all
2075        // fall below the horizon floor.  Orphans arise when a crash interrupted
2076        // the retention-prune sequence after the floor was written but before
2077        // all surplus archives were deleted.  Safe to delete: floor already
2078        // accounts for their frames.
2079        if may_repair {
2080            db.cleanup_orphaned_archives()?;
2081        }
2082        let _t0 = std::time::Instant::now();
2083        // Peek 6 bytes to determine snapshot version without reading the full
2084        // file. For RealFs this is a true partial read (O(1)); for SimFs the
2085        // default impl reads all bytes and truncates (still correct).
2086        let snap_header = db.fs.read_prefix(FileId::Snapshot, 6)?;
2087        // V8 and V9 share the mmap-able container; V9 only adds section 12.
2088        let is_v8 = snap_header.len() >= 6
2089            && &snap_header[0..4] == b"GDB1"
2090            && matches!(
2091                u16::from_le_bytes([snap_header[4], snap_header[5]]),
2092                core_storage::snapshot::VERSION_8 | core_storage::snapshot::VERSION_9
2093            );
2094        if is_v8 {
2095            // V8: map the file zero-copy (RealFs) or read full bytes (SimFs).
2096            // No 2.4GB heap Vec is allocated on RealFs.
2097            let mapped = Arc::new(
2098                if let Some(snap_path) = db.fs.snapshot_path() {
2099                    core_storage::v8::MappedBase::map(&snap_path)
2100                } else {
2101                    let snap_bytes = db.fs.read(FileId::Snapshot)?;
2102                    core_storage::v8::MappedBase::from_bytes(snap_bytes)
2103                }
2104                .map_err(|e| GraphError::Corrupt {
2105                    detail: format!("v8: mmap open: {e:?}"),
2106                })?,
2107            );
2108            db.restore_v8_base(Arc::clone(&mapped))?;
2109            trace_open!("restore_v8_base", _t0);
2110            db.base = Some(mapped);
2111            trace_open!("base assigned", _t0);
2112        } else if !snap_header.is_empty() {
2113            // Legacy V5-V7: full read required for decode.
2114            let snap_bytes = db.fs.read(FileId::Snapshot)?;
2115            if let Some(state) = core_storage::snapshot::decode(&snap_bytes)? {
2116                db.restore_snapshot_state(state)?;
2117            }
2118        }
2119        // else: snap_header is empty = no snapshot file, fresh store.
2120        //
2121        // Seed commit_seq from the highest seq persisted in last_change so that
2122        // WAL-replay frames (which start at commit_seq+1) always exceed any seq
2123        // already stored in the snapshot.  Without this, a db with one snapshot
2124        // commit would save last_change["a"]=1, then on reopen the first WAL
2125        // frame would replay at seq=1 again — colliding and making WAL-tail
2126        // mutations indistinguishable from the snapshot baseline.
2127        //
2128        // Safety invariant (seq-recycling):
2129        //   Recycled seqs (those below the seeded baseline) were NEVER stored in
2130        //   last_change because they belonged to a previous db lifetime — a new
2131        //   db starts at commit_seq=0 with an empty last_change.  Therefore no
2132        //   CAS precondition can carry a recycled seq as its `expected` value
2133        //   and accidentally match a live node's last_change entry.
2134        //
2135        // `expected:0` on a deleted-then-reinserted node:
2136        //   After deletion, last_changed() returns None; callers that call
2137        //   last_changed() and then use NodeUnchangedSince get None.unwrap_or(0)
2138        //   = 0.  The reinserted node gets seq > 0, so a subsequent CAS with
2139        //   expected=0 correctly conflicts.  The only way to observe actual=0 in
2140        //   a CasConflict would be a caller that invented expected=0 without ever
2141        //   calling last_changed() — unreachable via the documented API contract.
2142        if let Some(&max_seq) = db.last_change.values().max() {
2143            db.commit_seq = db.commit_seq.max(max_seq);
2144        }
2145        let bytes = db.fs.read(FileId::Wal)?;
2146        let (records, valid_len) = decode_all(&bytes);
2147        // The valid prefix is replayed either way; `repair_wal` only decides
2148        // whether the truncation is written back. A reader that races a live
2149        // appender must not persist a truncation the writer never asked for.
2150        if valid_len < bytes.len() && repair_wal {
2151            db.fs.write_atomic(FileId::Wal, &bytes[..valid_len])?;
2152        }
2153        // WAL-present path: build indexes eagerly BEFORE replay so that the
2154        // first replayed record does not trigger the lazy-init guard (which
2155        // would call reindex_all_load_state on an empty graph, defeating the
2156        // point of restoring IVF/HNSW blobs from the snapshot).
2157        if !records.is_empty() {
2158            db.ensure_v8_base_sections_loaded();
2159            trace_open!("lazy sections loaded (WAL path)", _t0);
2160        }
2161        let replayed = db.apply_frames(records)?;
2162        // The cursor sits at the end of the valid prefix, not the end of the
2163        // file: a torn or still-being-written tail is unconsumed by definition
2164        // and stays visible to `is_stale` until it decodes.
2165        db.wal_consumed = valid_len as u64;
2166        db.snapshot_ident = db.fs.snapshot_ident().map_err(GraphError::Io)?;
2167        trace_open!("wal replay done", _t0);
2168        // Rebuild view values after WAL replay only when there is no V8 base.
2169        // With a V8 base, view values are correct in the snapshot and are updated
2170        // incrementally during WAL replay (on_edge_changed / on_prop_changed).
2171        // A full rebuild would read overlay-only props (empty after restore_v8_base)
2172        // and overwrite correct base values with wrong results (e.g. NeighborAgg
2173        // Sum reads no "score" in overlay → writes 0.0, shadowing the correct
2174        // base value).
2175        if db.base.is_none() {
2176            let topo_view = TopologyView::owned(&db.topo);
2177            db.view_store
2178                .rebuild_all(&mut db.props, &topo_view, &db.ids, &db.syms, &db.labels);
2179        }
2180        // Rebuild full-text index after WAL replay.  Corrects drift from
2181        // per-record incremental apply during replay.
2182        db.fulltext.rebuild_all(
2183            &db.ids,
2184            &db.labels,
2185            &db.syms,
2186            build_props_view(&db.props, &db.base),
2187        );
2188        db.prop_index.rebuild_all(
2189            &db.ids,
2190            &db.labels,
2191            &db.syms,
2192            build_props_view(&db.props, &db.base),
2193        );
2194        // Namespaces: one pass over the `ns` column, after the snapshot is
2195        // restored and the WAL replayed. Replay maintains `node_ns` record by
2196        // record as well; this pass is what makes a snapshot-only open right,
2197        // and it reads nothing on a store with no `ns` column.
2198        db.rebuild_node_ns();
2199        // Load roles sidecar. Missing file = no roles (Some(vec![])).
2200        // Corrupt/unparseable = poisoned (None); mask_for_role will fail-loud.
2201        db.roles = Self::load_roles_from_fs(&db.fs)?;
2202        // Capture the initial MVCC fold so reader() is ready immediately.
2203        db.fold_now();
2204        trace_open!("open_with complete", _t0);
2205        Ok(replayed)
2206    }
2207
2208    /// Apply decoded WAL frames to in-memory state, exactly as the open-path
2209    /// replay does — same `apply` calls, same per-frame delta drain, same
2210    /// commit-seq and last-change bookkeeping. Rules therefore fire and derived
2211    /// edges appear identically whether a frame arrives at open, from a local
2212    /// commit, or from another process by way of [`refresh`](GraphDb::refresh).
2213    ///
2214    /// Returns the number of frames applied.
2215    ///
2216    /// Deltas are drained and discarded per frame: replayed frames are already
2217    /// reflected on disk, so they are not news to a subscriber, and draining
2218    /// inside the loop keeps `pending_deltas` O(1) over a large WAL (I-2).
2219    fn apply_frames(&mut self, records: Vec<WalRecord>) -> Result<usize> {
2220        if records.is_empty() {
2221            return Ok(0);
2222        }
2223        // Materialize any state retained in the mmap base before the first
2224        // frame lands, so a replayed record cannot trip the lazy-init guard and
2225        // rebuild indexes from an empty graph. Both calls are idempotent.
2226        self.ensure_v8_base_sections_loaded();
2227        self.engine.consume_retained_state_eager(
2228            &self.ids,
2229            &self.syms,
2230            &self.labels,
2231            build_props_view(&self.props, &self.base),
2232        );
2233        let applied = records.len();
2234        for rec in records {
2235            self.apply(&rec)?;
2236            let _ = self.engine.drain_deltas();
2237            // Track commit_seq during replay so last_change entries are
2238            // consistent with the seqs assigned by log_then_apply_with on
2239            // subsequent live commits.  After N replayed frames, commit_seq=N;
2240            // live commits begin at N+1.
2241            self.commit_seq += 1;
2242            let replay_seq = self.commit_seq;
2243            self.update_last_change_from_rec(&rec, replay_seq);
2244        }
2245        // Enforce I-2: if the per-frame drain above is ever removed or skipped,
2246        // this assert catches the regression in debug builds immediately.
2247        debug_assert_eq!(
2248            self.engine.pending_delta_count(),
2249            0,
2250            "pending_deltas non-empty after replay — \
2251             per-frame drain must run inside the loop to keep memory O(1)"
2252        );
2253        // T2 note: the per-frame drain IS the suppression seam for replay.
2254        // Any future as-of replay path (Plan-15 T2) must drain here to feed
2255        // replaying subscribers; the mechanism is already in place.
2256        let _ = self.engine.drain_deltas(); // belt-and-braces no-op after loop drain
2257        Ok(applied)
2258    }
2259
2260    // ── Multi-process safety: cross-process write lock + WAL tailing ──────────
2261    //
2262    // mushroomdb is many-readers / one-writer across processes. Writers take an
2263    // advisory exclusive lock on the store's `LOCK` file; readers never do.
2264    // Every handle tracks how much of the WAL it has consumed, so it can pick
2265    // up another process's commits by decoding only the new tail rather than
2266    // reopening. See `docs/site/concurrency.md`.
2267
2268    /// Whether the store on disk has moved ahead of (or out from under) this
2269    /// handle's in-memory state.
2270    ///
2271    /// True when the WAL's length differs from this handle's cursor — another
2272    /// process committed, or is mid-append — or when the snapshot file's
2273    /// identity changed. Costs two metadata lookups and reads no file contents,
2274    /// so it is cheap enough for a read path to call.
2275    ///
2276    /// Always false for an as-of view from [`GraphDb::open_at`]: such a view is
2277    /// pinned to one commit and later commits are deliberately invisible to it.
2278    pub fn is_stale(&self) -> Result<bool> {
2279        if self.pinned {
2280            return Ok(false);
2281        }
2282        if self.fs.wal_len().map_err(GraphError::Io)? != self.wal_consumed {
2283            return Ok(true);
2284        }
2285        Ok(self.fs.snapshot_ident().map_err(GraphError::Io)? != self.snapshot_ident)
2286    }
2287
2288    /// Bring this handle up to date with every commit other processes have made,
2289    /// and return how many frames were applied.
2290    ///
2291    /// The WAL tail is decoded from this handle's cursor and applied through the
2292    /// same path the open replay uses, so rules fire and derived edges appear
2293    /// exactly as they would on a fresh open. Interners, id maps and indexes
2294    /// stay valid for the same reason.
2295    ///
2296    /// A frame another process is still writing is left alone: a trailing
2297    /// partial frame is a wait, not a corruption, and the handle stays stale
2298    /// until that frame is complete. Nothing is written to disk, so a read-only
2299    /// handle can refresh freely.
2300    ///
2301    /// When the snapshot file's identity changed, or the WAL is shorter than
2302    /// this handle's cursor, the WAL no longer continues our state — another
2303    /// process snapshotted or archived. The handle is then rebuilt from disk
2304    /// with the options it was opened with, and the return value is the number
2305    /// of frames in the new WAL.
2306    ///
2307    /// Returns 0 for an as-of view, which never follows later commits.
2308    ///
2309    /// # Errors
2310    ///
2311    /// An error here leaves the handle **degraded**: it got partway through
2312    /// applying the tail, or partway through a reload, so its in-memory state
2313    /// no longer matches any point on disk. Further mutations are refused and
2314    /// the handle must be reopened. Nothing on disk was damaged — the store
2315    /// itself is fine, and a fresh open recovers it.
2316    pub fn refresh(&mut self) -> Result<u64> {
2317        if self.pinned {
2318            return Ok(0);
2319        }
2320        let disk_ident = self.fs.snapshot_ident().map_err(GraphError::Io)?;
2321        let wal_len = self.fs.wal_len().map_err(GraphError::Io)?;
2322        if disk_ident != self.snapshot_ident || wal_len < self.wal_consumed {
2323            // The WAL no longer continues our state: rebuild from disk. State
2324            // is cleared first, so a failed load leaves an empty handle — mark
2325            // it degraded rather than let a caller read an empty graph as if
2326            // it were the store's contents.
2327            self.reset_for_reload();
2328            return match self.load_from_disk(LoadOrigin::Reload) {
2329                Ok(frames) => Ok(frames as u64),
2330                Err(e) => {
2331                    self.degraded = true;
2332                    Err(e)
2333                }
2334            };
2335        }
2336        if wal_len == self.wal_consumed {
2337            return Ok(0);
2338        }
2339        let tail = self
2340            .fs
2341            .read_range(FileId::Wal, self.wal_consumed)
2342            .map_err(GraphError::Io)?;
2343        let (records, valid_len) = decode_all(&tail);
2344        let applied = match self.apply_frames(records) {
2345            Ok(n) => n,
2346            Err(e) => {
2347                // Some frames landed and some did not, and the cursor cannot
2348                // say how many. Advancing it would skip the rest; leaving it
2349                // would replay what already applied. Neither is recoverable in
2350                // place, so refuse further writes and require a reopen.
2351                self.degraded = true;
2352                return Err(e);
2353            }
2354        };
2355        // Advance by the bytes actually decoded, never by the file length: an
2356        // incomplete trailing frame stays unconsumed for the next refresh.
2357        self.wal_consumed += valid_len as u64;
2358        if applied > 0 {
2359            // Peer commits must reach `reader()` snapshots taken from here on.
2360            // A full fold is what open does; refresh does not build per-commit
2361            // deltas, so there is nothing cheaper that stays correct.
2362            self.fold_now();
2363        }
2364        Ok(applied as u64)
2365    }
2366
2367    /// Byte offset of the WAL prefix this handle has applied.
2368    ///
2369    /// Exposed for tests that assert the cursor tracks appended bytes exactly.
2370    #[doc(hidden)]
2371    pub fn wal_consumed(&self) -> u64 {
2372        self.wal_consumed
2373    }
2374
2375    /// Rewind the WAL cursor after the group-commit drain thread truncated a
2376    /// failed group off the tail, so the cursor still describes the file.
2377    pub(crate) fn set_wal_consumed(&mut self, len: u64) {
2378        self.wal_consumed = len;
2379    }
2380
2381    /// One non-blocking attempt at the cross-process write lock.
2382    ///
2383    /// Takes `&self` so a caller can poll for the lock *before* it acquires the
2384    /// in-process write guard. That ordering is what keeps a busy peer in
2385    /// another process from stalling this process's readers.
2386    ///
2387    /// A handle that owns the lock for its lifetime always succeeds.
2388    pub(crate) fn try_cross_process_lock(&self) -> Result<bool> {
2389        if self.holds_lifetime_lock {
2390            return Ok(true);
2391        }
2392        self.fs.try_lock_exclusive().map_err(GraphError::Io)
2393    }
2394
2395    /// Poll for the cross-process write lock until `wait` elapses.
2396    ///
2397    /// One attempt is always made, so a zero wait is a single try. Returns
2398    /// `false` when the lock is still held elsewhere at the deadline; nothing
2399    /// has been written and retrying later is safe.
2400    ///
2401    /// Only the plain-`GraphDb` open path uses this, where the caller owns the
2402    /// handle outright. [`SharedDb`](crate::SharedDb) polls
2403    /// [`try_cross_process_lock`](GraphDb::try_cross_process_lock) itself so
2404    /// that it holds no in-process guard while it waits.
2405    fn poll_lock(&self, wait: std::time::Duration) -> Result<bool> {
2406        let deadline = std::time::Instant::now() + wait;
2407        loop {
2408            if self.try_cross_process_lock()? {
2409                return Ok(true);
2410            }
2411            let now = std::time::Instant::now();
2412            if now >= deadline {
2413                return Ok(false);
2414            }
2415            std::thread::sleep(LOCK_POLL_INTERVAL.min(deadline.saturating_duration_since(now)));
2416        }
2417    }
2418
2419    /// Open a cross-process write scope, given the outcome of an already-made
2420    /// lock attempt.
2421    ///
2422    /// The caller polls for the lock first — outside any in-process guard — and
2423    /// passes what it got. On success this refreshes, so the writes about to
2424    /// happen land on top of every other process's commits. On failure the
2425    /// handle refuses WAL-appending mutations and `snapshot()` with
2426    /// [`GraphError::Busy`] until [`end_write_lock`](GraphDb::end_write_lock)
2427    /// closes the scope, so a caller holding a guard cannot write behind
2428    /// another process's back.
2429    ///
2430    /// A handle that already owns the lock for its lifetime skips the refresh:
2431    /// no other process can have written, so there is nothing to pick up.
2432    pub(crate) fn enter_write_scope(&mut self, acquired: bool) -> Result<()> {
2433        self.lock_denied = !acquired;
2434        if !acquired || self.holds_lifetime_lock {
2435            return Ok(());
2436        }
2437        if let Err(e) = self.refresh() {
2438            // Do not hold a lock we cannot use: release it and let the caller
2439            // see the underlying failure.
2440            let _ = self.fs.unlock();
2441            self.lock_denied = true;
2442            return Err(e);
2443        }
2444        Ok(())
2445    }
2446
2447    /// Close a cross-process write scope opened by
2448    /// [`enter_write_scope`](GraphDb::enter_write_scope): release the lock and
2449    /// clear the Busy latch. Safe to call when the lock was never taken.
2450    pub(crate) fn end_write_lock(&mut self) {
2451        self.lock_denied = false;
2452        if !self.holds_lifetime_lock {
2453            // Releasing a lock we do not hold is a no-op; a failure to release
2454            // is reported by the OS closing the descriptor at handle drop.
2455            let _ = self.fs.unlock();
2456        }
2457    }
2458
2459    /// As-of replay for [`GraphDb::open_at`]: snapshot base (only when the
2460    /// snapshot truncated the WAL) plus the first `commit + 1` WAL frames;
2461    /// see [`GraphDb::open_at`] for the semantics.  The per-frame drain
2462    /// mirrors `open_with` exactly so pending_delta_count is 0 on exit.
2463    /// Restore all persisted state from a decoded snapshot. Shared by
2464    /// `open_with` and (when the snapshot truncated the WAL) `open_at_with`.
2465    fn restore_snapshot_state(
2466        &mut self,
2467        state: core_storage::snapshot::SnapshotState,
2468    ) -> Result<()> {
2469        self.ids = state.ids;
2470        self.syms = state.syms;
2471        self.topo = state.topo;
2472        self.props = state.props;
2473        self.labels = state.labels;
2474        self.edge_props = state.edge_props;
2475        // Cross-section label integrity for V5/V7 snapshots: same invariants as
2476        // restore_v8_base.  A crafted bincode snapshot with a short `labels` vec,
2477        // out-of-range sym ids, or a sentinel label on a live node would otherwise
2478        // open successfully and panic later in `NodeRef::label()` or
2479        // `neighborhood_masked()`.  Catching it here turns those into typed
2480        // `GraphError::Corrupt` at open time.
2481        {
2482            let ids_len = self.ids.len();
2483            if self.labels.len() != ids_len {
2484                return Err(GraphError::Corrupt {
2485                    detail: format!(
2486                        "snapshot: labels vec has {} entries but id table has {} total slots",
2487                        self.labels.len(),
2488                        ids_len,
2489                    ),
2490                });
2491            }
2492            let syms_len = self.syms.len() as u32;
2493            for (i, &sym) in self.labels.iter().enumerate() {
2494                let is_tombstoned = self.ids.is_tombstoned(i as u32);
2495                if sym == u32::MAX {
2496                    if !is_tombstoned {
2497                        return Err(GraphError::Corrupt {
2498                            detail: format!(
2499                                "snapshot: live node at id slot {i} has sentinel label (u32::MAX)"
2500                            ),
2501                        });
2502                    }
2503                } else if sym >= syms_len {
2504                    return Err(GraphError::Corrupt {
2505                        detail: format!(
2506                            "snapshot: label at id slot {i} references sym {sym} \
2507                             which is out of interner range ({syms_len})"
2508                        ),
2509                    });
2510                }
2511            }
2512        }
2513        let defs: Vec<RuleDef> = state
2514            .rule_defs
2515            .iter()
2516            .map(|b| {
2517                decode_rule_def(b).map_err(|e| GraphError::Corrupt {
2518                    detail: format!("snapshot rule_def deserialize: {e}"),
2519                })
2520            })
2521            .collect::<Result<Vec<_>>>()?;
2522        self.engine =
2523            RuleEngine::from_persist(defs, state.provenance, state.rule_tripped, state.rule_fires);
2524        // Candidate indexes are rebuilt lazily on the first mutation (see
2525        // RuleEngine::on_node_changed).  HNSW blobs and IVF centroids from the
2526        // snapshot are retained without deserializing so that:
2527        //   - clean-open (empty WAL): indexes stay empty; blobs load on first
2528        //     ANN query via ensure_hnsw_loaded, or on first mutation via the
2529        //     lazy-init guard which calls reindex_all_load_state (the scan
2530        //     skips the HNSW build for every side the blob supplies).
2531        //   - WAL-present: open_with calls consume_retained_state_eager before
2532        //     replay so HNSW/IVF are live before any record fires the hooks.
2533        let ivf_bytes = if state.ivf_state.is_empty() {
2534            Vec::new()
2535        } else {
2536            bincode::serialize(&state.ivf_state).expect("IVF state serialize cannot fail")
2537        };
2538        // Store blobs without eagerly deserializing them.
2539        // `self.ids` is the snapshot's id table at this point — WAL replay has
2540        // not run — so its length is the line an interrupted build is detected
2541        // against.
2542        let snapshot_ids = self.ids.len() as u32;
2543        self.engine
2544            .store_snapshot_state(state.hnsw_state, ivf_bytes, snapshot_ids);
2545        // Restore view defs from snapshot (V5).
2546        // The ColumnStore already contains view values from the snapshot;
2547        // use restore_view (no collision check, no backfill) so the store
2548        // is aware of the definitions.  rebuild_all runs after WAL replay.
2549        for def_bytes in &state.view_defs {
2550            let def: ViewDef =
2551                bincode::deserialize(def_bytes).map_err(|e| GraphError::Corrupt {
2552                    detail: format!("snapshot view_def deserialize: {e}"),
2553                })?;
2554            self.view_store
2555                .restore_view(def)
2556                .map_err(|e| GraphError::Corrupt {
2557                    detail: format!("snapshot view restore: {e}"),
2558                })?;
2559        }
2560        Ok(())
2561    }
2562
2563    /// Restore all persisted state from a V8 `MappedBase` snapshot, **except**
2564    /// topology (`self.topo` stays empty and serves as the WAL-replay overlay).
2565    ///
2566    /// `self.props` IS fully materialised from the base so that HNSW/IVF blob
2567    /// deserialization and view rebuild have access to all column data.
2568    fn restore_v8_base(&mut self, mapped: Arc<core_storage::v8::MappedBase>) -> Result<()> {
2569        self.ids = archived_to_idmap(mapped.ids().map_err(|e| GraphError::Corrupt {
2570            detail: format!("v8: ids section: {e:?}"),
2571        })?);
2572        self.syms = archived_to_interner(mapped.syms().map_err(|e| GraphError::Corrupt {
2573            detail: format!("v8: syms section: {e:?}"),
2574        })?);
2575
2576        // C1: self.props is left as an empty overlay. Column reads go through
2577        // props_view() (ColumnsView::with_base), which consults the archived base
2578        // section zero-copy. This avoids the O(columns) heap copy at every open.
2579
2580        // self.topo deliberately left as Topology::new() — overlay path.
2581
2582        let meta = decode_meta(mapped.meta_bytes().map_err(|e| GraphError::Corrupt {
2583            detail: format!("v8: meta section: {e:?}"),
2584        })?)
2585        .map_err(|e| GraphError::Corrupt {
2586            detail: format!("v8: meta decode: {e:?}"),
2587        })?;
2588        self.labels = meta.labels;
2589        // Cross-section label integrity: labels must cover every id slot (live
2590        // and tombstoned), every non-sentinel sym must be within the interner's
2591        // bound, and no live (non-tombstoned) node may carry the u32::MAX
2592        // sentinel label.  Without this check, a crafted snapshot where the META
2593        // section (small, CRC-validated) holds a short `labels` vec, out-of-range
2594        // sym ids, or a sentinel label on a live node, would open successfully
2595        // and then panic in `NodeRef::label()`, `neighborhood_masked()`, and
2596        // related read paths.  Catching the inconsistency here converts those
2597        // panics into typed `GraphError::Corrupt` at open time.
2598        {
2599            let ids_len = self.ids.len();
2600            if self.labels.len() != ids_len {
2601                return Err(GraphError::Corrupt {
2602                    detail: format!(
2603                        "v8: labels section has {} entries but id table has {} total slots",
2604                        self.labels.len(),
2605                        ids_len,
2606                    ),
2607                });
2608            }
2609            let syms_len = self.syms.len() as u32;
2610            for (i, &sym) in self.labels.iter().enumerate() {
2611                let is_tombstoned = self.ids.is_tombstoned(i as u32);
2612                if sym == u32::MAX {
2613                    // Sentinel is only valid for tombstoned slots.
2614                    if !is_tombstoned {
2615                        return Err(GraphError::Corrupt {
2616                            detail: format!(
2617                                "v8: live node at id slot {i} has sentinel label (u32::MAX)"
2618                            ),
2619                        });
2620                    }
2621                } else if sym >= syms_len {
2622                    return Err(GraphError::Corrupt {
2623                        detail: format!(
2624                            "v8: label at id slot {i} references sym {sym} \
2625                             which is out of interner range ({syms_len})"
2626                        ),
2627                    });
2628                }
2629            }
2630        }
2631        // C3: self.edge_props stays as an empty overlay.  Reads go through
2632        // edge_props_view() which consults the mmap'd base section zero-copy
2633        // via EdgePropsView::with_base.  No heap decode at open time.
2634
2635        // Restore rule engine.
2636        let (rule_def_bytes, rule_tripped, rule_fires) =
2637            archived_rules_meta_to_owned(mapped.rules_meta_section().map_err(|e| {
2638                GraphError::Corrupt {
2639                    detail: format!("v8: rules_meta section: {e:?}"),
2640                }
2641            })?);
2642        let defs: Vec<RuleDef> = rule_def_bytes
2643            .iter()
2644            .map(|b| {
2645                decode_rule_def(b).map_err(|e| GraphError::Corrupt {
2646                    detail: format!("v8: rule_def deserialize: {e}"),
2647                })
2648            })
2649            .collect::<Result<Vec<_>>>()?;
2650        self.engine = RuleEngine::from_persist(defs, BTreeMap::new(), rule_tripped, rule_fires);
2651        // C4+C5: provenance, HNSW, and IVF sections are NOT read here.
2652        // `ensure_v8_base_sections_loaded` reads them on first use from
2653        // `self.base` (set by the caller immediately after this returns).
2654        // A clean open touches only: header + IDS + SYMS + META + RULES_META.
2655
2656        // Restore view definitions.
2657        let view_defs =
2658            archived_views_to_owned(mapped.views_section().map_err(|e| GraphError::Corrupt {
2659                detail: format!("v8: views section: {e:?}"),
2660            })?);
2661        for def_bytes in &view_defs {
2662            let def: ViewDef =
2663                bincode::deserialize(def_bytes).map_err(|e| GraphError::Corrupt {
2664                    detail: format!("v8: view_def deserialize: {e}"),
2665                })?;
2666            self.view_store
2667                .restore_view(def)
2668                .map_err(|e| GraphError::Corrupt {
2669                    detail: format!("v8: view restore: {e}"),
2670                })?;
2671        }
2672        // Load the last-change map from section 11 (small section; load eagerly).
2673        // Pre-Task-3 snapshots lack this section; `last_change_bytes` returns &[]
2674        // in that case and `decode_last_change_bytes` returns an empty map.
2675        let last_change_raw = mapped
2676            .last_change_bytes()
2677            .map_err(|e| GraphError::Corrupt {
2678                detail: format!("v8: last_change section: {e:?}"),
2679            })?;
2680        self.last_change = decode_last_change_bytes(last_change_raw);
2681
2682        // Validate that all deferred sections (provenance, HNSW, IVF) fit within
2683        // the file.  Pure bounds check — no bytes read, no page faults triggered.
2684        // Catches truncated snapshots at open time before the lazy deferred reads.
2685        mapped.validate_section_bounds().map_err(|e| match e {
2686            GraphError::Corrupt { detail } => GraphError::Corrupt {
2687                detail: format!("v8: section bounds: {detail}"),
2688            },
2689            other => other,
2690        })?;
2691        Ok(())
2692    }
2693
2694    /// Read provenance, HNSW, and IVF sections from the mmap base into the
2695    /// engine's retained fields on first call.  Subsequent calls are a no-op
2696    /// (AtomicBool fast-path).
2697    ///
2698    /// Must be called before any code path that reads or mutates engine
2699    /// provenance, HNSW, or IVF state:
2700    /// - WAL replay (before `consume_retained_state_eager`)
2701    /// - First mutation (`log_then_apply_with`)
2702    /// - Read-only paths (`stats`, `explain`, `node_edges`)
2703    /// - Snapshot (`snapshot_with`)
2704    ///
2705    /// No-op for fresh stores and V5-V7 opens (`self.base` is `None`).
2706    fn ensure_v8_base_sections_loaded(&self) {
2707        use std::sync::atomic::Ordering;
2708        if self.v8_sections_loaded.load(Ordering::Acquire) {
2709            return;
2710        }
2711        let _guard = self
2712            .v8_sections_mutex
2713            .lock()
2714            .expect("v8 sections mutex poisoned");
2715        if self.v8_sections_loaded.load(Ordering::Acquire) {
2716            return; // another caller populated while we waited
2717        }
2718        let _t = std::time::Instant::now();
2719        if let Some(base) = &self.base {
2720            // Provenance: raw rkyv bytes; CRC validated inside section_bytes.
2721            // Bounds are already validated at open time (restore_v8_base →
2722            // validate_section_bounds) — unreachable post-validate_section_bounds;
2723            // unwrap_or_default is a safety belt against impossible errors.
2724            let prov_bytes = base
2725                .provenance_raw_bytes()
2726                .map(|b| b.to_vec())
2727                .unwrap_or_default();
2728            self.engine.store_provenance_bytes(prov_bytes);
2729            // HNSW: decode rkyv blobs into owned map.
2730            let hnsw_state = base
2731                .hnsw_section()
2732                .map(archived_hnsw_to_owned)
2733                .unwrap_or_default();
2734            // IVF: raw bincode bytes; deserialized on first mutation/query.
2735            let ivf_bytes = base.ivf_bytes().map(|b| b.to_vec()).unwrap_or_default();
2736            // Called before WAL replay on a WAL-present open (`open_with`) and
2737            // before any write on a clean one, so this is the snapshot's count.
2738            let snapshot_ids = self.ids.len() as u32;
2739            self.engine
2740                .store_snapshot_state(hnsw_state, ivf_bytes, snapshot_ids);
2741        }
2742        self.v8_sections_loaded.store(true, Ordering::Release);
2743        if std::env::var("MUSHROOMDB_TRACE_OPEN").is_ok() {
2744            eprintln!(
2745                "[MUSHROOMDB_TRACE_OPEN] ensure_v8_base_sections_loaded: {:>9.3?}",
2746                _t.elapsed()
2747            );
2748        }
2749    }
2750
2751    /// Return a `TopologyView` that merges the mmap'd base (when present) with
2752    /// the in-memory WAL overlay.  Used by all read paths in db.rs that need
2753    /// the full merged topology without going through `self.view()`.
2754    fn topo_view(&self) -> TopologyView<'_> {
2755        match self.base {
2756            None => TopologyView::owned(&self.topo),
2757            Some(ref base) => {
2758                // SAFETY: base lives as long as self; section bounds validated at open.
2759                // topology() uses access_unchecked; all field reads are bounds-checked in seam.rs.
2760                let archived = base
2761                    .topology()
2762                    .expect("base topology section bounds validated at open");
2763                TopologyView::with_base(&self.topo, archived)
2764            }
2765        }
2766    }
2767
2768    /// Return a `ColumnsView` that merges the mmap'd base columns (when a V8
2769    /// snapshot is open) with the in-memory WAL overlay.  Reads consult the
2770    /// overlay first, then fall through to the archived base section zero-copy.
2771    fn props_view(&self) -> core_storage::v8::seam::ColumnsView<'_> {
2772        match self.base {
2773            None => core_storage::v8::seam::ColumnsView::owned(&self.props),
2774            Some(ref base) => {
2775                // columns() uses access_unchecked; field reads are bounds-checked in seam.rs.
2776                let archived = base
2777                    .columns()
2778                    .expect("base columns section bounds validated at open");
2779                core_storage::v8::seam::ColumnsView::with_base_cached(
2780                    &self.props,
2781                    archived,
2782                    base.mixed_cache(),
2783                )
2784                .with_shared_strings(base_string_table(base))
2785            }
2786        }
2787    }
2788
2789    /// Return an `EdgePropsView` that merges the mmap'd base edge-props section
2790    /// (when a V8 snapshot is open) with the in-memory WAL overlay.
2791    ///
2792    /// Reads consult the overlay first (for post-snapshot mutations), then fall
2793    /// through to the archived base section zero-copy.  Tombstones in the
2794    /// overlay mask deleted-from-base entries.
2795    fn edge_props_view(&self) -> EdgePropsView<'_> {
2796        match self.base {
2797            None => EdgePropsView::owned(&self.edge_props),
2798            Some(ref base) => {
2799                // edge_props_section() uses access_unchecked; field reads bounds-checked in seam.rs.
2800                let archived = base
2801                    .edge_props_section()
2802                    .expect("base edge_props section bounds validated at open");
2803                EdgePropsView::with_base(&self.edge_props, archived)
2804            }
2805        }
2806    }
2807
2808    fn open_at_with(fs: F, commit: u64) -> Result<Self> {
2809        // An as-of view never writes and is pinned to one commit: it takes no
2810        // cross-process lock and does not follow later commits.
2811        let mut db = Self::new_empty(
2812            fs,
2813            OpenOptions {
2814                repair_wal: false,
2815                auto_migrate: false,
2816                read_only: true,
2817            },
2818        );
2819        db.pinned = true; // read_only is set after replay, but pinning is immediate
2820        db.wal_horizon_floor = db.fs.read_horizon_floor()?;
2821        db.archive_genesis_chain = db.fs.has_genesis_marker();
2822        // Same orphaned-archive cleanup as open_with: floor was written first
2823        // during pruning, so a crash may have left stale archives below floor.
2824        db.cleanup_orphaned_archives()?;
2825        // Collect archive frames (oldest-first) and live WAL frames.
2826        // Archives represent pre-snapshot history; the snapshot captures the
2827        // cumulative state at the time of archiving.  Crash-window guarantee:
2828        //   A: crash before rename → WAL intact, no archive. Reopen: normal.
2829        //   B: crash after rename, before new WAL → archive present, WAL
2830        //      absent. Reopen: snapshot loaded (full state), no WAL replay.
2831        //   C: crash after new baseline WAL written → normal post-archive.
2832        let archive_ns = db.fs.list_archives()?;
2833        let mut archive_frames_all: Vec<WalRecord> = Vec::new();
2834        for n in &archive_ns {
2835            let arc_bytes = db.fs.read_archive(*n)?;
2836            let (arc_frames, _) = decode_all(&arc_bytes);
2837            archive_frames_all.extend(arc_frames);
2838        }
2839        let total_archive_frames = archive_frames_all.len() as u64;
2840
2841        let live_bytes = db.fs.read(FileId::Wal)?;
2842        let (live_records, _valid_len) = decode_all(&live_bytes);
2843        let total_surviving = total_archive_frames + live_records.len() as u64;
2844        // Global total including any pruned history below the horizon floor.
2845        let total = db.wal_horizon_floor + total_surviving;
2846
2847        // Horizon and range check.
2848        if commit < db.wal_horizon_floor {
2849            return Err(GraphError::CommitOutOfRange {
2850                commit,
2851                total,
2852                floor: db.wal_horizon_floor,
2853            });
2854        }
2855        if commit >= total {
2856            return Err(GraphError::CommitOutOfRange {
2857                commit,
2858                total,
2859                floor: db.wal_horizon_floor,
2860            });
2861        }
2862
2863        // Local index into surviving frames (0 = first frame of oldest archive).
2864        let local = commit - db.wal_horizon_floor;
2865
2866        if local < total_archive_frames {
2867            // Target commit is in an archive.  Correct replay from empty state
2868            // is only possible when the archive chain is an uninterrupted
2869            // genesis chain (first archive taken from a fresh store, no prior
2870            // WAL truncation) and no archives have been pruned (floor == 0).
2871            //
2872            // If either condition is violated the prefix needed to reconstruct
2873            // the requested state is gone; refuse rather than return wrong data.
2874            if db.wal_horizon_floor > 0 || !db.archive_genesis_chain {
2875                return Err(GraphError::CommitOutOfRange {
2876                    commit,
2877                    total,
2878                    floor: db.wal_horizon_floor,
2879                });
2880            }
2881            // Replay all archive frames up to and including the target commit
2882            // from an empty database state.  Archives must be replayed in order
2883            // so that dense-id intern tables are built up correctly.
2884            for rec in archive_frames_all.into_iter().take((local + 1) as usize) {
2885                db.apply(&rec)?;
2886                let _ = db.engine.drain_deltas();
2887            }
2888        } else {
2889            // Target commit is in the live WAL: load snapshot as base, then
2890            // replay the needed live WAL prefix.
2891            //
2892            // Base state: a truncating snapshot (wal_truncated=true) compacts
2893            // all pre-truncation / pre-archive commits.  Dense-id records in
2894            // the live WAL reference ids/interns that the snapshot provides.
2895            // Peek 6 bytes (same pattern as open_with).
2896            let snap_header = db.fs.read_prefix(FileId::Snapshot, 6)?;
2897            let is_v8 = snap_header.len() >= 6
2898                && &snap_header[0..4] == b"GDB1"
2899                && matches!(
2900                    u16::from_le_bytes([snap_header[4], snap_header[5]]),
2901                    core_storage::snapshot::VERSION_8 | core_storage::snapshot::VERSION_9
2902                );
2903            if is_v8 {
2904                let state = if let Some(snap_path) = db.fs.snapshot_path() {
2905                    let mapped = core_storage::v8::MappedBase::map(&snap_path).map_err(|e| {
2906                        GraphError::Corrupt {
2907                            detail: format!("v8: open_at mmap: {e:?}"),
2908                        }
2909                    })?;
2910                    core_storage::snapshot::decode_v8_from_mapped(&mapped)?
2911                } else {
2912                    let snap_bytes = db.fs.read(FileId::Snapshot)?;
2913                    core_storage::snapshot::decode(&snap_bytes)?
2914                };
2915                if let Some(state) = state {
2916                    if state.wal_truncated {
2917                        db.restore_snapshot_state(state)?;
2918                    }
2919                }
2920            } else if !snap_header.is_empty() {
2921                let snap_bytes = db.fs.read(FileId::Snapshot)?;
2922                if let Some(state) = core_storage::snapshot::decode(&snap_bytes)? {
2923                    if state.wal_truncated {
2924                        db.restore_snapshot_state(state)?;
2925                    }
2926                }
2927            }
2928            // else: snap_header empty = no snapshot file.
2929            let live_local = local - total_archive_frames;
2930            for rec in live_records.into_iter().take((live_local + 1) as usize) {
2931                db.apply(&rec)?;
2932                let _ = db.engine.drain_deltas();
2933            }
2934        }
2935        // Pin: pending_delta_count must be 0 after as-of replay, mirroring T1's
2936        // post-loop assert in open_with.
2937        debug_assert_eq!(
2938            db.engine.pending_delta_count(),
2939            0,
2940            "pending_deltas non-empty after open_at replay — \
2941             per-frame drain must run inside the loop to keep memory O(1)"
2942        );
2943        let _ = db.engine.drain_deltas(); // belt-and-braces no-op
2944                                          // Rebuild view values after WAL replay so derived-edge-driven views
2945                                          // reflect the as-of state.  open_at always uses the legacy path (no V8
2946                                          // base), so topo_view is always owned.
2947        {
2948            let topo_view = TopologyView::owned(&db.topo);
2949            db.view_store
2950                .rebuild_all(&mut db.props, &topo_view, &db.ids, &db.syms, &db.labels);
2951        }
2952        // Rebuild full-text index for as-of view (mirrors open_with pattern).
2953        db.fulltext.rebuild_all(
2954            &db.ids,
2955            &db.labels,
2956            &db.syms,
2957            build_props_view(&db.props, &db.base),
2958        );
2959        db.prop_index.rebuild_all(
2960            &db.ids,
2961            &db.labels,
2962            &db.syms,
2963            build_props_view(&db.props, &db.base),
2964        );
2965        // Namespaces on the temporal handle, built by the same pass the live
2966        // open uses, so an as-of mask narrows by the namespaces of that commit.
2967        db.rebuild_node_ns();
2968        // Load roles sidecar (current roles, not point-in-time).
2969        db.roles = Self::load_roles_from_fs(&db.fs)?;
2970        db.read_only = true;
2971        db.total_wal_commits = total;
2972        // Capture initial fold so reader() is immediately usable.
2973        db.fold_now();
2974        Ok(db)
2975    }
2976
2977    /// Whether this instance is a read-only as-of view.
2978    pub fn is_read_only(&self) -> bool {
2979        self.read_only
2980    }
2981
2982    // ── MVCC epoch reader ─────────────────────────────────────────────────────
2983
2984    /// Clone the current overlay state into a new `FrozenOverlay` and reset
2985    /// the delta tail. Called automatically every `FOLD_EVERY_K` commits and at
2986    /// the end of `open_with` / `open_at_with` to prime the reader.
2987    fn fold_now(&mut self) {
2988        let frozen = crate::reader::FrozenOverlay {
2989            ids: self.ids.clone(),
2990            syms: self.syms.clone(),
2991            topo: self.topo.clone(),
2992            props: self.props.clone(),
2993            labels: self.labels.clone(),
2994            edge_props: self.edge_props.clone(),
2995            roles: self.roles.clone(),
2996            fulltext: self.fulltext.clone(),
2997        };
2998        self.fold_overlay = Some(Arc::new(frozen));
2999        self.delta_tail.clear();
3000        self.commits_since_fold = 0;
3001    }
3002
3003    /// Capture a lock-free reader snapshot of the current db state.
3004    ///
3005    /// The read lock is held only for the duration of this call (to clone a
3006    /// handful of `Arc` handles). Subsequent query operations run without any
3007    /// lock.
3008    pub fn reader(&self) -> crate::reader::ReaderSnapshot {
3009        crate::reader::ReaderSnapshot::new(
3010            self.fold_overlay
3011                .clone()
3012                .expect("fold_overlay is always Some after open_with; call reader() after open"),
3013            self.base.clone(),
3014            self.delta_tail.clone(),
3015            // The snapshot's effective state is exactly this handle's state at
3016            // this commit, so it shares the memo and its version key.
3017            self.commit_seq,
3018            Arc::clone(&self.role_masks),
3019        )
3020    }
3021
3022    /// Total number of WAL commits at the time [`open_at`] was called.
3023    /// Returns 0 for normal (non-as-of) instances.
3024    pub fn total_wal_commits(&self) -> u64 {
3025        self.total_wal_commits
3026    }
3027
3028    /// Apply a record to in-memory state. Used by both live writes and replay,
3029    /// so replay is definitionally identical to the original execution.
3030    fn apply(&mut self, rec: &WalRecord) -> Result<()> {
3031        // Before the record mutates anything: a store restored from a snapshot
3032        // defers building its candidate indexes until the first write, and that
3033        // build is a full node scan. Left where it used to fire — inside the
3034        // engine hook, after `props.set` and the label assignment — the scan
3035        // read the half-applied record and took the in-flight node's vector for
3036        // one the snapshot should have carried, which read as an interrupted
3037        // vector-index build and cost a full `RebuildRule` on the first
3038        // embedded write after every reopen. Hoisted here the scan sees exactly
3039        // the persisted state; the record's own hook then files its vector
3040        // through the ordinary insert path a line later.
3041        self.populate_indexes_before_write();
3042        match rec {
3043            WalRecord::InsertNode { label, key, props } => {
3044                let id = self.ids.try_insert(key)?;
3045                let sym = self.syms.intern(label);
3046                if self.labels.len() <= id as usize {
3047                    // gap slots are sentinels, never valid label symbols
3048                    self.labels.resize(id as usize + 1, u32::MAX);
3049                }
3050                self.labels[id as usize] = sym;
3051                let mut ns_name = NS_DEFAULT.to_string();
3052                for (field, value) in props {
3053                    if field == NS_PROP {
3054                        ns_name = namespace_of_value(Some(value)).to_string();
3055                    }
3056                    self.props.set(id, field, value.clone());
3057                }
3058                self.set_node_ns(id, &ns_name);
3059                // Initialize view values for the new node before the engine runs so
3060                // delta-based increments start from a known zero baseline.
3061                self.view_store
3062                    .init_node_views(id, &mut self.props, &self.syms, &self.labels);
3063                // Fire rules for the newly inserted node.
3064                let cursor = self.engine.pending_delta_count();
3065                let mut eng = std::mem::take(&mut self.engine);
3066                {
3067                    let mut gm = make_graph_mut(
3068                        &self.ids,
3069                        &mut self.syms,
3070                        &self.labels,
3071                        build_props_view(&self.props, &self.base),
3072                        &mut self.topo,
3073                        &self.base,
3074                        &mut self.edge_props,
3075                    );
3076                    eng.on_node_changed(id, None, &mut gm);
3077                }
3078                self.engine = eng;
3079                // Process derived-edge deltas for view maintenance.
3080                // Fast path: skip the O(delta_count) allocation when no views exist.
3081                if !self.view_store.is_empty() {
3082                    #[cfg(test)]
3083                    DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
3084                    let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
3085                    for d in &new_deltas {
3086                        self.view_store.on_edge_changed(
3087                            d.etype_sym,
3088                            d.src_id,
3089                            d.dst_id,
3090                            d.fired,
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                    }
3099                }
3100                // Full-text index maintenance: index enabled fields for this label.
3101                if self.fulltext.has_label(label) {
3102                    for (field, value) in props {
3103                        if self.fulltext.is_enabled(label, field) {
3104                            self.fulltext.add_tokens(id, field, value);
3105                        }
3106                    }
3107                }
3108                // Property (equality) index maintenance.
3109                if self.prop_index.has_label(label) {
3110                    for (field, value) in props {
3111                        self.prop_index.set(label, field, id, value);
3112                    }
3113                }
3114            }
3115            WalRecord::InsertEdge {
3116                edge_type,
3117                src_key,
3118                dst_key,
3119            } => {
3120                let src = self.ids.get(src_key).ok_or_else(|| GraphError::Corrupt {
3121                    detail: format!("wal replay references unknown key {src_key}"),
3122                })?;
3123                let dst = self.ids.get(dst_key).ok_or_else(|| GraphError::Corrupt {
3124                    detail: format!("wal replay references unknown key {dst_key}"),
3125                })?;
3126                let etype = self.syms.intern(edge_type);
3127                // Skip if the edge is already visible in the merged base+overlay
3128                // view.  This keeps WAL replay idempotent when the WAL contains
3129                // pre-snapshot records that are already encoded in a V8 base
3130                // (keep_wal=true opens and crash-before-truncation scenarios).
3131                if self.base.is_some()
3132                    && self
3133                        .topo_view()
3134                        .neighbors(etype, Direction::Out, src)
3135                        .contains(&dst)
3136                {
3137                    return Ok(());
3138                }
3139                self.topo.add_edge(etype, src, dst);
3140                // View maintenance for manual edge insert.
3141                self.view_store.on_edge_changed(
3142                    etype,
3143                    src,
3144                    dst,
3145                    true,
3146                    &mut self.props,
3147                    &build_topo_view(&self.topo, &self.base),
3148                    &self.ids,
3149                    &self.syms,
3150                    &self.labels,
3151                    base_columns(&self.base),
3152                );
3153                // Rule engine: via-hop rules must update when user edges change.
3154                let cursor = self.engine.pending_delta_count();
3155                let mut eng = std::mem::take(&mut self.engine);
3156                {
3157                    let mut gm = make_graph_mut(
3158                        &self.ids,
3159                        &mut self.syms,
3160                        &self.labels,
3161                        build_props_view(&self.props, &self.base),
3162                        &mut self.topo,
3163                        &self.base,
3164                        &mut self.edge_props,
3165                    );
3166                    eng.on_edge_changed(edge_type, src, dst, &mut gm);
3167                }
3168                self.engine = eng;
3169                if !self.view_store.is_empty() {
3170                    let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
3171                    for d in &new_deltas {
3172                        self.view_store.on_edge_changed(
3173                            d.etype_sym,
3174                            d.src_id,
3175                            d.dst_id,
3176                            d.fired,
3177                            &mut self.props,
3178                            &build_topo_view(&self.topo, &self.base),
3179                            &self.ids,
3180                            &self.syms,
3181                            &self.labels,
3182                            base_columns(&self.base),
3183                        );
3184                    }
3185                }
3186            }
3187            WalRecord::SetProp { key, field, value } => {
3188                let id = self.ids.get(key).ok_or_else(|| GraphError::Corrupt {
3189                    detail: format!("wal replay references unknown key {key}"),
3190                })?;
3191                let old_value = build_props_view(&self.props, &self.base)
3192                    .get(id, field)
3193                    .map(|vr| vr.into_value());
3194                self.props.set(id, field, value.clone());
3195                // Fire rules for the changed field.
3196                let cursor = self.engine.pending_delta_count();
3197                let mut eng = std::mem::take(&mut self.engine);
3198                {
3199                    let mut gm = make_graph_mut(
3200                        &self.ids,
3201                        &mut self.syms,
3202                        &self.labels,
3203                        build_props_view(&self.props, &self.base),
3204                        &mut self.topo,
3205                        &self.base,
3206                        &mut self.edge_props,
3207                    );
3208                    eng.on_node_changed(id, Some((field, old_value)), &mut gm);
3209                }
3210                self.engine = eng;
3211                // Derived-edge deltas → view updates.
3212                if !self.view_store.is_empty() {
3213                    #[cfg(test)]
3214                    DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
3215                    let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
3216                    for d in &new_deltas {
3217                        self.view_store.on_edge_changed(
3218                            d.etype_sym,
3219                            d.src_id,
3220                            d.dst_id,
3221                            d.fired,
3222                            &mut self.props,
3223                            &build_topo_view(&self.topo, &self.base),
3224                            &self.ids,
3225                            &self.syms,
3226                            &self.labels,
3227                            base_columns(&self.base),
3228                        );
3229                    }
3230                }
3231                // Neighbor-aggregate views that read `field` must also update.
3232                self.view_store.on_prop_changed(
3233                    id,
3234                    field,
3235                    &mut self.props,
3236                    &build_topo_view(&self.topo, &self.base),
3237                    &self.ids,
3238                    &self.syms,
3239                    &self.labels,
3240                    base_columns(&self.base),
3241                );
3242                // Full-text index maintenance: update tokens for this field if indexed.
3243                if self.fulltext.field_indexed(field) {
3244                    let label_opt = self.labels.get(id as usize).and_then(|&sym| {
3245                        if sym == u32::MAX {
3246                            None
3247                        } else {
3248                            self.syms.resolve(sym)
3249                        }
3250                    });
3251                    if let Some(label) = label_opt {
3252                        if self.fulltext.is_enabled(label, field) {
3253                            self.fulltext.remove_node_field(id, field);
3254                            self.fulltext.add_tokens(id, field, value);
3255                        }
3256                    }
3257                }
3258                // Property (equality) index maintenance: re-key this node's value.
3259                if self.prop_index.field_indexed(field) {
3260                    let label_opt = self.labels.get(id as usize).and_then(|&sym| {
3261                        if sym == u32::MAX {
3262                            None
3263                        } else {
3264                            self.syms.resolve(sym)
3265                        }
3266                    });
3267                    if let Some(label) = label_opt {
3268                        self.prop_index.set(label, field, id, value);
3269                    }
3270                }
3271            }
3272            WalRecord::Intern { id, text } => {
3273                if let Some(existing) = self.syms.get(text) {
3274                    if existing != *id {
3275                        return Err(GraphError::Corrupt {
3276                            detail: format!(
3277                                "wal intern mismatch for {text:?}: have {existing}, record {id}"
3278                            ),
3279                        });
3280                    }
3281                } else {
3282                    let got = self.syms.intern(text);
3283                    if got != *id {
3284                        return Err(GraphError::Corrupt {
3285                            detail: format!(
3286                                "wal intern assigned {got} for {text:?}, record wanted {id}"
3287                            ),
3288                        });
3289                    }
3290                }
3291            }
3292            WalRecord::InsertNodeId { label, key, props } => {
3293                let id = self.ids.try_insert(key)?;
3294                if self.labels.len() <= id as usize {
3295                    self.labels.resize(id as usize + 1, u32::MAX);
3296                }
3297                self.labels[id as usize] = *label;
3298                let label_str = self
3299                    .syms
3300                    .resolve(*label)
3301                    .ok_or_else(|| GraphError::Corrupt {
3302                        detail: format!("wal InsertNodeId unknown label intern {label}"),
3303                    })?
3304                    .to_string();
3305                let mut ns_name = NS_DEFAULT.to_string();
3306                for (field_sym, value) in props {
3307                    let field =
3308                        self.syms
3309                            .resolve(*field_sym)
3310                            .ok_or_else(|| GraphError::Corrupt {
3311                                detail: format!(
3312                                    "wal InsertNodeId unknown field intern {field_sym}"
3313                                ),
3314                            })?;
3315                    if field == NS_PROP {
3316                        ns_name = namespace_of_value(Some(value)).to_string();
3317                    }
3318                    self.props.set(id, field, value.clone());
3319                }
3320                self.set_node_ns(id, &ns_name);
3321                self.view_store
3322                    .init_node_views(id, &mut self.props, &self.syms, &self.labels);
3323                let cursor = self.engine.pending_delta_count();
3324                let mut eng = std::mem::take(&mut self.engine);
3325                {
3326                    let mut gm = make_graph_mut(
3327                        &self.ids,
3328                        &mut self.syms,
3329                        &self.labels,
3330                        build_props_view(&self.props, &self.base),
3331                        &mut self.topo,
3332                        &self.base,
3333                        &mut self.edge_props,
3334                    );
3335                    eng.on_node_changed(id, None, &mut gm);
3336                }
3337                self.engine = eng;
3338                if !self.view_store.is_empty() {
3339                    #[cfg(test)]
3340                    DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
3341                    let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
3342                    for d in &new_deltas {
3343                        self.view_store.on_edge_changed(
3344                            d.etype_sym,
3345                            d.src_id,
3346                            d.dst_id,
3347                            d.fired,
3348                            &mut self.props,
3349                            &build_topo_view(&self.topo, &self.base),
3350                            &self.ids,
3351                            &self.syms,
3352                            &self.labels,
3353                            base_columns(&self.base),
3354                        );
3355                    }
3356                }
3357                if self.fulltext.has_label(&label_str) {
3358                    for (field_sym, value) in props {
3359                        let Some(field) = self.syms.resolve(*field_sym) else {
3360                            continue;
3361                        };
3362                        if self.fulltext.is_enabled(&label_str, field) {
3363                            self.fulltext.add_tokens(id, field, value);
3364                        }
3365                    }
3366                }
3367                if self.prop_index.has_label(&label_str) {
3368                    for (field_sym, value) in props {
3369                        let Some(field) = self.syms.resolve(*field_sym) else {
3370                            continue;
3371                        };
3372                        self.prop_index.set(&label_str, field, id, value);
3373                    }
3374                }
3375            }
3376            WalRecord::InsertEdgeId { etype, src, dst } => {
3377                // Replay-over-snapshot: dense ids in the pre-snapshot WAL may
3378                // already be tombstoned. Skip rather than attaching edges to
3379                // dead ids (DeleteNode keys the live re-insert, not the old id).
3380                if self.ids.is_tombstoned(*src)
3381                    || self.ids.is_tombstoned(*dst)
3382                    || self.ids.key_of(*src).is_none()
3383                    || self.ids.key_of(*dst).is_none()
3384                {
3385                    return Ok(());
3386                }
3387                // Skip if already visible in the merged view (same idempotency
3388                // guard as InsertEdge above: prevents double-counting when
3389                // pre-snapshot WAL records are replayed over a V8 base).
3390                if self.base.is_some()
3391                    && self
3392                        .topo_view()
3393                        .neighbors(*etype, Direction::Out, *src)
3394                        .contains(dst)
3395                {
3396                    return Ok(());
3397                }
3398                self.topo.add_edge(*etype, *src, *dst);
3399                self.view_store.on_edge_changed(
3400                    *etype,
3401                    *src,
3402                    *dst,
3403                    true,
3404                    &mut self.props,
3405                    &build_topo_view(&self.topo, &self.base),
3406                    &self.ids,
3407                    &self.syms,
3408                    &self.labels,
3409                    base_columns(&self.base),
3410                );
3411                // Rule engine: via-hop rules fire when user via-edges are inserted.
3412                // Resolve etype back to string so on_edge_changed can match rules by name.
3413                if let Some(etype_str) = self.syms.resolve(*etype).map(|s| s.to_string()) {
3414                    let cursor = self.engine.pending_delta_count();
3415                    let mut eng = std::mem::take(&mut self.engine);
3416                    {
3417                        let mut gm = make_graph_mut(
3418                            &self.ids,
3419                            &mut self.syms,
3420                            &self.labels,
3421                            build_props_view(&self.props, &self.base),
3422                            &mut self.topo,
3423                            &self.base,
3424                            &mut self.edge_props,
3425                        );
3426                        eng.on_edge_changed(&etype_str, *src, *dst, &mut gm);
3427                    }
3428                    self.engine = eng;
3429                    if !self.view_store.is_empty() {
3430                        let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
3431                        for d in &new_deltas {
3432                            self.view_store.on_edge_changed(
3433                                d.etype_sym,
3434                                d.src_id,
3435                                d.dst_id,
3436                                d.fired,
3437                                &mut self.props,
3438                                &build_topo_view(&self.topo, &self.base),
3439                                &self.ids,
3440                                &self.syms,
3441                                &self.labels,
3442                                base_columns(&self.base),
3443                            );
3444                        }
3445                    }
3446                }
3447            }
3448            WalRecord::SetPropId { id, field, value } => {
3449                if self.ids.is_tombstoned(*id) || self.ids.key_of(*id).is_none() {
3450                    return Ok(());
3451                }
3452                let field_str = self
3453                    .syms
3454                    .resolve(*field)
3455                    .ok_or_else(|| GraphError::Corrupt {
3456                        detail: format!("wal SetPropId unknown field intern {field}"),
3457                    })?
3458                    .to_string();
3459                let old_value = build_props_view(&self.props, &self.base)
3460                    .get(*id, &field_str)
3461                    .map(|vr| vr.into_value());
3462                self.props.set(*id, &field_str, value.clone());
3463                let cursor = self.engine.pending_delta_count();
3464                let mut eng = std::mem::take(&mut self.engine);
3465                {
3466                    let mut gm = make_graph_mut(
3467                        &self.ids,
3468                        &mut self.syms,
3469                        &self.labels,
3470                        build_props_view(&self.props, &self.base),
3471                        &mut self.topo,
3472                        &self.base,
3473                        &mut self.edge_props,
3474                    );
3475                    eng.on_node_changed(*id, Some((field_str.as_str(), old_value)), &mut gm);
3476                }
3477                self.engine = eng;
3478                if !self.view_store.is_empty() {
3479                    #[cfg(test)]
3480                    DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
3481                    let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
3482                    for d in &new_deltas {
3483                        self.view_store.on_edge_changed(
3484                            d.etype_sym,
3485                            d.src_id,
3486                            d.dst_id,
3487                            d.fired,
3488                            &mut self.props,
3489                            &build_topo_view(&self.topo, &self.base),
3490                            &self.ids,
3491                            &self.syms,
3492                            &self.labels,
3493                            base_columns(&self.base),
3494                        );
3495                    }
3496                }
3497                self.view_store.on_prop_changed(
3498                    *id,
3499                    &field_str,
3500                    &mut self.props,
3501                    &build_topo_view(&self.topo, &self.base),
3502                    &self.ids,
3503                    &self.syms,
3504                    &self.labels,
3505                    base_columns(&self.base),
3506                );
3507                if self.fulltext.field_indexed(&field_str) {
3508                    let label_opt = self.labels.get(*id as usize).and_then(|&sym| {
3509                        if sym == u32::MAX {
3510                            None
3511                        } else {
3512                            self.syms.resolve(sym)
3513                        }
3514                    });
3515                    if let Some(label) = label_opt {
3516                        if self.fulltext.is_enabled(label, &field_str) {
3517                            self.fulltext.remove_node_field(*id, &field_str);
3518                            self.fulltext.add_tokens(*id, &field_str, value);
3519                        }
3520                    }
3521                }
3522                if self.prop_index.field_indexed(&field_str) {
3523                    let label_opt = self.labels.get(*id as usize).and_then(|&sym| {
3524                        if sym == u32::MAX {
3525                            None
3526                        } else {
3527                            self.syms.resolve(sym)
3528                        }
3529                    });
3530                    if let Some(label) = label_opt {
3531                        self.prop_index.set(label, &field_str, *id, value);
3532                    }
3533                }
3534            }
3535            WalRecord::CreateRule { def_bytes } => {
3536                let def: RuleDef = decode_rule_def(def_bytes).map_err(|e| GraphError::Corrupt {
3537                    detail: format!("CreateRule def_bytes deserialize failed: {e}"),
3538                })?;
3539                // Replay-over-snapshot idempotency: the rule was captured in the snapshot
3540                // so the engine already has it; silently skip to avoid a spurious
3541                // RuleInvalid error in the crash window between snapshot write and WAL
3542                // truncation.
3543                if self.engine.rules().any(|r| r.name == def.name) {
3544                    return Ok(());
3545                }
3546                let cursor = self.engine.pending_delta_count();
3547                let mut eng = std::mem::take(&mut self.engine);
3548                let result = {
3549                    let mut gm = make_graph_mut(
3550                        &self.ids,
3551                        &mut self.syms,
3552                        &self.labels,
3553                        build_props_view(&self.props, &self.base),
3554                        &mut self.topo,
3555                        &self.base,
3556                        &mut self.edge_props,
3557                    );
3558                    eng.create_rule(def, &mut gm)
3559                };
3560                self.engine = eng;
3561                result.map_err(|e| GraphError::RuleInvalid { detail: e })?;
3562                // Derived-edge fires from backfill → view updates.
3563                // Fast path: skip O(edge_count) allocation when no views exist.
3564                if !self.view_store.is_empty() {
3565                    #[cfg(test)]
3566                    DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
3567                    let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
3568                    for d in &new_deltas {
3569                        self.view_store.on_edge_changed(
3570                            d.etype_sym,
3571                            d.src_id,
3572                            d.dst_id,
3573                            d.fired,
3574                            &mut self.props,
3575                            &build_topo_view(&self.topo, &self.base),
3576                            &self.ids,
3577                            &self.syms,
3578                            &self.labels,
3579                            base_columns(&self.base),
3580                        );
3581                    }
3582                }
3583            }
3584            WalRecord::DeleteRule { name } => {
3585                // Replay-over-snapshot idempotency: the snapshot already captured the
3586                // post-delete state so the rule is absent; silently skip to avoid a
3587                // spurious RuleNotFound error in the crash window between snapshot write
3588                // and WAL truncation.
3589                if !self.engine.rules().any(|r| r.name == *name) {
3590                    return Ok(());
3591                }
3592                let cursor = self.engine.pending_delta_count();
3593                let mut eng = std::mem::take(&mut self.engine);
3594                let result = {
3595                    let mut gm = make_graph_mut(
3596                        &self.ids,
3597                        &mut self.syms,
3598                        &self.labels,
3599                        build_props_view(&self.props, &self.base),
3600                        &mut self.topo,
3601                        &self.base,
3602                        &mut self.edge_props,
3603                    );
3604                    eng.delete_rule(name, &mut gm)
3605                };
3606                self.engine = eng;
3607                result.map_err(|_| GraphError::RuleNotFound { name: name.clone() })?;
3608                // Derived-edge retractions → view updates.
3609                if !self.view_store.is_empty() {
3610                    #[cfg(test)]
3611                    DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
3612                    let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
3613                    for d in &new_deltas {
3614                        self.view_store.on_edge_changed(
3615                            d.etype_sym,
3616                            d.src_id,
3617                            d.dst_id,
3618                            d.fired,
3619                            &mut self.props,
3620                            &build_topo_view(&self.topo, &self.base),
3621                            &self.ids,
3622                            &self.syms,
3623                            &self.labels,
3624                            base_columns(&self.base),
3625                        );
3626                    }
3627                }
3628            }
3629            WalRecord::RemoveProp { key, field } => {
3630                // Recovery-safe: unknown key or already-absent field is a
3631                // clean no-op. Crash-window replay over a snapshot that
3632                // already applied this record must not Err.
3633                let Some(id) = self.ids.get(key) else {
3634                    return Ok(());
3635                };
3636                // Read old value through the seam for rule retraction.
3637                let old = build_props_view(&self.props, &self.base)
3638                    .get(id, field)
3639                    .map(|vr| vr.into_value());
3640                self.props.remove(id, field);
3641                // If the base still supplies the value after the overlay removal,
3642                // record a tombstone so ColumnsView::get does not resurrect it.
3643                // This covers both the base-only case AND the both-resident case:
3644                //   base-only (in_overlay=false): old prop was only in base, remove
3645                //     is a no-op on overlay, base still visible → tombstone needed.
3646                //   both-resident (in_overlay=true): overlay had v2, base has v1;
3647                //     removing overlay uncovers v1 → tombstone needed.
3648                // Idempotent on double-replay: second pass sees the tombstone →
3649                // get() returns None → condition is false → no duplicate tombstone.
3650                if build_props_view(&self.props, &self.base)
3651                    .get(id, field)
3652                    .is_some()
3653                {
3654                    self.props.record_prop_tombstone(id, field);
3655                }
3656                let cursor = self.engine.pending_delta_count();
3657                let mut eng = std::mem::take(&mut self.engine);
3658                {
3659                    let mut gm = make_graph_mut(
3660                        &self.ids,
3661                        &mut self.syms,
3662                        &self.labels,
3663                        build_props_view(&self.props, &self.base),
3664                        &mut self.topo,
3665                        &self.base,
3666                        &mut self.edge_props,
3667                    );
3668                    eng.on_node_changed(id, Some((field, old)), &mut gm);
3669                }
3670                self.engine = eng;
3671                // Derived-edge deltas → view updates.
3672                if !self.view_store.is_empty() {
3673                    #[cfg(test)]
3674                    DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
3675                    let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
3676                    for d in &new_deltas {
3677                        self.view_store.on_edge_changed(
3678                            d.etype_sym,
3679                            d.src_id,
3680                            d.dst_id,
3681                            d.fired,
3682                            &mut self.props,
3683                            &build_topo_view(&self.topo, &self.base),
3684                            &self.ids,
3685                            &self.syms,
3686                            &self.labels,
3687                            base_columns(&self.base),
3688                        );
3689                    }
3690                }
3691                // Neighbor-aggregate views that read `field` must also update.
3692                self.view_store.on_prop_changed(
3693                    id,
3694                    field,
3695                    &mut self.props,
3696                    &build_topo_view(&self.topo, &self.base),
3697                    &self.ids,
3698                    &self.syms,
3699                    &self.labels,
3700                    base_columns(&self.base),
3701                );
3702                // Full-text index maintenance: remove tokens for this field.
3703                if self.fulltext.field_indexed(field) {
3704                    self.fulltext.remove_node_field(id, field);
3705                }
3706                // Property (equality) index maintenance: drop this node's entry.
3707                if self.prop_index.field_indexed(field) {
3708                    if let Some(label) = self.labels.get(id as usize).and_then(|&sym| {
3709                        (sym != u32::MAX).then(|| self.syms.resolve(sym)).flatten()
3710                    }) {
3711                        self.prop_index.remove_node(label, field, id);
3712                    }
3713                }
3714            }
3715            WalRecord::DeleteEdge {
3716                edge_type,
3717                src_key,
3718                dst_key,
3719            } => {
3720                // Recovery-safe: unknown keys, unknown etype, or already-
3721                // absent edge is a clean no-op (remove_edge returns false).
3722                let Some(src) = self.ids.get(src_key) else {
3723                    return Ok(());
3724                };
3725                let Some(dst) = self.ids.get(dst_key) else {
3726                    return Ok(());
3727                };
3728                let Some(etype) = self.syms.get(edge_type) else {
3729                    return Ok(());
3730                };
3731                // I3: phantom-tombstone guard.  When a V8 base is present, a
3732                // DeleteEdge WAL record for an edge that was already absorbed into
3733                // the new base (i.e. neither in overlay nor in base) must be skipped.
3734                // Without this guard, remove_edge records a tombstone for an edge
3735                // that no longer exists, incorrectly understating edge_count.
3736                if self.base.is_some()
3737                    && !self
3738                        .topo_view()
3739                        .neighbors(etype, core_storage::topology::Direction::Out, src)
3740                        .contains(&dst)
3741                {
3742                    return Ok(());
3743                }
3744                self.topo.remove_edge(etype, src, dst);
3745                self.edge_props.remove_edge(etype, src, dst);
3746                // View maintenance for manual edge delete (topo already updated above).
3747                self.view_store.on_edge_changed(
3748                    etype,
3749                    src,
3750                    dst,
3751                    false,
3752                    &mut self.props,
3753                    &build_topo_view(&self.topo, &self.base),
3754                    &self.ids,
3755                    &self.syms,
3756                    &self.labels,
3757                    base_columns(&self.base),
3758                );
3759                // Rule engine: via-hop rules must retract when user via-edges are deleted.
3760                let cursor = self.engine.pending_delta_count();
3761                let mut eng = std::mem::take(&mut self.engine);
3762                {
3763                    let mut gm = make_graph_mut(
3764                        &self.ids,
3765                        &mut self.syms,
3766                        &self.labels,
3767                        build_props_view(&self.props, &self.base),
3768                        &mut self.topo,
3769                        &self.base,
3770                        &mut self.edge_props,
3771                    );
3772                    eng.on_edge_changed(edge_type, src, dst, &mut gm);
3773                }
3774                self.engine = eng;
3775                if !self.view_store.is_empty() {
3776                    let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
3777                    for d in &new_deltas {
3778                        self.view_store.on_edge_changed(
3779                            d.etype_sym,
3780                            d.src_id,
3781                            d.dst_id,
3782                            d.fired,
3783                            &mut self.props,
3784                            &build_topo_view(&self.topo, &self.base),
3785                            &self.ids,
3786                            &self.syms,
3787                            &self.labels,
3788                            base_columns(&self.base),
3789                        );
3790                    }
3791                }
3792            }
3793            WalRecord::DeleteNode { key } => {
3794                // Recovery-safe: already-tombstoned / unknown key is a clean
3795                // no-op. Crash-window replay over a snapshot that already
3796                // applied this record cannot recover the retired id from the
3797                // key (`IdMap::get` is None), so every subsequent step is
3798                // skipped. Each step is independently idempotent if invoked
3799                // twice on a still-live id: retraction is a no-op on empty
3800                // provenance, `remove_edge` returns false, `remove_all` is a
3801                // no-op, `ids.delete` returns None, label sentinel is sticky.
3802                let Some(n) = self.ids.get(key) else {
3803                    return Ok(());
3804                };
3805
3806                // (1) Retract derived edges + de-index while props/labels live.
3807                let cursor = self.engine.pending_delta_count();
3808                let mut eng = std::mem::take(&mut self.engine);
3809                {
3810                    let mut gm = make_graph_mut(
3811                        &self.ids,
3812                        &mut self.syms,
3813                        &self.labels,
3814                        build_props_view(&self.props, &self.base),
3815                        &mut self.topo,
3816                        &self.base,
3817                        &mut self.edge_props,
3818                    );
3819                    eng.on_node_removed(n, &mut gm);
3820                }
3821                self.engine = eng;
3822                // Derived-edge retractions → view updates for neighbors.
3823                if !self.view_store.is_empty() {
3824                    #[cfg(test)]
3825                    DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
3826                    let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
3827                    for d in &new_deltas {
3828                        self.view_store.on_edge_changed(
3829                            d.etype_sym,
3830                            d.src_id,
3831                            d.dst_id,
3832                            d.fired,
3833                            &mut self.props,
3834                            &build_topo_view(&self.topo, &self.base),
3835                            &self.ids,
3836                            &self.syms,
3837                            &self.labels,
3838                            base_columns(&self.base),
3839                        );
3840                    }
3841                }
3842
3843                // (2) Sweep ALL remaining edges incident to n, both directions,
3844                // every etype. This cascade is intentionally mask-independent:
3845                // topology integrity requires removing every edge touching the
3846                // deleted node regardless of the caller's visibility scope.
3847                // (The mask limits which nodes a role's read phase can return;
3848                // the WAL delete always executes with full storage authority.)
3849                // Collect then remove so neighbor slices stay valid during
3850                // iteration. Remove from topo first, then call view maintenance
3851                // so Avg/Min/Max recompute sees the correct (reduced) neighbor set.
3852                let etypes: Vec<u32> = self.topo.etypes().collect();
3853                let mut doomed = Vec::new();
3854                for et in &etypes {
3855                    for &dst in self.topo.neighbors(*et, Direction::Out, n).as_ref() {
3856                        doomed.push((*et, n, dst));
3857                    }
3858                    for &src in self.topo.neighbors(*et, Direction::In, n).as_ref() {
3859                        doomed.push((*et, src, n));
3860                    }
3861                }
3862                for (et, s, d) in doomed {
3863                    self.topo.remove_edge(et, s, d);
3864                    self.edge_props.remove_edge(et, s, d);
3865                    // View maintenance: n's own view values will be cleared by
3866                    // remove_all below; only update surviving neighbors.
3867                    self.view_store.on_edge_changed(
3868                        et,
3869                        s,
3870                        d,
3871                        false,
3872                        &mut self.props,
3873                        &build_topo_view(&self.topo, &self.base),
3874                        &self.ids,
3875                        &self.syms,
3876                        &self.labels,
3877                        base_columns(&self.base),
3878                    );
3879                }
3880
3881                // (3) Drop every remaining prop (`ColumnStore::remove_all`).
3882                self.props.remove_all(n);
3883                // Full-text index maintenance: remove all tokens for this node.
3884                self.fulltext.remove_node(n);
3885                // Property (equality) index maintenance: drop all entries for n.
3886                self.prop_index.remove_node_all(n);
3887
3888                // (4) Retire the dense id and stamp the label sentinel.
3889                self.ids.delete(key);
3890                if let Some(slot) = self.labels.get_mut(n as usize) {
3891                    *slot = u32::MAX;
3892                }
3893            }
3894            WalRecord::Batch(inner) => {
3895                // Apply each inner record in order through the same apply path.
3896                // Inner records are validated free of nested Batch by encode_record.
3897                for rec in inner {
3898                    self.apply(rec)?;
3899                }
3900            }
3901            WalRecord::RebuildRule { name } => {
3902                // Replay-over-snapshot idempotency: the snapshot may already
3903                // reflect a later delete_rule, so the rule is absent; skip.
3904                if !self.engine.rules().any(|r| r.name == *name) {
3905                    return Ok(());
3906                }
3907                let cursor = self.engine.pending_delta_count();
3908                let mut eng = std::mem::take(&mut self.engine);
3909                let result = {
3910                    let mut gm = make_graph_mut(
3911                        &self.ids,
3912                        &mut self.syms,
3913                        &self.labels,
3914                        build_props_view(&self.props, &self.base),
3915                        &mut self.topo,
3916                        &self.base,
3917                        &mut self.edge_props,
3918                    );
3919                    eng.rebuild(name, &mut gm)
3920                };
3921                self.engine = eng;
3922                result.map_err(|_| GraphError::RuleNotFound { name: name.clone() })?;
3923                // Derived-edge delta changes → view updates.
3924                if !self.view_store.is_empty() {
3925                    #[cfg(test)]
3926                    DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
3927                    let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
3928                    for d in &new_deltas {
3929                        self.view_store.on_edge_changed(
3930                            d.etype_sym,
3931                            d.src_id,
3932                            d.dst_id,
3933                            d.fired,
3934                            &mut self.props,
3935                            &build_topo_view(&self.topo, &self.base),
3936                            &self.ids,
3937                            &self.syms,
3938                            &self.labels,
3939                            base_columns(&self.base),
3940                        );
3941                    }
3942                }
3943            }
3944            WalRecord::CreateView { def_bytes } => {
3945                let def: ViewDef =
3946                    bincode::deserialize(def_bytes).map_err(|e| GraphError::Corrupt {
3947                        detail: format!("CreateView def_bytes deserialize failed: {e}"),
3948                    })?;
3949                // Replay-over-snapshot idempotency: view already present → skip.
3950                if self.view_store.has_view(&def.name) {
3951                    return Ok(());
3952                }
3953                self.view_store
3954                    .create_view(
3955                        def,
3956                        &mut self.props,
3957                        &build_topo_view(&self.topo, &self.base),
3958                        &self.ids,
3959                        &self.syms,
3960                        &self.labels,
3961                    )
3962                    .map_err(|e| GraphError::RuleInvalid { detail: e })?;
3963            }
3964            WalRecord::DeleteView { name } => {
3965                // Replay-over-snapshot idempotency: view already absent → skip.
3966                if !self.view_store.has_view(name) {
3967                    return Ok(());
3968                }
3969                self.view_store
3970                    .delete_view(name, &mut self.props, &self.ids, &self.labels, &self.syms)
3971                    .map_err(|_| GraphError::RuleNotFound { name: name.clone() })?;
3972            }
3973            WalRecord::EnableFulltext { label, field } => {
3974                // Replay-over-snapshot idempotency: already enabled → skip.
3975                if self.fulltext.is_enabled(label, field) {
3976                    return Ok(());
3977                }
3978                self.fulltext.enable(label, field);
3979                // Backfill: index all live nodes of this label that have the field.
3980                let n = self.ids.len() as u32;
3981                for id in 0..n {
3982                    let Some(&sym) = self.labels.get(id as usize) else {
3983                        continue;
3984                    };
3985                    if sym == u32::MAX {
3986                        continue; // tombstoned
3987                    }
3988                    let Some(lbl) = self.syms.resolve(sym) else {
3989                        continue;
3990                    };
3991                    if lbl != label {
3992                        continue;
3993                    }
3994                    if let Some(value) = build_props_view(&self.props, &self.base)
3995                        .get(id, field)
3996                        .map(|vr| vr.into_value())
3997                    {
3998                        self.fulltext.add_tokens(id, field, &value);
3999                    }
4000                }
4001            }
4002            WalRecord::DisableFulltext { label, field } => {
4003                // Replay-over-snapshot idempotency: already disabled → skip.
4004                if !self.fulltext.is_enabled(label, field) {
4005                    return Ok(());
4006                }
4007                // If another label still indexes this field, the postings column
4008                // is kept — but it must not contain node_ids from the now-disabled
4009                // label.  Remove them before calling disable() so the field_indexed
4010                // guard inside disable() sees the correct post-removal state.
4011                if self.fulltext.field_indexed_by_other(label, field) {
4012                    if let Some(label_sym) = self.syms.get(label) {
4013                        for (node_id, &lsym) in self.labels.iter().enumerate() {
4014                            if lsym == label_sym {
4015                                self.fulltext.remove_node_field(node_id as u32, field);
4016                            }
4017                        }
4018                    }
4019                }
4020                self.fulltext.disable(label, field);
4021            }
4022            WalRecord::EnableIndex { label, field } => {
4023                // Replay-over-snapshot idempotency: already enabled → skip.
4024                if self.prop_index.is_enabled(label, field) {
4025                    return Ok(());
4026                }
4027                self.prop_index.enable(label, field);
4028                // Backfill: index all live nodes of this label that have the field.
4029                let n = self.ids.len() as u32;
4030                for id in 0..n {
4031                    let Some(&sym) = self.labels.get(id as usize) else {
4032                        continue;
4033                    };
4034                    if sym == u32::MAX {
4035                        continue; // tombstoned
4036                    }
4037                    let Some(lbl) = self.syms.resolve(sym) else {
4038                        continue;
4039                    };
4040                    if lbl != label {
4041                        continue;
4042                    }
4043                    if let Some(value) = build_props_view(&self.props, &self.base)
4044                        .get(id, field)
4045                        .map(|vr| vr.into_value())
4046                    {
4047                        self.prop_index.set(label, field, id, &value);
4048                    }
4049                }
4050            }
4051            WalRecord::DisableIndex { label, field } => {
4052                self.prop_index.disable(label, field);
4053            }
4054            // History markers carry no replay state — rules re-derive edges
4055            // deterministically on open/replay. Skip unconditionally.
4056            WalRecord::DerivedEdgeAdded { .. } | WalRecord::DerivedEdgeRetracted { .. } => {}
4057            // ── rename_node ──────────────────────────────────────────────────
4058            WalRecord::RenameNode { old_key, new_key } => {
4059                // Recovery-safe: if old_key is already gone (key was renamed
4060                // by a snapshot or a prior replay frame), skip cleanly.
4061                if self.ids.get(old_key).is_none() {
4062                    return Ok(());
4063                }
4064                // The rename only updates the key-table; the dense id, all
4065                // topo edges, props, labels, and rule state are id-indexed and
4066                // require no change.
4067                self.ids
4068                    .rename(old_key, new_key)
4069                    .map_err(|e| GraphError::Corrupt {
4070                        detail: format!("wal replay RenameNode {old_key}→{new_key}: {e}"),
4071                    })?;
4072            }
4073        }
4074        Ok(())
4075    }
4076
4077    /// Intern `s` in `syms` and emit a WAL `Intern` record so `*Id` records
4078    /// replay on WAL-only `open_at` (no snapshot intern table). Apply is
4079    /// idempotent when the string is already bound. Always emit: after
4080    /// `snapshot()` the WAL is truncated and live intern is not on disk.
4081    fn intern_wal(&mut self, s: &str) -> (u32, WalRecord) {
4082        let id = if let Some(id) = self.syms.get(s) {
4083            id
4084        } else {
4085            self.syms.intern(s)
4086        };
4087        (
4088            id,
4089            WalRecord::Intern {
4090                id,
4091                text: s.to_string(),
4092            },
4093        )
4094    }
4095
4096    /// Rewrite user-facing records into dense-id records. On `Err`, no live
4097    /// state is left mutated: speculative interns made while building the
4098    /// output are rolled back, so a later successful mutation cannot log an
4099    /// `Intern` record whose id replay would never reproduce.
4100    fn rewrite_wal_dense(&mut self, recs: Vec<WalRecord>) -> Result<Vec<WalRecord>> {
4101        let syms_checkpoint = self.syms.len();
4102        let result = self.rewrite_wal_dense_inner(recs);
4103        if result.is_err() {
4104            self.syms.truncate(syms_checkpoint);
4105        }
4106        result
4107    }
4108
4109    fn rewrite_wal_dense_inner(&mut self, recs: Vec<WalRecord>) -> Result<Vec<WalRecord>> {
4110        let mut out = Vec::with_capacity(recs.len());
4111        // Node ids allocated by later apply(InsertNodeId) in this same batch.
4112        let mut pending: std::collections::HashMap<String, u32> = std::collections::HashMap::new();
4113        // Namespace of each node inserted earlier in this same frame, so a SET
4114        // on a node this frame created is measured against the namespace it was
4115        // created in rather than against the store, where it does not exist yet.
4116        let mut pending_ns: std::collections::HashMap<String, String> =
4117            std::collections::HashMap::new();
4118        let mut interned = std::collections::HashSet::<u32>::new();
4119        let mut next = u32::try_from(self.ids.len()).map_err(|_| GraphError::Corrupt {
4120            detail: "id space exhausted".into(),
4121        })?;
4122        let lookup = |ids: &IdMap,
4123                      pending: &std::collections::HashMap<String, u32>,
4124                      key: &str|
4125         -> Option<u32> { ids.get(key).or_else(|| pending.get(key).copied()) };
4126        for rec in recs {
4127            match rec {
4128                WalRecord::InsertNode { label, key, props } => {
4129                    // Namespace validation and normalisation, on the one seam
4130                    // every user-visible node insert passes through: insert_node,
4131                    // a batch, ingest, Cypher CREATE and MERGE all arrive here
4132                    // before the WAL append, and replay never does.
4133                    let (props, ns_name) = Self::normalise_insert_ns(&key, props)?;
4134                    pending_ns.insert(key.clone(), ns_name);
4135                    let (label_id, intern) = self.intern_wal(&label);
4136                    if interned.insert(label_id) {
4137                        out.push(intern);
4138                    }
4139                    let mut props_id = Vec::with_capacity(props.len());
4140                    for (field, value) in props {
4141                        let (field_id, intern) = self.intern_wal(&field);
4142                        if interned.insert(field_id) {
4143                            out.push(intern);
4144                        }
4145                        props_id.push((field_id, value));
4146                    }
4147                    if lookup(&self.ids, &pending, &key).is_none() {
4148                        pending.insert(key.clone(), next);
4149                        next = next.checked_add(1).ok_or_else(|| GraphError::Corrupt {
4150                            detail: "id space exhausted".into(),
4151                        })?;
4152                    }
4153                    out.push(WalRecord::InsertNodeId {
4154                        label: label_id,
4155                        key,
4156                        props: props_id,
4157                    });
4158                }
4159                WalRecord::SetProp { key, field, value } => {
4160                    // A namespace is set at insert and fixed after: the write is
4161                    // refused when it would move the node, and dropped when it
4162                    // names the namespace the node is already in. Checked here
4163                    // so set_prop, a batch, Cypher SET/MERGE and every upsert
4164                    // that merges props get the same answer.
4165                    if field == NS_PROP {
4166                        let Value::Str(ref to) = value else {
4167                            return Err(GraphError::RuleInvalid {
4168                                detail: format!(
4169                                    "node {key}: {NS_PROP} must be a string naming a namespace, \
4170                                     got {value:?}"
4171                                ),
4172                            });
4173                        };
4174                        let from = pending_ns
4175                            .get(&key)
4176                            .cloned()
4177                            .or_else(|| self.namespace_of(&key))
4178                            .unwrap_or_else(|| NS_DEFAULT.to_string());
4179                        let to = to.clone();
4180                        if to != from {
4181                            return Err(GraphError::NamespaceImmutable {
4182                                key: key.clone(),
4183                                from,
4184                                to,
4185                            });
4186                        }
4187                        continue;
4188                    }
4189                    let id =
4190                        lookup(&self.ids, &pending, &key).ok_or_else(|| GraphError::Corrupt {
4191                            detail: format!("dense WAL rewrite missing key {key}"),
4192                        })?;
4193                    let (field_id, intern) = self.intern_wal(&field);
4194                    if interned.insert(field_id) {
4195                        out.push(intern);
4196                    }
4197                    out.push(WalRecord::SetPropId {
4198                        id,
4199                        field: field_id,
4200                        value,
4201                    });
4202                }
4203                WalRecord::InsertEdge {
4204                    edge_type,
4205                    src_key,
4206                    dst_key,
4207                } => {
4208                    let (etype, intern) = self.intern_wal(&edge_type);
4209                    if interned.insert(etype) {
4210                        out.push(intern);
4211                    }
4212                    let src = lookup(&self.ids, &pending, &src_key).ok_or_else(|| {
4213                        GraphError::Corrupt {
4214                            detail: format!("dense WAL rewrite missing src {src_key}"),
4215                        }
4216                    })?;
4217                    let dst = lookup(&self.ids, &pending, &dst_key).ok_or_else(|| {
4218                        GraphError::Corrupt {
4219                            detail: format!("dense WAL rewrite missing dst {dst_key}"),
4220                        }
4221                    })?;
4222                    out.push(WalRecord::InsertEdgeId { etype, src, dst });
4223                }
4224                WalRecord::RenameNode {
4225                    ref old_key,
4226                    ref new_key,
4227                } => {
4228                    // Track the rename in `pending` so subsequent InsertEdge /
4229                    // SetProp records in this batch can resolve the new key.
4230                    let id = lookup(&self.ids, &pending, old_key).ok_or_else(|| {
4231                        GraphError::Corrupt {
4232                            detail: format!(
4233                                "dense WAL rewrite: RenameNode old key {old_key} not found"
4234                            ),
4235                        }
4236                    })?;
4237                    pending.remove(old_key.as_str());
4238                    pending.insert(new_key.clone(), id);
4239                    out.push(rec);
4240                }
4241                // # Symbol-order invariant (load-bearing)
4242                //
4243                // Write-time and replay-time symbol assignment must agree: every
4244                // symbol in a `Batch` frame has to receive the same dense id when
4245                // the frame's records are replayed in order as it received when
4246                // the frame was written.
4247                //
4248                // A rule's backfill interns its `edge_type` lazily
4249                // (`core_rules::engine`, every `g.syms.intern(&def.edge_type)`
4250                // site), and that backfill runs from `apply` — during the
4251                // `CreateRule` record itself, and again from any later
4252                // `InsertNodeId` in the same frame that makes the rule fire. At
4253                // write time the whole batch is rewritten before any of it is
4254                // applied, so a later `InsertEdge` in the same batch would win the
4255                // lower id for its edge type; on replay the rule's lazy intern
4256                // gets there first and steals it, and the `Intern` record fails at
4257                // the `wal intern assigned …` check in `apply`.
4258                //
4259                // Pre-interning the rule's `edge_type` here, and emitting its
4260                // `Intern` record ahead of the `CreateRule` record, makes both
4261                // orders identical. `weight_prop` needs no pre-intern:
4262                // `EdgeProps::set` keys props by `String`, never through the
4263                // interner. `via_edge` needs none either: via-hop rules resolve it
4264                // with `syms.get` and skip when it is absent.
4265                //
4266                // `RebuildRule` and `DeleteRule` need no such handling here:
4267                // `RebuildRule` has no `BatchOp` variant, so it never appears
4268                // inside a `Batch` today — it is only ever issued as its own
4269                // standalone commit (`rebuild_rule`, or the auto-rebuild path
4270                // that logs it as a second commit after the triggering op).
4271                // `DeleteRule` does have a `BatchOp` variant and can appear
4272                // inside a `Batch`, but it carries only a rule `name` — no
4273                // `edge_type` or other symbol that needs pre-interning — so
4274                // only `CreateRule` needs this arm.
4275                WalRecord::CreateRule { ref def_bytes } => {
4276                    let def = decode_rule_def(def_bytes).map_err(|e| GraphError::Corrupt {
4277                        detail: format!("CreateRule def_bytes deserialize failed: {e}"),
4278                    })?;
4279                    let (etype, intern) = self.intern_wal(&def.edge_type);
4280                    if interned.insert(etype) {
4281                        out.push(intern);
4282                    }
4283                    out.push(rec);
4284                }
4285                other => out.push(other),
4286            }
4287        }
4288        Ok(out)
4289    }
4290
4291    fn log_dense(&mut self, recs: Vec<WalRecord>) -> Result<()> {
4292        let recs = self.rewrite_wal_dense(recs)?;
4293        match recs.len() {
4294            0 => Ok(()),
4295            1 => self.log_then_apply(recs.into_iter().next().unwrap()),
4296            _ => self.log_then_apply(WalRecord::Batch(recs)),
4297        }
4298    }
4299
4300    /// Durable write, then notify the event sink. Replay (`apply` during
4301    /// `open`) never enters this function, so it is the replay-silent seam.
4302    fn log_then_apply(&mut self, rec: WalRecord) -> Result<()> {
4303        self.log_then_apply_with(rec, None, self.fsync)
4304    }
4305
4306    /// Whether this frame must fsync under `policy`.
4307    ///
4308    /// Batched contract: user-visible batches (>1 mutation) fsync; single
4309    /// mutations do not. The dense rewrite wraps a single mutation in a
4310    /// `Batch([Intern.., <one *Id record>])`, so `Intern` records are excluded
4311    /// from the count — removing that filter would make every single-op write
4312    /// fsync under Batched (or, if the threshold were raised instead, skip a
4313    /// needed fsync for real two-op batches).
4314    fn wal_needs_sync(policy: FsyncPolicy, rec: &WalRecord) -> bool {
4315        match policy {
4316            FsyncPolicy::Relaxed => false,
4317            FsyncPolicy::Strict => true,
4318            FsyncPolicy::Batched => match rec {
4319                // Intern + one mutation is the single-op rewrite, not a user batch.
4320                WalRecord::Batch(inner) => {
4321                    inner
4322                        .iter()
4323                        .filter(|r| !matches!(r, WalRecord::Intern { .. }))
4324                        .count()
4325                        > 1
4326                }
4327                _ => false,
4328            },
4329        }
4330    }
4331
4332    /// # Apply-infallibility invariant (load-bearing)
4333    ///
4334    /// The ordering is: WAL append → fsync → apply. If `apply` returned `Err`
4335    /// for a `Batch` frame after a successful WAL write, the WAL would contain
4336    /// the full frame while in-memory state would reflect only the ops before
4337    /// the failure. On reopen, WAL replay would then apply the entire batch —
4338    /// diverging permanently from what the pre-crash process had in memory.
4339    ///
4340    /// For `Batch` frames this situation cannot arise because:
4341    /// - All validation runs via `commit_logged_batch`/`MutPreview` **before**
4342    ///   the WAL write. `MutPreview` uses the same `&mut self` that apply will
4343    ///   use, with no concurrent mutation between validation exit and apply entry.
4344    /// - Every `apply` arm for a validated op is either infallible by construction
4345    ///   (`InsertNode`, `RemoveProp`, `DeleteEdge`, `DeleteNode`), has idempotency
4346    ///   guards that return `Ok(())` (`CreateRule`, `DeleteRule`), or is
4347    ///   guaranteed-present by validation (`InsertEdge`/`SetProp` key lookups).
4348    /// - `on_node_changed` and `on_node_removed` return `()` — never `Err`.
4349    ///
4350    /// A `debug_assert!` below fires in debug builds if `apply` ever returns
4351    /// `Err` for a `Batch` frame, making any future regression immediately visible
4352    /// in tests rather than silently diverging crash-recovery behaviour.
4353    fn log_then_apply_with(
4354        &mut self,
4355        rec: WalRecord,
4356        ingest: Option<(String, usize)>,
4357        policy: FsyncPolicy,
4358    ) -> Result<()> {
4359        // Read-only guard: as-of instances must never write the WAL.
4360        if self.read_only {
4361            return Err(GraphError::ReadOnly);
4362        }
4363        // Degraded guard: fsync failure left WAL truncated, or a refresh failed
4364        // partway; in-memory state is ahead of (or out of step with) the
4365        // on-disk WAL, so further mutations would deepen the divergence.
4366        // Reopen the database to recover.  Checked before the lock guard: this
4367        // is the more serious condition and the more useful error.
4368        if self.degraded {
4369            return Err(GraphError::Io(std::io::Error::other(
4370                "database degraded after group-commit fsync failure; reopen required",
4371            )));
4372        }
4373        // Cross-process guard: this write scope asked for the store's write
4374        // lock and did not get it. Writing anyway would append frames on top of
4375        // a WAL another process is extending, so refuse instead.
4376        if self.lock_denied {
4377            return Err(GraphError::Busy { holder: None });
4378        }
4379        // Ensure retained provenance bytes are decoded into the live mutable
4380        // fields before any mutation touches self.engine.provenance.  This is a
4381        // no-op if provenance was never stored (fresh store) or has already been
4382        // consumed (subsequent mutations).  WAL replay calls apply() directly
4383        // and is covered by consume_retained_state_eager before replay.
4384        self.ensure_v8_base_sections_loaded();
4385        self.engine.ensure_provenance_loaded_mut();
4386        // Invariant (I-1): no stale deltas may enter from a previous apply.
4387        // If any engine method ever accumulates deltas before erroring, they would
4388        // contaminate the *next* commit's event stream. This assert fires in debug
4389        // builds, making any future regression visible at the earliest point.
4390        debug_assert_eq!(
4391            self.engine.pending_delta_count(),
4392            0,
4393            "stale engine deltas at log_then_apply_with entry — \
4394             a previous apply arm may have accumulated deltas before erroring; \
4395             the caller must drain_deltas() on any error path before returning"
4396        );
4397        let frame = encode_record(&rec);
4398        self.fs.append(FileId::Wal, &frame)?;
4399        // The cursor advances by exactly the bytes appended: these frames are
4400        // ours and already applied, so a later refresh must not replay them.
4401        self.wal_consumed += frame.len() as u64;
4402        if Self::wal_needs_sync(policy, &rec) {
4403            self.fs.sync(FileId::Wal)?;
4404        }
4405        // Marker writing always needs the engine deltas, but the engine only
4406        // accumulates them when emit_deltas is true (normally gated on subscribers
4407        // or views being present).  Enable emission for this apply if it is
4408        // currently off, then restore the original state unconditionally via an
4409        // RAII guard — this prevents a panic in apply() from leaking the flag.
4410        // The same guard resets the engine's transient chaining state. A panic
4411        // unwinding out of a rule hook would otherwise leave `chain_depth`
4412        // non-zero, which makes every later `begin_chain` decide chaining is
4413        // already running and silently switch it off for good.
4414        struct RestoreEmitDeltas(*mut RuleEngine, bool);
4415        impl Drop for RestoreEmitDeltas {
4416            fn drop(&mut self) {
4417                // SAFETY: pointer into self (GraphDb); guard is dropped within
4418                // this frame before log_then_apply_with returns.
4419                unsafe {
4420                    (*self.0).set_emit_deltas(self.1);
4421                    (*self.0).reset_chain_state();
4422                }
4423            }
4424        }
4425        let original_emit = self.engine.emit_deltas();
4426        if !original_emit {
4427            self.engine.set_emit_deltas(true);
4428        }
4429        // SAFETY: raw pointer into self; guard dropped within this frame.
4430        let _emit_guard = RestoreEmitDeltas(&mut self.engine as *mut _, original_emit);
4431
4432        let apply_result = self.apply(&rec);
4433        // For Batch frames, post-validation apply must be infallible (see above).
4434        // A debug_assert here catches any future change that makes apply fallible
4435        // before the caller notices via silent WAL/memory divergence.
4436        if matches!(&rec, WalRecord::Batch(_)) {
4437            debug_assert!(
4438                apply_result.is_ok(),
4439                "Batch apply returned Err after successful WAL write — \
4440                 the validate-then-apply invariant has been violated; \
4441                 see log_then_apply_with invariant doc"
4442            );
4443        }
4444        if apply_result.is_err() {
4445            // Discard any partial deltas accumulated by the failed apply.
4446            // They must not ride the next commit's event stream (I-1).
4447            // _emit_guard restores emit_deltas on drop automatically.
4448            let _ = self.engine.drain_deltas();
4449            let _ = self.engine.take_rebuild_needed();
4450            apply_result?;
4451        }
4452        self.commit_seq += 1;
4453        let seq = self.commit_seq;
4454        // Update per-node last-change map for the committed record.
4455        // Must happen after commit_seq is incremented so the seq is correct.
4456        self.update_last_change_from_rec(&rec, seq);
4457        // Drain engine deltas and distribute to subscribers before the existing
4458        // MutationEvent sink fires — both happen post-fsync, post-apply.
4459        // _emit_guard restores emit_deltas after this line when it drops.
4460        let engine_deltas = self.engine.drain_deltas();
4461
4462        // Append history-marker WAL records for any derived-edge changes so
4463        // that `edge_history` and `was_linked` can surface rule-attributed
4464        // events. Markers are STATE NO-OPS during replay; they are written
4465        // without an additional fsync (the triggering commit's sync already
4466        // happened; the next commit's sync covers these lazily).
4467        if !engine_deltas.is_empty() {
4468            let markers: Vec<WalRecord> = engine_deltas
4469                .iter()
4470                .map(|d| {
4471                    if d.fired {
4472                        WalRecord::DerivedEdgeAdded {
4473                            rule: d.rule.clone(),
4474                            edge_type: d.edge_type.clone(),
4475                            src_key: d.src_key.clone(),
4476                            dst_key: d.dst_key.clone(),
4477                        }
4478                    } else {
4479                        WalRecord::DerivedEdgeRetracted {
4480                            rule: d.rule.clone(),
4481                            edge_type: d.edge_type.clone(),
4482                            src_key: d.src_key.clone(),
4483                            dst_key: d.dst_key.clone(),
4484                        }
4485                    }
4486                })
4487                .collect();
4488            let marker_frame = if markers.len() == 1 {
4489                markers.into_iter().next().unwrap()
4490            } else {
4491                WalRecord::Batch(markers)
4492            };
4493            // Ignore append errors: markers are best-effort history
4494            // annotations. Losing them does not affect state correctness.
4495            // The cursor only advances when the bytes actually landed.
4496            let marker_bytes = encode_record(&marker_frame);
4497            if self.fs.append(FileId::Wal, &marker_bytes).is_ok() {
4498                self.wal_consumed += marker_bytes.len() as u64;
4499            }
4500        }
4501
4502        // Record MVCC CommitDelta for the epoch reader.  The WAL record is
4503        // stored as-is (including any nested Batch / Intern records); the
4504        // ReaderSnapshot's apply_one function handles all variants.
4505        {
4506            let derived_inserts = engine_deltas
4507                .iter()
4508                .filter(|d| d.fired)
4509                .map(|d| (d.etype_sym, d.src_id, d.dst_id))
4510                .collect();
4511            let derived_deletes = engine_deltas
4512                .iter()
4513                .filter(|d| !d.fired)
4514                .map(|d| (d.etype_sym, d.src_id, d.dst_id))
4515                .collect();
4516            let delta = Arc::new(crate::reader::CommitDelta {
4517                records: vec![rec.clone()],
4518                derived_inserts,
4519                derived_deletes,
4520            });
4521            self.delta_tail.push(delta);
4522            self.commits_since_fold += 1;
4523            if self.commits_since_fold >= crate::reader::FOLD_EVERY_K {
4524                self.fold_now();
4525            }
4526        }
4527
4528        if self.defer_events {
4529            // Group-commit drain thread: hold events until after the group
4530            // fsync so subscribers only observe durable data (R2).
4531            self.deferred_events.push(DeferredEvent {
4532                rec: rec.clone(),
4533                engine_deltas,
4534                seq,
4535                ingest,
4536            });
4537        } else {
4538            self.distribute_events(&rec, &engine_deltas, seq);
4539            self.emit_committed(&rec, ingest);
4540        }
4541        // Drift is only known after apply, so auto-rebuild cannot join the
4542        // triggering op's WAL frame. Issue RebuildRule as a second commit.
4543        // Skip when `rec` is itself RebuildRule: rebuild resets drift, so a
4544        // retrigger loop is impossible if the fit succeeded, but we still
4545        // drain the flag so a leftover cannot re-enter.
4546        // One slice of any outstanding vector-index build rides here too, so a
4547        // store that is being written to finishes its build without anyone
4548        // calling `pump_index_build`. A rule that becomes whole joins the same
4549        // RebuildRule loop below.
4550        let mut rebuilds = self.engine.take_rebuild_needed();
4551        if !matches!(&rec, WalRecord::RebuildRule { .. }) {
4552            // Not after `CreateRule`: that record's own apply already did the
4553            // rule's first slice, and pumping again here would make one
4554            // `create_rule` call do two slices' work under one lock.
4555            // Nothing pending is the overwhelmingly common case and must cost
4556            // a map lookup, not an engine swap: a store being written to has
4557            // long since populated its indexes, so the `pump_index_build`
4558            // entry point owns the not-yet-populated case on its own.
4559            if !matches!(&rec, WalRecord::CreateRule { .. })
4560                && !self.engine.builds_in_progress().is_empty()
4561            {
4562                rebuilds.extend(self.pump_one_slice().into_iter().map(|b| b.rule));
4563            }
4564            let mut failed = Vec::new();
4565            for name in rebuilds {
4566                if self.engine.rules().any(|r| r.name == name) {
4567                    // User op is already durable. A failed second commit must
4568                    // not surface as the caller's error.
4569                    if let Err(e) =
4570                        self.log_then_apply(WalRecord::RebuildRule { name: name.clone() })
4571                    {
4572                        eprintln!(
4573                            "auto-rebuild of rule {name:?} failed after durable user commit: {e}"
4574                        );
4575                        failed.push(name);
4576                    }
4577                }
4578            }
4579            for name in failed {
4580                self.engine.queue_rebuild_needed(name);
4581            }
4582        }
4583        Ok(())
4584    }
4585
4586    /// Install a post-commit hook. Replaces any previous sink.
4587    ///
4588    /// The sink runs inside `log_then_apply` after a successful
4589    /// durable commit, while the caller still holds `&mut self`. When this
4590    /// database is behind a [`crate::SharedDb`], that means the **write
4591    /// guard is held**. The sink must never call `read` / `write` (or any
4592    /// other method) on the same `SharedDb` — the `RwLock` is not
4593    /// re-entrant and doing so deadlocks. The sink is `Send + Sync`;
4594    /// `std::sync::mpsc::Sender` is not `Sync` and will not type-check.
4595    /// Intended examples: `std::sync::mpsc::SyncSender`,
4596    /// `tokio::sync::mpsc::Sender`, `tokio::sync::broadcast::Sender`
4597    /// (non-blocking `send`), or `Arc<Mutex<Vec<MutationEvent>>>`.
4598    pub fn set_event_sink(&mut self, sink: Box<dyn Fn(MutationEvent) + Send + Sync>) {
4599        self.event_sink = Some(sink);
4600    }
4601
4602    /// Whether a post-commit event sink is currently installed.
4603    pub fn has_event_sink(&self) -> bool {
4604        self.event_sink.is_some()
4605    }
4606
4607    /// Set WAL fsync cadence. Default [`FsyncPolicy::Strict`].
4608    pub fn set_fsync_policy(&mut self, p: FsyncPolicy) {
4609        self.fsync = p;
4610    }
4611
4612    /// Return the current WAL fsync cadence.
4613    pub fn fsync_policy(&self) -> FsyncPolicy {
4614        self.fsync
4615    }
4616
4617    // ── Group-commit event deferral ───────────────────────────────────────────
4618
4619    /// Enable or disable deferred event mode.
4620    ///
4621    /// When `true`, event notifications (subscription `DbEvent`s and legacy
4622    /// `MutationEvent` sink calls) are buffered rather than fired immediately.
4623    /// Call [`flush_deferred_events`] after the group fsync to deliver them,
4624    /// or [`discard_deferred_events`] if the fsync failed and the group must
4625    /// be treated as lost.
4626    pub fn set_deferred_events_mode(&mut self, defer: bool) {
4627        self.defer_events = defer;
4628    }
4629
4630    /// Fire all buffered events accumulated since [`set_deferred_events_mode`]
4631    /// was set to true.  Clears the buffer.
4632    ///
4633    /// Called by the drain thread AFTER a successful group fsync, so
4634    /// subscribers observe only data that is durably on disk.
4635    pub fn flush_deferred_events(&mut self) {
4636        let events = std::mem::take(&mut self.deferred_events);
4637        for de in events {
4638            self.distribute_events(&de.rec, &de.engine_deltas, de.seq);
4639            self.emit_committed(&de.rec, de.ingest);
4640        }
4641    }
4642
4643    /// Discard all buffered events without firing them.
4644    ///
4645    /// Called by the drain thread when a group fsync fails: the WAL has been
4646    /// truncated back to the pre-group offset, so the committed-but-unsynced
4647    /// ops must not be observable to subscribers.
4648    pub fn discard_deferred_events(&mut self) {
4649        self.deferred_events.clear();
4650    }
4651
4652    // ── Degraded state ────────────────────────────────────────────────────────
4653
4654    /// Mark this database as degraded.
4655    ///
4656    /// Called by the group-commit drain thread after a group fsync failure and
4657    /// WAL truncation: the in-memory state is now ahead of the on-disk WAL, so
4658    /// further mutations would deepen the divergence.  All subsequent calls to
4659    /// [`log_then_apply_with`] return `Err` until the database is reopened.
4660    pub fn set_degraded(&mut self) {
4661        self.degraded = true;
4662    }
4663
4664    fn emit(&self, ev: MutationEvent) {
4665        if let Some(sink) = &self.event_sink {
4666            sink(ev);
4667        }
4668    }
4669
4670    fn emit_committed(&self, rec: &WalRecord, ingest: Option<(String, usize)>) {
4671        match rec {
4672            WalRecord::Batch(inner) => {
4673                for r in inner {
4674                    if let Some(ev) = event_from_record(r, &self.syms, &self.ids) {
4675                        self.emit(ev);
4676                    }
4677                }
4678                match ingest {
4679                    Some((label, inserted)) => {
4680                        self.emit(MutationEvent::Ingested { label, inserted })
4681                    }
4682                    None => {
4683                        let ops = inner
4684                            .iter()
4685                            .filter(|r| !matches!(r, WalRecord::Intern { .. }))
4686                            .count();
4687                        if ops > 1 {
4688                            self.emit(MutationEvent::BatchApplied { ops });
4689                        }
4690                    }
4691                }
4692            }
4693            other => {
4694                if let Some(ev) = event_from_record(other, &self.syms, &self.ids) {
4695                    self.emit(ev);
4696                }
4697            }
4698        }
4699    }
4700
4701    // -----------------------------------------------------------------------
4702    // Subscription API
4703    // -----------------------------------------------------------------------
4704
4705    /// Distribute post-commit events to all live subscribers.
4706    ///
4707    /// Build a row-key → row-data map from a [`ResultSet`].
4708    ///
4709    /// Each row is serialized to JSON to form its key; a debug fallback is used
4710    /// if serialization fails. Used by both the initial-seed path in
4711    /// [`Self::subscribe_query`] and the per-commit diff path in
4712    /// [`Self::distribute_events`] to keep the two in sync.
4713    fn result_to_row_map(
4714        result: &core_query::ResultSet,
4715    ) -> std::collections::HashMap<String, Vec<Option<Value>>> {
4716        (0..result.len())
4717            .map(|i| {
4718                let row = result.row(i).to_vec();
4719                let key = serde_json::to_string(&row).unwrap_or_else(|_| format!("{row:?}"));
4720                (key, row)
4721            })
4722            .collect()
4723    }
4724
4725    /// Collect the set of label syms touched by a WAL record.
4726    ///
4727    /// Returns `Some(set)` when every record in this commit can be attributed to
4728    /// a known label sym. Returns `None` when the commit must not be skipped:
4729    /// edge records, unresolvable key→label lookups, or any record type not in
4730    /// the explicit handled set.
4731    ///
4732    /// Handled record types and their actions:
4733    /// - `InsertNode`   → look up label in interner (fails → None)
4734    /// - `InsertNodeId` → label sym is carried directly
4735    /// - `SetProp`      → resolve key→id→label (fails → None)
4736    /// - `DeleteNode`   → resolve key→id→label (fails → None)
4737    /// - `Batch`        → recurse into every inner record
4738    /// - `InsertEdge`, `DeleteEdge`, `InsertEdgeId` → always None (edge records)
4739    /// - everything else → None (conservative)
4740    fn commit_touched_labels(
4741        rec: &WalRecord,
4742        syms: &Interner,
4743        ids: &IdMap,
4744        labels: &[u32],
4745    ) -> Option<BTreeSet<u32>> {
4746        let mut out = BTreeSet::new();
4747        if Self::collect_touched_labels(rec, syms, ids, labels, &mut out) {
4748            Some(out)
4749        } else {
4750            None
4751        }
4752    }
4753
4754    fn collect_touched_labels(
4755        rec: &WalRecord,
4756        syms: &Interner,
4757        ids: &IdMap,
4758        labels: &[u32],
4759        out: &mut BTreeSet<u32>,
4760    ) -> bool {
4761        match rec {
4762            // String-key insert: the dense rewrite converts this to
4763            // [Intern, InsertNodeId], so this arm fires only for legacy WAL
4764            // records written before the dense path was added.
4765            WalRecord::InsertNode { label, .. } => {
4766                if let Some(sym) = syms.get(label) {
4767                    out.insert(sym);
4768                    true
4769                } else {
4770                    false
4771                }
4772            }
4773            // Dense-id insert (produced by rewrite_wal_dense for every
4774            // insert_node call in the current codebase).
4775            WalRecord::InsertNodeId { label, .. } => {
4776                out.insert(*label);
4777                true
4778            }
4779            // String-key prop set: dense path converts to [Intern, SetPropId].
4780            WalRecord::SetProp { key, .. } => {
4781                if let Some(sym) = Self::resolve_key_label_sym(key, ids, labels) {
4782                    out.insert(sym);
4783                    true
4784                } else {
4785                    false
4786                }
4787            }
4788            // Dense-id prop set (produced by rewrite_wal_dense for set_prop).
4789            WalRecord::SetPropId { id, .. } => {
4790                if let Some(sym) = labels.get(*id as usize).copied().filter(|&s| s != u32::MAX) {
4791                    out.insert(sym);
4792                    true
4793                } else {
4794                    false
4795                }
4796            }
4797            WalRecord::DeleteNode { key } => {
4798                if let Some(sym) = Self::resolve_key_label_sym(key, ids, labels) {
4799                    out.insert(sym);
4800                    true
4801                } else {
4802                    false
4803                }
4804            }
4805            WalRecord::Batch(inner) => inner
4806                .iter()
4807                .all(|r| Self::collect_touched_labels(r, syms, ids, labels, out)),
4808            // Intern is a pure metadata record — it does not touch any node's
4809            // label and is safe to skip for the label-skip predicate.
4810            WalRecord::Intern { .. } => true,
4811            // Edge records: always re-execute (edges can change join results).
4812            WalRecord::InsertEdge { .. }
4813            | WalRecord::DeleteEdge { .. }
4814            | WalRecord::InsertEdgeId { .. } => false,
4815            _ => false,
4816        }
4817    }
4818
4819    /// Resolve a node key to its label sym via the dense id table.
4820    /// Returns `None` if the key is unknown or the label is a tombstone sentinel.
4821    fn resolve_key_label_sym(key: &str, ids: &IdMap, labels: &[u32]) -> Option<u32> {
4822        let id = ids.get(key)?;
4823        let sym = labels.get(id as usize).copied()?;
4824        (sym != u32::MAX).then_some(sym)
4825    }
4826
4827    /// Distribute post-commit events to all live subscribers.
4828    ///
4829    /// Called from `log_then_apply_with` after apply + fsync, before the
4830    /// legacy MutationEvent sink. Prunes dead `Weak` entries in-place.
4831    ///
4832    /// Query subscriptions (subscribe_query) re-execute their plan on every
4833    /// call and diff the result against the previous run. Zero overhead when
4834    /// no query subscriptions are active.
4835    fn distribute_events(&mut self, rec: &WalRecord, engine_deltas: &[EngineEdgeDelta], seq: u64) {
4836        if self.subscriptions.is_empty() && self.query_subscriptions.is_empty() {
4837            return;
4838        }
4839
4840        if !self.subscriptions.is_empty() {
4841            // Build write events from the WAL record.
4842            let write_events: Vec<DbEvent> =
4843                Self::write_events_from_record(rec, seq, &self.syms, &self.ids);
4844
4845            // Build edge events from engine deltas.  Weight is looked up from
4846            // edge_props at distribution time (after apply), so it's always fresh.
4847            let edge_events: Vec<DbEvent> = engine_deltas
4848                .iter()
4849                .map(|d| {
4850                    if d.fired {
4851                        // The score lives under the rule's declared weight_prop,
4852                        // which is not always the literal "weight".
4853                        let prop = self
4854                            .engine
4855                            .rules()
4856                            .find(|r| r.name == d.rule)
4857                            .and_then(|r| r.weight_prop.as_deref());
4858                        let weight = prop.and_then(|p| {
4859                            self.edge_props
4860                                .get(d.etype_sym, d.src_id, d.dst_id, p)
4861                                .and_then(|v| {
4862                                    if let core_storage::Value::Float(f) = v {
4863                                        Some(*f)
4864                                    } else {
4865                                        None
4866                                    }
4867                                })
4868                        });
4869                        DbEvent::EdgeFired {
4870                            rule: d.rule.clone(),
4871                            src_key: d.src_key.clone(),
4872                            dst_key: d.dst_key.clone(),
4873                            edge_type: d.edge_type.clone(),
4874                            weight,
4875                            commit_seq: seq,
4876                        }
4877                    } else {
4878                        DbEvent::EdgeRetracted {
4879                            rule: d.rule.clone(),
4880                            src_key: d.src_key.clone(),
4881                            dst_key: d.dst_key.clone(),
4882                            edge_type: d.edge_type.clone(),
4883                            commit_seq: seq,
4884                        }
4885                    }
4886                })
4887                .collect();
4888
4889            // Prune dead entries; push matching events to live ones.
4890            self.subscriptions.retain(|entry| {
4891                let Some(inner) = entry.inner.upgrade() else {
4892                    return false;
4893                };
4894                for ev in &write_events {
4895                    if event_matches(ev, &entry.filter) {
4896                        inner.push(ev.clone());
4897                    }
4898                }
4899                for ev in &edge_events {
4900                    if event_matches(ev, &entry.filter) {
4901                        inner.push(ev.clone());
4902                    }
4903                }
4904                true
4905            });
4906
4907            // Turn off delta accumulation if all subscribers dropped and no views remain.
4908            if self.subscriptions.is_empty() && self.view_store.is_empty() {
4909                self.engine.set_emit_deltas(false);
4910            }
4911        }
4912
4913        // Query subscriptions: full re-run per commit, then diff rows.
4914        // IMPORTANT: full re-execution on every commit — use LIMIT to bound cost.
4915        // Differential evaluation is roadmap / Phase 5.
4916        if !self.query_subscriptions.is_empty() {
4917            // Take the list out so we can call self.view() without borrow conflict.
4918            let mut query_subs = std::mem::take(&mut self.query_subscriptions);
4919            let empty_params = BTreeMap::new();
4920            query_subs.retain_mut(|entry| {
4921                let Some(inner) = entry.inner.upgrade() else {
4922                    return false; // subscriber dropped — prune
4923                };
4924                // Label-skip: if the plan has a known scan label and this commit
4925                // can be proven to touch only different labels (and no rule-derived
4926                // edge deltas fired), the result set cannot have changed — skip.
4927                if let Some(scan_sym) = entry.scan_label {
4928                    if engine_deltas.is_empty() {
4929                        let touched =
4930                            Self::commit_touched_labels(rec, &self.syms, &self.ids, &self.labels);
4931                        if touched.map(|t| !t.contains(&scan_sym)).unwrap_or(false) {
4932                            return true; // safe to skip — result set unchanged
4933                        }
4934                    }
4935                }
4936                QUERY_SUB_EXECS_TL.with(|c| c.set(c.get() + 1));
4937                let result = match execute(&self.view(), &entry.ops, &Params(&empty_params)) {
4938                    Ok(r) => r,
4939                    Err(e) => {
4940                        // Keep the subscription alive; skip the diff for this commit.
4941                        // Re-run errors are transient (e.g., planner change) and
4942                        // self-heal when the next commit succeeds.
4943                        eprintln!("[mushroomdb] subscribe_query re-run failed: {e}");
4944                        return true;
4945                    }
4946                };
4947                // Build new row map: serialized-key → row data.
4948                let new_row_map = Self::result_to_row_map(&result);
4949                // Removed rows: in prev but not in new.
4950                for (key, row) in &entry.prev_row_map {
4951                    if !new_row_map.contains_key(key) {
4952                        inner.push(DbEvent::QueryRowRemoved {
4953                            columns: entry.columns.clone(),
4954                            row: row.clone(),
4955                        });
4956                    }
4957                }
4958                // Added rows: in new but not in prev.
4959                for (key, row) in &new_row_map {
4960                    if !entry.prev_row_map.contains_key(key) {
4961                        inner.push(DbEvent::QueryRowAdded {
4962                            columns: entry.columns.clone(),
4963                            row: row.clone(),
4964                        });
4965                    }
4966                }
4967                entry.prev_row_map = new_row_map;
4968                true
4969            });
4970            self.query_subscriptions = query_subs;
4971        }
4972    }
4973
4974    /// Returns `true` if any live subscriber or view definition requires delta
4975    /// accumulation. Used to set `engine.emit_deltas` on subscribe/view DDL.
4976    fn needs_emit_deltas(&self) -> bool {
4977        !self.view_store.is_empty()
4978            || self
4979                .subscriptions
4980                .iter()
4981                .any(|e| e.inner.upgrade().is_some())
4982    }
4983
4984    /// Convert a WAL record into `DbEvent` write events with the given seq.
4985    fn write_events_from_record(
4986        rec: &WalRecord,
4987        seq: u64,
4988        intern: &Interner,
4989        ids: &IdMap,
4990    ) -> Vec<DbEvent> {
4991        match rec {
4992            WalRecord::InsertNode { label, key, .. } => vec![DbEvent::NodeInserted {
4993                label: label.clone(),
4994                key: key.clone(),
4995                commit_seq: seq,
4996            }],
4997            // *Id arms run after a successful apply, so resolution can only
4998            // fail on a programming error. Skip the event rather than emit a
4999            // fabricated "" that clients can't tell from a real empty value
5000            // (mirrors event_from_record returning None).
5001            WalRecord::InsertNodeId { label, key, .. } => intern
5002                .resolve(*label)
5003                .map(|label| DbEvent::NodeInserted {
5004                    label: label.to_string(),
5005                    key: key.clone(),
5006                    commit_seq: seq,
5007                })
5008                .into_iter()
5009                .collect(),
5010            WalRecord::SetProp { key, field, .. } => vec![DbEvent::PropSet {
5011                key: key.clone(),
5012                field: field.clone(),
5013                commit_seq: seq,
5014            }],
5015            WalRecord::SetPropId { id, field, .. } => ids
5016                .key_of(*id)
5017                .zip(intern.resolve(*field))
5018                .map(|(key, field)| DbEvent::PropSet {
5019                    key: key.to_string(),
5020                    field: field.to_string(),
5021                    commit_seq: seq,
5022                })
5023                .into_iter()
5024                .collect(),
5025            WalRecord::RemoveProp { key, field } => vec![DbEvent::PropRemoved {
5026                key: key.clone(),
5027                field: field.clone(),
5028                commit_seq: seq,
5029            }],
5030            WalRecord::InsertEdge {
5031                edge_type,
5032                src_key,
5033                dst_key,
5034            } => vec![DbEvent::EdgeInserted {
5035                edge_type: edge_type.clone(),
5036                src: src_key.clone(),
5037                dst: dst_key.clone(),
5038                commit_seq: seq,
5039            }],
5040            WalRecord::InsertEdgeId { etype, src, dst } => (|| {
5041                Some(DbEvent::EdgeInserted {
5042                    edge_type: intern.resolve(*etype)?.to_string(),
5043                    src: ids.key_of(*src)?.to_string(),
5044                    dst: ids.key_of(*dst)?.to_string(),
5045                    commit_seq: seq,
5046                })
5047            })()
5048            .into_iter()
5049            .collect(),
5050            WalRecord::DeleteEdge {
5051                edge_type,
5052                src_key,
5053                dst_key,
5054            } => vec![DbEvent::EdgeDeleted {
5055                edge_type: edge_type.clone(),
5056                src: src_key.clone(),
5057                dst: dst_key.clone(),
5058                commit_seq: seq,
5059            }],
5060            WalRecord::DeleteNode { key } => vec![DbEvent::NodeDeleted {
5061                key: key.clone(),
5062                commit_seq: seq,
5063            }],
5064            WalRecord::Batch(inner) => inner
5065                .iter()
5066                .flat_map(|r| Self::write_events_from_record(r, seq, intern, ids))
5067                .collect(),
5068            WalRecord::CreateRule { .. }
5069            | WalRecord::DeleteRule { .. }
5070            | WalRecord::RebuildRule { .. }
5071            | WalRecord::CreateView { .. }
5072            | WalRecord::DeleteView { .. }
5073            | WalRecord::EnableFulltext { .. }
5074            | WalRecord::DisableFulltext { .. }
5075            | WalRecord::EnableIndex { .. }
5076            | WalRecord::DisableIndex { .. }
5077            | WalRecord::Intern { .. }
5078            // History markers produce no DbEvent — the engine delta already
5079            // fired the EdgeFired/EdgeRetracted subscription events.
5080            | WalRecord::DerivedEdgeAdded { .. }
5081            | WalRecord::DerivedEdgeRetracted { .. }
5082            | WalRecord::RenameNode { .. } => vec![],
5083        }
5084    }
5085
5086    /// Subscribe to edge-fire and edge-retract events for one named rule.
5087    ///
5088    /// Returns `Err(GraphError::RuleNotFound)` if `rule_name` is not
5089    /// currently registered. Dropping the returned [`Subscription`] handle
5090    /// unregisters the subscriber — no further events are queued, no
5091    /// resources leak.
5092    pub fn subscribe_rule(&mut self, rule_name: &str) -> core_storage::Result<Subscription> {
5093        if self.read_only {
5094            return Err(core_storage::GraphError::ReadOnly);
5095        }
5096        if !self.engine.rules().any(|r| r.name == rule_name) {
5097            return Err(core_storage::GraphError::RuleNotFound {
5098                name: rule_name.to_string(),
5099            });
5100        }
5101        let inner = SubInner::new(self.sub_capacity());
5102        self.subscriptions.push(SubEntry {
5103            filter: SubFilter::Rule(rule_name.to_string()),
5104            inner: std::sync::Arc::downgrade(&inner),
5105        });
5106        self.engine.set_emit_deltas(true);
5107        Ok(Subscription(inner))
5108    }
5109
5110    /// Subscribe to edge-fire and edge-retract events for **all** rules.
5111    ///
5112    /// Returns `Err(GraphError::ReadOnly)` if called on an as-of instance —
5113    /// as-of instances never commit, so `distribute_events` never runs and the
5114    /// subscription would never deliver events.
5115    pub fn subscribe_all_rules(&mut self) -> core_storage::Result<Subscription> {
5116        if self.read_only {
5117            return Err(core_storage::GraphError::ReadOnly);
5118        }
5119        let inner = SubInner::new(self.sub_capacity());
5120        self.subscriptions.push(SubEntry {
5121            filter: SubFilter::AllRules,
5122            inner: std::sync::Arc::downgrade(&inner),
5123        });
5124        self.engine.set_emit_deltas(true);
5125        Ok(Subscription(inner))
5126    }
5127
5128    /// Subscribe to write events: node insert/delete, prop set/remove.
5129    ///
5130    /// Does not include edge-fire / edge-retract (rule-derived edge events).
5131    ///
5132    /// Returns `Err(GraphError::ReadOnly)` if called on an as-of instance —
5133    /// as-of instances never commit, so `distribute_events` never runs and the
5134    /// subscription would never deliver events.
5135    pub fn subscribe_writes(&mut self) -> core_storage::Result<Subscription> {
5136        if self.read_only {
5137            return Err(core_storage::GraphError::ReadOnly);
5138        }
5139        let inner = SubInner::new(self.sub_capacity());
5140        self.subscriptions.push(SubEntry {
5141            filter: SubFilter::Writes,
5142            inner: std::sync::Arc::downgrade(&inner),
5143        });
5144        self.engine.set_emit_deltas(true);
5145        Ok(Subscription(inner))
5146    }
5147
5148    /// Subscribe to incremental Cypher query results.
5149    ///
5150    /// Parses and plans `cypher`; rejects the query if the plan is not in the
5151    /// allowlisted subset (see [`core_query::cypher::is_subscribable`]):
5152    ///   - `MATCH (n:Label) WHERE … RETURN … [LIMIT n]`
5153    ///   - `MATCH (a)-[r:TYPE]->(b) RETURN … [LIMIT n]`  (exactly one hop)
5154    ///
5155    /// SKIP is not supported — it shifts the result window on every commit,
5156    /// causing spurious Added/Removed churn for rows whose data never changed.
5157    /// Multi-hop Expand chains are not supported; each additional MATCH clause
5158    /// widens scope beyond the documented single-scan / single-hop subset.
5159    ///
5160    /// After each successful commit, the plan is **fully re-executed** and the
5161    /// result is diffed against the previous run. Added rows produce
5162    /// [`DbEvent::QueryRowAdded`]; removed rows produce
5163    /// [`DbEvent::QueryRowRemoved`].
5164    ///
5165    /// **Full re-run per commit; use LIMIT to bound execution cost.**
5166    /// The existing 1 M intermediate-row cap applies. Differential evaluation
5167    /// is roadmap / Phase 5.
5168    ///
5169    /// Returns `Err(GraphError::ReadOnly)` if called on an as-of instance —
5170    /// as-of instances never commit, so `distribute_events` never runs and the
5171    /// subscription would never deliver events.
5172    ///
5173    /// Returns `Err(GraphError::QueryError)` if the query fails to parse, plan,
5174    /// or if the plan shape is not in the allowlist.
5175    pub fn subscribe_query(&mut self, cypher: &str) -> Result<Subscription> {
5176        if self.read_only {
5177            return Err(GraphError::ReadOnly);
5178        }
5179        let tokens = lex(cypher).map_err(|e| GraphError::QueryError {
5180            detail: format!("lex: {e}"),
5181        })?;
5182        let ast = parse(&tokens).map_err(|e| GraphError::QueryError {
5183            detail: format!("parse: {e}"),
5184        })?;
5185        let ops = plan(&ast).map_err(|e| GraphError::QueryError {
5186            detail: format!("plan: {e}"),
5187        })?;
5188        if !is_subscribable(&ops) {
5189            return Err(GraphError::QueryError {
5190                detail: "subscribe_query only supports allowlisted plan shapes: \
5191                         MATCH (n:Label) WHERE … RETURN … [LIMIT n] or \
5192                         MATCH (a)-[r:TYPE]->(b) RETURN … [LIMIT n] (exactly one hop). \
5193                         Not supported: multi-hop Expand chains, SKIP (creates \
5194                         unstable offset windows), ORDER BY, DISTINCT, aggregates, \
5195                         variable-length paths, OPTIONAL MATCH, WITH, UNWIND. \
5196                         Use LIMIT to bound re-execution cost."
5197                    .to_string(),
5198            });
5199        }
5200        // Execute once to capture initial state (initial rows are not emitted as
5201        // events — the subscriber learns the baseline via the first query call).
5202        let empty_params = BTreeMap::new();
5203        let initial = execute(&self.view(), &ops, &Params(&empty_params)).map_err(|e| {
5204            GraphError::QueryError {
5205                detail: format!("execute: {e}"),
5206            }
5207        })?;
5208        let columns = initial.columns().to_vec();
5209        let prev_row_map = Self::result_to_row_map(&initial);
5210        let inner = SubInner::new(self.sub_capacity());
5211        // Derive the scan-label sym for the commit-skip fast-path.  Any Expand op
5212        // or unrecognized leading scan → None (always re-execute).
5213        let scan_label = extract_scan_label(&ops, &mut self.syms);
5214        self.query_subscriptions.push(QuerySubEntry {
5215            ops,
5216            columns,
5217            prev_row_map,
5218            inner: std::sync::Arc::downgrade(&inner),
5219            scan_label,
5220        });
5221        Ok(Subscription(inner))
5222    }
5223
5224    /// Queue capacity used for new subscriptions.
5225    fn sub_capacity(&self) -> usize {
5226        self.sub_capacity
5227    }
5228
5229    /// Override per-subscriber queue capacity for subsequently created
5230    /// subscriptions on this db instance.
5231    ///
5232    /// Default is [`DEFAULT_SUB_CAPACITY`] (65,536 events). Use a smaller
5233    /// value in tests to exercise the [`DbEvent::Lagged`] path without
5234    /// generating tens of thousands of events.
5235    ///
5236    /// This is a test-support escape hatch. Calling it in production reduces
5237    /// subscriber reliability (more Lagged events). It is hidden from rustdoc
5238    /// to discourage accidental production use.
5239    #[doc(hidden)]
5240    pub fn set_sub_capacity(&mut self, capacity: usize) {
5241        self.sub_capacity = capacity;
5242    }
5243
5244    // -----------------------------------------------------------------------
5245
5246    /// Start an atomic batch.
5247    ///
5248    /// The returned [`BatchBuilder`] borrows `self` mutably until
5249    /// [`BatchBuilder::commit`]. Builder methods queue ops only — no
5250    /// validation, no WAL I/O. `commit` validates every queued op against
5251    /// live state plus preceding ops in this batch (duplicate key inside
5252    /// the batch is `Err`; an edge between two nodes created earlier in
5253    /// the batch is valid; `delete_node` then insert of the same key is a
5254    /// fresh identity). Validation never mutates the database. Any failure
5255    /// leaves WAL bytes and in-memory state identical to before `commit`.
5256    /// On success, one `WalRecord::Batch` frame is appended (one fsync)
5257    /// and each inner record is applied in order so rules fire per record.
5258    /// An empty batch, or a batch of only no-ops, writes zero WAL bytes.
5259    ///
5260    /// **Rule-window limitation:** batch validation cannot see edges that a
5261    /// rule created earlier in the *same* batch will derive at apply time, so
5262    /// a `delete_edge` / `insert_edge` in that window is silently no-oped
5263    /// where sequential calls would return `Err(RuleOwned)`. State integrity
5264    /// is unaffected (idempotent apply, provenance intact). Create rules in
5265    /// their own batch, or sequentially, when later ops may touch derived
5266    /// edges.
5267    pub fn batch(&mut self) -> BatchBuilder<'_, F> {
5268        BatchBuilder {
5269            db: self,
5270            ops: Vec::new(),
5271        }
5272    }
5273
5274    /// Closure-style atomic write batch.
5275    ///
5276    /// Equivalent to calling [`GraphDb::batch`], invoking `build` to queue ops,
5277    /// then committing. All ops queued inside `build` are validated in order and
5278    /// committed as a single `WalRecord::Batch` frame (one fsync). Rules fire
5279    /// once per inner record, in order, after commit — semantically identical to
5280    /// sequential single-op writes.
5281    ///
5282    /// **Error semantics — validate-then-apply.** `build` queues ops without
5283    /// touching the database. [`BatchBuilder::commit`] validates every op against
5284    /// live state plus earlier ops in this batch before writing anything. If op N
5285    /// fails validation (duplicate key, unknown key, rule-owned edge, …) the
5286    /// entire batch is rejected: no WAL bytes are written and no in-memory state
5287    /// changes. The database is identical to its state before `write_batch` was
5288    /// called.
5289    ///
5290    /// **Atomicity is crash-level, NOT isolation-level.** On replay after a crash,
5291    /// a partial (torn) `Batch` frame applies NONE of its ops — the frame is
5292    /// either fully applied or not at all. However, while applying a committed
5293    /// batch, concurrent readers may observe intermediate states as ops are applied
5294    /// sequentially in memory. There is no interactive transaction isolation in v1.
5295    /// This is documented as "crash-atomic write batches; no interactive
5296    /// transactions or read isolation."
5297    ///
5298    /// **Returns** `(nodes_inserted, edges_inserted)`. An empty or all-noop batch
5299    /// writes zero WAL bytes and returns `(0, 0)`.
5300    ///
5301    /// # Example
5302    ///
5303    /// ```rust,ignore
5304    /// let (nodes, edges) = db.write_batch(|b| {
5305    ///     b.insert_node("Person", "alice", vec![("age".into(), Value::Int(30))]);
5306    ///     b.insert_node("Person", "bob", vec![]);
5307    ///     b.insert_edge("KNOWS", "alice", "bob");
5308    ///     b.set_prop("alice", "role", Value::Str("admin".into()));
5309    ///     b.delete_node("old_key");
5310    /// })?;
5311    /// // One fsync; on crash replay: all five ops land or none do.
5312    /// ```
5313    pub fn write_batch<C>(&mut self, build: C) -> Result<(usize, usize)>
5314    where
5315        C: FnOnce(&mut BatchBuilder<'_, F>),
5316    {
5317        let mut b = self.batch();
5318        build(&mut b);
5319        b.commit()
5320    }
5321
5322    /// Insert `rows` as nodes of `label`. One call is one atomic batch:
5323    /// auto-declared KeyMatch rules (if any) first, then the accepted node
5324    /// inserts, so incremental fire sees the new rules. Per-row key problems
5325    /// are collected in [`IngestReport::row_errors`] and skipped; a commit
5326    /// `Err` means nothing was applied.
5327    ///
5328    /// Auto-FK rule names are `auto_fk_<src_label_lowercase>_<field>` so
5329    /// distinct source labels sharing an FK field each get their own rule.
5330    pub fn ingest(
5331        &mut self,
5332        label: &str,
5333        rows: Vec<BTreeMap<String, Value>>,
5334        opts: &IngestOptions,
5335    ) -> Result<IngestReport> {
5336        self.ingest_with_edges(label, rows, opts, &[])
5337    }
5338
5339    /// [`ingest`] plus user edges in the **same** previewed WAL batch.
5340    /// A failing edge rejects the whole request; nothing is applied.
5341    pub fn ingest_with_edges(
5342        &mut self,
5343        label: &str,
5344        rows: Vec<BTreeMap<String, Value>>,
5345        opts: &IngestOptions,
5346        edges: &[(String, String, String)],
5347    ) -> Result<IngestReport> {
5348        crate::ingest::run(self, label, rows, opts, edges)
5349    }
5350
5351    /// Parse `json` as an array of objects and ingest via [`GraphDb::ingest`].
5352    ///
5353    /// JSON `null` fields are silently omitted (not stored, not a row error).
5354    /// Nested objects and arrays-of-objects are a per-row error (row skipped).
5355    /// Parse failures and a top-level value that is not an array of objects
5356    /// return [`GraphError::IngestError`].
5357    pub fn ingest_json(
5358        &mut self,
5359        label: &str,
5360        json: &str,
5361        opts: &IngestOptions,
5362    ) -> Result<IngestReport> {
5363        crate::ingest::run_json(self, label, json, opts)
5364    }
5365
5366    fn commit_logged_batch(
5367        &mut self,
5368        ops: Vec<BatchOp>,
5369        ingest: Option<(String, usize)>,
5370        // Two-source rule: write_batch_authz threads authz here directly (never
5371        // touches pending_write_authz); query_write_authz sets the field instead
5372        // and passes None.  Only one source is non-None per call.
5373        param_authz: Option<WriteAuthz>,
5374    ) -> Result<(usize, usize)> {
5375        // Read-only guard: catches empty-batch calls before the early-return
5376        // that skips log_then_apply_with, ensuring all mutation entry points fail.
5377        if self.read_only {
5378            return Err(GraphError::ReadOnly);
5379        }
5380        // Ensure provenance is decoded before MutPreview accesses it
5381        // (note_delete_rule / is_rule_owned may call engine.provenance()).
5382        self.engine.ensure_provenance_loaded_mut();
5383
5384        // ── Authz pre-check ──────────────────────────────────────────────────
5385        // Evaluate the decision table per-op BEFORE MutPreview so that a denial
5386        // produces no WAL frame (all-or-nothing at the authz boundary extends
5387        // the existing validate-then-apply contract to role-scope checks).
5388        //
5389        // `batch_created` tracks key→label for nodes created by earlier ops in
5390        // THIS batch, so InsertEdgeUpsert can count same-batch placeholder nodes
5391        // as visible without needing to call `self.ids.get` on not-yet-committed
5392        // keys (they won't be there yet).
5393        //
5394        // Two-source rule: param_authz (write_batch_authz path) takes precedence;
5395        // fall back to self.pending_write_authz (query_write_authz/Cypher path).
5396        // Cloning the field copy avoids a simultaneous borrow of self.ids below.
5397        let authz_opt = param_authz.or_else(|| self.pending_write_authz.clone());
5398        if let Some(ref authz) = authz_opt {
5399            let mut batch_created: BTreeMap<String, String> = BTreeMap::new();
5400            for op in &ops {
5401                self.check_single_op_authz(authz, op, &batch_created)?;
5402                // Update batch_created after a passing authz check so that
5403                // subsequent ops in this batch see the nodes as "about to exist".
5404                match op {
5405                    BatchOp::InsertNode { label, key, .. } => {
5406                        // Only track genuinely new nodes (absent from the
5407                        // snapshot at authz-check time). A pre-existing visible
5408                        // key would be a DuplicateKey — not a real creation —
5409                        // so MutPreview handles it. Letting it into batch_created
5410                        // would allow a later SetProp to bypass update_labels
5411                        // via the "batch-created → always updatable" ruling
5412                        // (delete+recreate exploit, fix for I1 review round 2).
5413                        //
5414                        // Accepted edge: for a delete+recreate-with-different-
5415                        // label batch, node_status resolves the pre-delete
5416                        // (store) label for any subsequent update checks. This
5417                        // grants no net-new capability — a role that can delete+
5418                        // create can already place arbitrary props via
5419                        // InsertNode's own props field.
5420                        if self.ids.get(key.as_str()).is_none() {
5421                            batch_created.insert(key.clone(), label.clone());
5422                        }
5423                    }
5424                    BatchOp::InsertEdgeUpsert {
5425                        placeholder_label,
5426                        src_key,
5427                        dst_key,
5428                        ..
5429                    } => {
5430                        // Both endpoints will be created if not already in store.
5431                        for ep_key in [src_key, dst_key] {
5432                            if self.ids.get(ep_key.as_str()).is_none()
5433                                && !batch_created.contains_key(ep_key.as_str())
5434                            {
5435                                batch_created.insert(ep_key.clone(), placeholder_label.clone());
5436                            }
5437                        }
5438                    }
5439                    _ => {}
5440                }
5441            }
5442        }
5443
5444        let recs = {
5445            let mut preview = MutPreview::new(self);
5446            let mut recs = Vec::with_capacity(ops.len());
5447            for op in ops {
5448                match op {
5449                    BatchOp::InsertNode { label, key, props } => {
5450                        preview.check_insert_node(&key)?;
5451                        preview.note_insert_node(&key, &props);
5452                        recs.push(WalRecord::InsertNode { label, key, props });
5453                    }
5454                    BatchOp::InsertEdge {
5455                        edge_type,
5456                        src_key,
5457                        dst_key,
5458                    } => {
5459                        if preview.prepare_insert_edge(&edge_type, &src_key, &dst_key)? {
5460                            preview.note_insert_edge(&edge_type, &src_key, &dst_key);
5461                            recs.push(WalRecord::InsertEdge {
5462                                edge_type,
5463                                src_key,
5464                                dst_key,
5465                            });
5466                        }
5467                    }
5468                    BatchOp::SetProp { key, field, value } => {
5469                        preview.check_live_key(&key)?;
5470                        preview.note_set_prop(&key, &field, &value);
5471                        recs.push(WalRecord::SetProp { key, field, value });
5472                    }
5473                    BatchOp::RemoveProp { key, field } => {
5474                        if preview.prepare_remove_prop(&key, &field)? {
5475                            preview.note_remove_prop(&key, &field);
5476                            recs.push(WalRecord::RemoveProp { key, field });
5477                        }
5478                    }
5479                    BatchOp::DeleteEdge {
5480                        edge_type,
5481                        src_key,
5482                        dst_key,
5483                    } => {
5484                        if preview.prepare_delete_edge(&edge_type, &src_key, &dst_key)? {
5485                            preview.note_delete_edge(&edge_type, &src_key, &dst_key);
5486                            recs.push(WalRecord::DeleteEdge {
5487                                edge_type,
5488                                src_key,
5489                                dst_key,
5490                            });
5491                        }
5492                    }
5493                    BatchOp::DeleteNode { key } => {
5494                        preview.check_live_key(&key)?;
5495                        preview.note_delete_node(&key);
5496                        recs.push(WalRecord::DeleteNode { key });
5497                    }
5498                    BatchOp::CreateRule(def) => {
5499                        preview.check_create_rule(&def)?;
5500                        let def_bytes =
5501                            bincode::serialize(&def).map_err(|e| GraphError::Corrupt {
5502                                detail: format!("serialize rule: {e}"),
5503                            })?;
5504                        preview.note_create_rule(&def);
5505                        recs.push(WalRecord::CreateRule { def_bytes });
5506                    }
5507                    BatchOp::DeleteRule { name } => {
5508                        preview.check_delete_rule(&name)?;
5509                        preview.note_delete_rule(&name);
5510                        recs.push(WalRecord::DeleteRule { name });
5511                    }
5512                    BatchOp::RenameNode { old_key, new_key } => {
5513                        preview.check_rename_node(&old_key, &new_key)?;
5514                        preview.note_rename_node(&old_key, &new_key);
5515                        recs.push(WalRecord::RenameNode { old_key, new_key });
5516                    }
5517                    BatchOp::InsertEdgeUpsert {
5518                        edge_type,
5519                        src_key,
5520                        dst_key,
5521                        placeholder_label,
5522                    } => {
5523                        // Auto-create any missing endpoints as plain InsertNode ops.
5524                        // Rules fire and last-change is updated for each created node.
5525                        for key in [&src_key, &dst_key] {
5526                            if !preview.has_key(key) {
5527                                preview.check_insert_node(key)?;
5528                                preview.note_insert_node(key, &[]);
5529                                recs.push(WalRecord::InsertNode {
5530                                    label: placeholder_label.clone(),
5531                                    key: key.clone(),
5532                                    props: vec![],
5533                                });
5534                            }
5535                        }
5536                        if preview.prepare_insert_edge(&edge_type, &src_key, &dst_key)? {
5537                            preview.note_insert_edge(&edge_type, &src_key, &dst_key);
5538                            recs.push(WalRecord::InsertEdge {
5539                                edge_type,
5540                                src_key,
5541                                dst_key,
5542                            });
5543                        }
5544                    }
5545                }
5546            }
5547            recs
5548        };
5549        if recs.is_empty() {
5550            return Ok((0, 0));
5551        }
5552        // rewrite_wal_dense converts every InsertNode/InsertEdge into its
5553        // *Id form, so only the dense variants can appear in `recs` here.
5554        let recs = self.rewrite_wal_dense(recs)?;
5555        // The rewrite can empty a non-empty batch: a `SET n.ns` naming the
5556        // namespace the node is already in is a no-op and is dropped there. An
5557        // empty `Batch` frame would still take a commit sequence and a WAL
5558        // record, so a batch that turns out to be nothing writes nothing.
5559        if recs.is_empty() {
5560            return Ok((0, 0));
5561        }
5562        let nodes_inserted = recs
5563            .iter()
5564            .filter(|r| matches!(r, WalRecord::InsertNodeId { .. }))
5565            .count();
5566        let edges_inserted = recs
5567            .iter()
5568            .filter(|r| matches!(r, WalRecord::InsertEdgeId { .. }))
5569            .count();
5570        // Ingest / write_batch / query_write: one Batch frame, one fsync per call
5571        // under Strict.  Pass self.fsync directly so Strict stays Strict —
5572        // wal_needs_sync(Strict, _) always returns true regardless of op count.
5573        // Mapping Strict → Batched (the prior bug) caused wal_needs_sync to
5574        // short-circuit on single-op batches and silently skip the fsync.
5575        // Batched fsyncs only for multi-op batches; Relaxed always skips.
5576        self.log_then_apply_with(WalRecord::Batch(recs), ingest, self.fsync)?;
5577        Ok((nodes_inserted, edges_inserted))
5578    }
5579
5580    fn commit_batch(&mut self, ops: Vec<BatchOp>) -> Result<(usize, usize)> {
5581        self.commit_logged_batch(ops, None, None)
5582    }
5583
5584    /// Commit one submission WITHOUT an fsync — for use inside `commit_group`
5585    /// and the group-commit drain thread, which do a single group fsync later.
5586    fn commit_batch_nosync(&mut self, ops: Vec<BatchOp>) -> Result<(usize, usize)> {
5587        // Restore fsync policy even on panic via a raw-pointer drop guard.
5588        // A panic here would poison the RwLock anyway, but the correct policy
5589        // must be in place if the guard is ever unwrapped.
5590        struct RestoreFsync(*mut FsyncPolicy, FsyncPolicy);
5591        impl Drop for RestoreFsync {
5592            fn drop(&mut self) {
5593                // SAFETY: the pointer is valid for the full duration of
5594                // commit_batch_nosync; the guard is dropped before the frame
5595                // returns, and GraphDb outlives this frame.
5596                unsafe {
5597                    *self.0 = self.1;
5598                }
5599            }
5600        }
5601        let saved = self.fsync;
5602        // SAFETY: raw pointer into self; guard dropped within this frame.
5603        let _g = RestoreFsync(&mut self.fsync as *mut FsyncPolicy, saved);
5604        self.fsync = FsyncPolicy::Relaxed;
5605        self.commit_logged_batch(ops, None, None)
5606    }
5607
5608    /// Commit multiple op-batches as a **group**: each submission gets its own
5609    /// WAL `Batch` frame, but there is exactly **one** `Fs::sync` for the whole
5610    /// group (under `Strict` / `Batched` policy; `Relaxed` skips all syncs).
5611    ///
5612    /// # Durability semantics
5613    ///
5614    /// A crash before the group fsync may lose **all** submissions in the group.
5615    /// A crash after the group fsync preserves all of them.  No submission is
5616    /// ever torn: each WAL frame is either fully applied on replay or dropped
5617    /// in its entirety (CRC-protected frame boundaries).
5618    ///
5619    /// Events and subscription notifications fire per-submission immediately
5620    /// after apply, which may be before the group fsync.  From a subscriber's
5621    /// perspective this is equivalent to the `Relaxed` durability window.
5622    /// Submitters using [`SharedDb::submit_batch`] only unblock after the group
5623    /// fsync, so from their perspective durability is fully guaranteed.
5624    ///
5625    /// # MVCC interplay
5626    ///
5627    /// Each submission records its own `CommitDelta`; the fold-every-K counter
5628    /// increments per submission (not per group), preserving existing reader
5629    /// snapshot semantics.
5630    ///
5631    /// # Returns
5632    ///
5633    /// One `Result<(nodes_inserted, edges_inserted)>` per input group element,
5634    /// in order.  Failures are per-submission (validation errors); the group
5635    /// fsync error (if any) is returned as the second tuple element.
5636    pub fn commit_group(
5637        &mut self,
5638        groups: Vec<Vec<BatchOp>>,
5639    ) -> (Vec<Result<(usize, usize)>>, Option<GraphError>) {
5640        let mut results = Vec::with_capacity(groups.len());
5641        for ops in groups {
5642            results.push(self.commit_batch_nosync(ops));
5643        }
5644        let any_ok = results.iter().any(|r| r.is_ok());
5645        let sync_err = if self.fsync != FsyncPolicy::Relaxed && any_ok {
5646            self.fs
5647                .sync(core_storage::fs::FileId::Wal)
5648                .map_err(GraphError::Io)
5649                .err()
5650        } else {
5651            None
5652        };
5653        (results, sync_err)
5654    }
5655
5656    /// Like [`commit_group`] but skips the group fsync entirely.
5657    ///
5658    /// Used by the drain thread to apply submissions under the write lock and
5659    /// then perform the single fsync OUTSIDE the lock (via
5660    /// `core_storage::sync_wal_at`), reducing the write-lock hold time visible
5661    /// to concurrent readers.
5662    pub fn commit_group_nosync(
5663        &mut self,
5664        groups: Vec<Vec<BatchOp>>,
5665    ) -> Vec<Result<(usize, usize)>> {
5666        let mut results = Vec::with_capacity(groups.len());
5667        for ops in groups {
5668            results.push(self.commit_batch_nosync(ops));
5669        }
5670        results
5671    }
5672
5673    pub fn insert_node(
5674        &mut self,
5675        label: &str,
5676        key: &str,
5677        props: Vec<(String, Value)>,
5678    ) -> Result<()> {
5679        if self.read_only {
5680            return Err(GraphError::ReadOnly);
5681        }
5682        MutPreview::new(self).check_insert_node(key)?;
5683        self.log_dense(vec![WalRecord::InsertNode {
5684            label: label.into(),
5685            key: key.into(),
5686            props,
5687        }])
5688    }
5689
5690    pub fn insert_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) -> Result<bool> {
5691        if self.read_only {
5692            return Err(GraphError::ReadOnly);
5693        }
5694        if !MutPreview::new(self).prepare_insert_edge(edge_type, src_key, dst_key)? {
5695            return Ok(false);
5696        }
5697        self.log_dense(vec![WalRecord::InsertEdge {
5698            edge_type: edge_type.into(),
5699            src_key: src_key.into(),
5700            dst_key: dst_key.into(),
5701        }])?;
5702        Ok(true)
5703    }
5704
5705    pub fn set_prop(&mut self, key: &str, field: &str, value: Value) -> Result<()> {
5706        if self.read_only {
5707            return Err(GraphError::ReadOnly);
5708        }
5709        if let Some(view_name) = self.view_store.view_for_prop(field) {
5710            return Err(GraphError::ViewPropReadOnly {
5711                view_name: view_name.to_string(),
5712            });
5713        }
5714        MutPreview::new(self).check_live_key(key)?;
5715        self.log_dense(vec![WalRecord::SetProp {
5716            key: key.into(),
5717            field: field.into(),
5718            value,
5719        }])
5720    }
5721
5722    /// Remove a property. Returns `Ok(false)` (and does not log) if the field
5723    /// is already absent. Unknown or tombstoned keys are `Err(KeyNotFound)`.
5724    pub fn remove_prop(&mut self, key: &str, field: &str) -> Result<bool> {
5725        if self.read_only {
5726            return Err(GraphError::ReadOnly);
5727        }
5728        if let Some(view_name) = self.view_store.view_for_prop(field) {
5729            return Err(GraphError::ViewPropReadOnly {
5730                view_name: view_name.to_string(),
5731            });
5732        }
5733        if !MutPreview::new(self).prepare_remove_prop(key, field)? {
5734            return Ok(false);
5735        }
5736        self.log_then_apply(WalRecord::RemoveProp {
5737            key: key.into(),
5738            field: field.into(),
5739        })?;
5740        Ok(true)
5741    }
5742
5743    /// Delete a user edge. Returns `Ok(false)` (and does not log) if the edge
5744    /// is absent. Unknown keys are `Err(KeyNotFound)`. Rule-owned edges — in
5745    /// provenance, or a pair a live rule would derive — are `Err(RuleOwned)`
5746    /// (the rule would just put the edge back; delete or change the rule).
5747    pub fn delete_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) -> Result<bool> {
5748        if self.read_only {
5749            return Err(GraphError::ReadOnly);
5750        }
5751        if !MutPreview::new(self).prepare_delete_edge(edge_type, src_key, dst_key)? {
5752            return Ok(false);
5753        }
5754        self.log_then_apply(WalRecord::DeleteEdge {
5755            edge_type: edge_type.into(),
5756            src_key: src_key.into(),
5757            dst_key: dst_key.into(),
5758        })?;
5759        Ok(true)
5760    }
5761
5762    /// Delete a live node. Unknown or already-tombstoned keys are
5763    /// `Err(KeyNotFound)` and are not logged. Validation runs before the WAL
5764    /// write; `apply` of a logged `DeleteNode` for an already-tombstoned key
5765    /// (crash window) is a clean no-op.
5766    ///
5767    /// Returns a [`DeleteReport`] with counts of manual and derived edges
5768    /// removed (computed from live state before the deletion is applied).
5769    pub fn delete_node(&mut self, key: &str) -> Result<DeleteReport> {
5770        if self.read_only {
5771            return Err(GraphError::ReadOnly);
5772        }
5773        // Provenance must be loaded before we query provenance_touching.
5774        self.engine.ensure_provenance_loaded_mut();
5775        let id = self
5776            .ids
5777            .get(key)
5778            .ok_or_else(|| GraphError::KeyNotFound { key: key.into() })?;
5779
5780        // Count edges before the delete is applied so we can report counts.
5781        let derived_set: BTreeSet<(u32, u32, u32)> = self
5782            .engine
5783            .provenance_touching(id)
5784            .map(|(_, etype, src, dst)| (etype, src, dst))
5785            .collect();
5786        let derived_edges = derived_set.len() as u64;
5787
5788        let mut total_topo = 0u64;
5789        let tv = self.topo_view();
5790        for et in tv.etypes() {
5791            total_topo += tv.neighbors(et, Direction::Out, id).len() as u64
5792                + tv.neighbors(et, Direction::In, id).len() as u64;
5793        }
5794        // For symmetric rules (e.g. Overlap), a→b and b→a are two separate directed
5795        // triples in both the topo scan (Out and In from id) and in provenance_touching.
5796        // The subtraction remains correct because both counts include both directions.
5797        let manual_edges = total_topo.saturating_sub(derived_edges);
5798
5799        self.log_then_apply(WalRecord::DeleteNode { key: key.into() })?;
5800        Ok(DeleteReport {
5801            manual_edges,
5802            derived_edges,
5803        })
5804    }
5805
5806    /// Rename a live node's key.  The dense id (and therefore all edges,
5807    /// props, history, and last-change tracking) is unaffected.
5808    ///
5809    /// Returns `Err(KeyNotFound)` if `old` is not a live key.
5810    /// Returns `Err(DuplicateKey)` if `new` is already live.
5811    pub fn rename_node(&mut self, old: &str, new: &str) -> Result<()> {
5812        if self.read_only {
5813            return Err(GraphError::ReadOnly);
5814        }
5815        MutPreview::new(self).check_rename_node(old, new)?;
5816        self.log_then_apply(WalRecord::RenameNode {
5817            old_key: old.into(),
5818            new_key: new.into(),
5819        })
5820    }
5821
5822    /// Return the IVF drift counter for the dst-side candidate index of `rule`.
5823    /// `None` if the rule does not exist or is not approximate.
5824    ///
5825    /// The drift counter increments on IVF insert/remove after the last fit.
5826    /// When dst-side drift exceeds [`core_rules::IVF_DRIFT_REBUILD`], apply
5827    /// WAL-logs `RebuildRule` as a second commit (rebuild resets the counter).
5828    pub fn ivf_dst_drift(&self, rule: &str) -> Option<u64> {
5829        // SideIvfExport = (centroids, node→cluster, drift)
5830        self.engine
5831            .export_ivf_state()
5832            .remove(rule)
5833            .map(|(_src, dst)| dst.2)
5834    }
5835
5836    /// Validate and WAL-log a new rule, then backfill derived edges inside apply.
5837    /// Validation and duplicate-name check run before logging so invalid rules
5838    /// never enter the WAL.
5839    pub fn create_rule(&mut self, def: RuleDef) -> Result<()> {
5840        if self.read_only {
5841            return Err(GraphError::ReadOnly);
5842        }
5843        MutPreview::new(self).check_create_rule(&def)?;
5844        let def_bytes = bincode::serialize(&def).map_err(|e| GraphError::Corrupt {
5845            detail: format!("serialize rule: {e}"),
5846        })?;
5847        self.log_then_apply(WalRecord::CreateRule { def_bytes })
5848    }
5849
5850    /// Override this handle's HNSW build-slice size, or `None` to restore
5851    /// [`core_rules::HNSW_BUILD_BATCH`].
5852    ///
5853    /// Exposed for tests that need a small slice without a large corpus; not
5854    /// part of the stable surface.
5855    #[doc(hidden)]
5856    pub fn set_hnsw_build_batch(&mut self, batch: Option<usize>) {
5857        self.engine.set_hnsw_build_batch(batch);
5858    }
5859
5860    /// Rules whose vector index is still being built, in name order.
5861    ///
5862    /// The same list [`GraphDb::stats`] reports per rule in `building`.
5863    pub fn builds_in_progress(&self) -> Vec<BuildProgress> {
5864        self.engine.builds_in_progress()
5865    }
5866
5867    /// Advance any vector index still building and backfill each rule that
5868    /// finishes. Returns what is still outstanding.
5869    ///
5870    /// A map lookup when nothing is pending, so it is cheap to call on a timer.
5871    /// One write lock and at most [`core_rules::HNSW_BUILD_BATCH`] vector
5872    /// inserts per pending rule per call, so a caller can drive a large build
5873    /// to completion without ever holding the lock for more than a slice.
5874    ///
5875    /// A rule that finishes here is backfilled through the same
5876    /// `WalRecord::RebuildRule` second commit that IVF drift already uses, so
5877    /// its derived edges are produced by [`GraphDb::rebuild_rule`]'s code path
5878    /// and appear all at once.
5879    ///
5880    /// Every ordinary write pumps one slice on its own (see the post-commit
5881    /// hook in `log_then_apply_with`), so this is for quiescent stores and for
5882    /// operators who want the build finished before traffic arrives.
5883    pub fn pump_index_build(&mut self) -> Result<Vec<BuildProgress>> {
5884        Ok(self.pump_index_build_reporting()?.1)
5885    }
5886
5887    /// [`GraphDb::pump_index_build`], also reporting the builds that **this**
5888    /// call finished, so a progress display can say so.
5889    ///
5890    /// A build can be registered and completed inside a single call — that is
5891    /// what a mid-build snapshot looks like on reopen, where the index scan
5892    /// finishes the graph and only the backfill is outstanding — and the
5893    /// outstanding list alone cannot show that anything happened.
5894    pub fn pump_index_build_reporting(
5895        &mut self,
5896    ) -> Result<(Vec<BuildProgress>, Vec<BuildProgress>)> {
5897        // A read-only handle cannot issue the `RebuildRule` a finished build
5898        // needs, so it would advance the index and then silently fail to
5899        // produce the edges. Refusing is the honest answer.
5900        if self.read_only {
5901            return Err(GraphError::ReadOnly);
5902        }
5903        let finished = self.pump_one_slice();
5904        for done in &finished {
5905            // The index is whole but the rule still owns no edges. A failed
5906            // second commit must leave the rule re-pumpable rather than
5907            // silently edge-less, so the error is surfaced here — unlike the
5908            // post-commit hook, this call is not riding someone else's commit.
5909            self.log_then_apply(WalRecord::RebuildRule {
5910                name: done.rule.clone(),
5911            })?;
5912        }
5913        Ok((finished, self.engine.builds_in_progress()))
5914    }
5915
5916    /// Run the deferred candidate-index build, if it is still owed, against the
5917    /// graph as it stands *now* — before the caller applies anything.
5918    ///
5919    /// A no-op bool test once the indexes are populated, which is after the
5920    /// first write of the handle's life, and for a store with no rules at all.
5921    fn populate_indexes_before_write(&mut self) {
5922        if !self.engine.needs_index_population() {
5923            return;
5924        }
5925        // The retained snapshot blobs arrive with the V8 base sections; without
5926        // them the scan would rebuild every graph the snapshot already holds.
5927        self.ensure_v8_base_sections_loaded();
5928        if !self.engine.needs_index_population() {
5929            return;
5930        }
5931        let mut eng = std::mem::take(&mut self.engine);
5932        {
5933            let gm = make_graph_mut(
5934                &self.ids,
5935                &mut self.syms,
5936                &self.labels,
5937                build_props_view(&self.props, &self.base),
5938                &mut self.topo,
5939                &self.base,
5940                &mut self.edge_props,
5941            );
5942            eng.populate_indexes(&gm);
5943        }
5944        self.engine = eng;
5945    }
5946
5947    /// One slice of build work for every pending rule. Returns the rules whose
5948    /// index just became whole, which the caller must `RebuildRule`.
5949    ///
5950    /// Goes through the engine even with nothing pending when the indexes have
5951    /// not been populated yet: that call is what re-derives a build a mid-build
5952    /// snapshot left behind, and a fresh handle has no other way to learn of it.
5953    fn pump_one_slice(&mut self) -> Vec<BuildProgress> {
5954        // The retained snapshot blobs — and the id count an interrupted build
5955        // is recognised against — arrive with the V8 base sections, which a
5956        // clean open reads lazily. Without this a freshly opened handle pumps
5957        // against empty retained state and concludes there is nothing to do,
5958        // which is precisely the store `build-index` exists for.
5959        self.ensure_v8_base_sections_loaded();
5960        let mut eng = std::mem::take(&mut self.engine);
5961        let finished = {
5962            let mut gm = make_graph_mut(
5963                &self.ids,
5964                &mut self.syms,
5965                &self.labels,
5966                build_props_view(&self.props, &self.base),
5967                &mut self.topo,
5968                &self.base,
5969                &mut self.edge_props,
5970            );
5971            eng.pump_index_build(&mut gm)
5972        };
5973        self.engine = eng;
5974        finished
5975    }
5976
5977    /// WAL-log rule deletion. Returns RuleNotFound if the rule does not exist.
5978    pub fn delete_rule(&mut self, name: &str) -> Result<()> {
5979        if self.read_only {
5980            return Err(GraphError::ReadOnly);
5981        }
5982        MutPreview::new(self).check_delete_rule(name)?;
5983        self.log_then_apply(WalRecord::DeleteRule { name: name.into() })
5984    }
5985
5986    /// Return a snapshot of all registered rules.
5987    pub fn rules(&self) -> Vec<RuleDef> {
5988        self.engine.rules().cloned().collect()
5989    }
5990
5991    // -----------------------------------------------------------------------
5992    // Rule suggestion API
5993    // -----------------------------------------------------------------------
5994
5995    /// Profile the database and suggest linking rules with previewed edge counts.
5996    ///
5997    /// Uses the default seed ([`core_rules::SUGGEST_DEFAULT_SEED`]) for deterministic
5998    /// sampling. Suggestions are sorted by estimated edge count (descending).
5999    /// **NO auto-accept** — call [`GraphDb::create_rule`] explicitly to apply.
6000    pub fn suggest_rules(&self) -> Vec<core_rules::RuleSuggestion> {
6001        self.suggest_rules_seeded(core_rules::SUGGEST_DEFAULT_SEED)
6002    }
6003
6004    /// Like [`suggest_rules`] but with a caller-supplied RNG seed for
6005    /// reproducibility. Same seed + same data = identical output.
6006    pub fn suggest_rules_seeded(&self, seed: u64) -> Vec<core_rules::RuleSuggestion> {
6007        self.suggest_rules_with_config(&core_rules::suggest::SuggestConfig::default(), seed)
6008            .suggestions
6009    }
6010
6011    /// [`suggest_rules_seeded`] with a fully custom [`SuggestConfig`].
6012    ///
6013    /// Returns a [`core_rules::SuggestReport`] that includes both the candidate list
6014    /// and a `truncated` flag indicating whether the global budget fired before all
6015    /// candidates were evaluated.
6016    pub fn suggest_rules_with_config(
6017        &self,
6018        config: &core_rules::suggest::SuggestConfig,
6019        seed: u64,
6020    ) -> core_rules::SuggestReport {
6021        use std::collections::BTreeMap;
6022
6023        // Collect (node_id, key) pairs per label, skipping tombstoned nodes.
6024        let mut label_nodes: BTreeMap<String, Vec<(u32, String)>> = BTreeMap::new();
6025        for id in 0..self.ids.len() as u32 {
6026            let Some(key) = self.ids.key_of(id) else {
6027                continue;
6028            };
6029            let Some(&sym) = self.labels.get(id as usize) else {
6030                continue;
6031            };
6032            if sym == u32::MAX {
6033                continue; // tombstoned
6034            }
6035            let Some(label) = self.syms.resolve(sym) else {
6036                continue;
6037            };
6038            label_nodes
6039                .entry(label.to_string())
6040                .or_default()
6041                .push((id, key.to_string()));
6042        }
6043
6044        let existing = self.rules();
6045        let pv = build_props_view(&self.props, &self.base);
6046        let all_fields: Vec<String> = pv.field_names();
6047
6048        core_rules::suggest::suggest_rules(
6049            &label_nodes,
6050            &|id, field| pv.get(id, field).map(|vr| vr.into_value()),
6051            &all_fields,
6052            &existing,
6053            config,
6054            seed,
6055        )
6056    }
6057
6058    /// Recompute a rule's derived edges from scratch. WAL-logged so un-trip
6059    /// plus later mutations replay identically (rebuild is a pure function
6060    /// of state).
6061    ///
6062    /// Only exit from the tripped latch: if the full desired set fits the
6063    /// budget, it is applied completely and `tripped` clears; if it still
6064    /// exceeds the budget, provenance is left untouched and `tripped` stays
6065    /// true. Counts as a fire evaluation (see [`RuleStats::fires`]).
6066    /// Unknown rule → `RuleNotFound`, nothing logged.
6067    pub fn rebuild_rule(&mut self, name: &str) -> Result<()> {
6068        if self.read_only {
6069            return Err(GraphError::ReadOnly);
6070        }
6071        if !self.engine.rules().any(|r| r.name == name) {
6072            return Err(GraphError::RuleNotFound { name: name.into() });
6073        }
6074        self.log_then_apply(WalRecord::RebuildRule { name: name.into() })
6075    }
6076
6077    // -----------------------------------------------------------------------
6078    // Materialized view API
6079    // -----------------------------------------------------------------------
6080
6081    /// Register a new materialized property view, backfill its values for all
6082    /// existing nodes, and WAL-log the definition.
6083    ///
6084    /// # Errors
6085    /// - `ReadOnly`: called on an as-of instance.
6086    /// - `RuleInvalid`: name collision, view_prop collision, or invalid def.
6087    pub fn create_view(&mut self, def: ViewDef) -> Result<()> {
6088        if self.read_only {
6089            return Err(GraphError::ReadOnly);
6090        }
6091        // Pre-validate before WAL write.
6092        def.validate()
6093            .map_err(|e| GraphError::RuleInvalid { detail: e })?;
6094        if self.view_store.has_view(&def.name) {
6095            return Err(GraphError::RuleInvalid {
6096                detail: format!("view {:?} already exists", def.name),
6097            });
6098        }
6099        if let Some(existing) = self.view_store.view_for_prop(&def.view_prop) {
6100            return Err(GraphError::RuleInvalid {
6101                detail: format!(
6102                    "view_prop {:?} is already used by view {:?}",
6103                    def.view_prop, existing
6104                ),
6105            });
6106        }
6107        let def_bytes = bincode::serialize(&def).map_err(|e| GraphError::Corrupt {
6108            detail: format!("serialize view: {e}"),
6109        })?;
6110        // Enable delta accumulation before the view is registered so subsequent
6111        // incremental edge events reach view maintenance from this point onward.
6112        // (The backfill inside create_view reads topo directly; it does not rely
6113        // on pending deltas.)
6114        self.engine.set_emit_deltas(true);
6115        self.log_then_apply(WalRecord::CreateView { def_bytes })
6116    }
6117
6118    /// Remove a named view and delete its values from every node.
6119    ///
6120    /// # Errors
6121    /// - `ReadOnly`: called on an as-of instance.
6122    /// - `RuleNotFound`: view does not exist.
6123    pub fn delete_view(&mut self, name: &str) -> Result<()> {
6124        if self.read_only {
6125            return Err(GraphError::ReadOnly);
6126        }
6127        if !self.view_store.has_view(name) {
6128            return Err(GraphError::RuleNotFound { name: name.into() });
6129        }
6130        let result = self.log_then_apply(WalRecord::DeleteView { name: name.into() });
6131        // After deletion, disable accumulation if no listeners remain.
6132        if !self.needs_emit_deltas() {
6133            self.engine.set_emit_deltas(false);
6134        }
6135        result
6136    }
6137
6138    /// Snapshot of all registered view definitions.
6139    pub fn views(&self) -> Vec<ViewDef> {
6140        self.view_store.views().cloned().collect()
6141    }
6142
6143    // -----------------------------------------------------------------------
6144    // Full-text-lite API
6145    // -----------------------------------------------------------------------
6146
6147    /// Enable full-text indexing for all nodes of `label` on property `field`.
6148    ///
6149    /// After this call, every subsequent write to `(label, field)` is reflected
6150    /// in the index incrementally.  Existing nodes are backfilled immediately.
6151    /// The declaration is persisted as a WAL record; the index itself is rebuilt
6152    /// from scratch on re-open (no snapshot format changes).
6153    ///
6154    /// # Errors
6155    /// - [`GraphError::ReadOnly`]: called on an as-of instance.
6156    /// - [`GraphError::RuleInvalid`]: `(label, field)` is already indexed.
6157    pub fn enable_fulltext(&mut self, label: &str, field: &str) -> Result<()> {
6158        if self.read_only {
6159            return Err(GraphError::ReadOnly);
6160        }
6161        if self.fulltext.is_enabled(label, field) {
6162            return Err(GraphError::RuleInvalid {
6163                detail: format!("full-text index for ({label:?}, {field:?}) already enabled"),
6164            });
6165        }
6166        self.log_then_apply(WalRecord::EnableFulltext {
6167            label: label.into(),
6168            field: field.into(),
6169        })
6170    }
6171
6172    /// Disable full-text indexing for `(label, field)` and drop its postings.
6173    ///
6174    /// # Errors
6175    /// - [`GraphError::ReadOnly`]: called on an as-of instance.
6176    /// - [`GraphError::RuleNotFound`]: `(label, field)` is not currently indexed.
6177    pub fn disable_fulltext(&mut self, label: &str, field: &str) -> Result<()> {
6178        if self.read_only {
6179            return Err(GraphError::ReadOnly);
6180        }
6181        if !self.fulltext.is_enabled(label, field) {
6182            return Err(GraphError::RuleNotFound {
6183                name: format!("fulltext({label},{field})"),
6184            });
6185        }
6186        self.log_then_apply(WalRecord::DisableFulltext {
6187            label: label.into(),
6188            field: field.into(),
6189        })
6190    }
6191
6192    /// Whether `(label, field)` is currently indexed for full-text search.
6193    pub fn is_fulltext_enabled(&self, label: &str, field: &str) -> bool {
6194        self.fulltext.is_enabled(label, field)
6195    }
6196
6197    /// Every `(label, field)` pair with a live full-text index, sorted.
6198    ///
6199    /// Note that [`GraphDb::search`] is keyed by field alone — a pair only
6200    /// declares which nodes are *indexed*, so callers that want to search
6201    /// everything indexed should query each distinct field once.
6202    pub fn fulltext_pairs(&self) -> Vec<(String, String)> {
6203        let mut v: Vec<(String, String)> = self.fulltext.enabled_pairs().cloned().collect();
6204        v.sort();
6205        v
6206    }
6207
6208    /// Enable an equality index for all nodes of `label` on scalar property
6209    /// `field`. Subsequent `WHERE n.field = value` lookups become O(matches)
6210    /// instead of an O(N_label) scan. Existing nodes are backfilled; the
6211    /// declaration persists via WAL and the postings rebuild on re-open.
6212    ///
6213    /// # Errors
6214    /// - [`GraphError::ReadOnly`]: called on an as-of instance.
6215    /// - [`GraphError::RuleInvalid`]: `(label, field)` is already indexed.
6216    pub fn enable_index(&mut self, label: &str, field: &str) -> Result<()> {
6217        if self.read_only {
6218            return Err(GraphError::ReadOnly);
6219        }
6220        if self.prop_index.is_enabled(label, field) {
6221            return Err(GraphError::RuleInvalid {
6222                detail: format!("property index for ({label:?}, {field:?}) already enabled"),
6223            });
6224        }
6225        self.log_then_apply(WalRecord::EnableIndex {
6226            label: label.into(),
6227            field: field.into(),
6228        })
6229    }
6230
6231    /// Disable the equality index for `(label, field)` and drop its postings.
6232    ///
6233    /// # Errors
6234    /// - [`GraphError::ReadOnly`]: called on an as-of instance.
6235    /// - [`GraphError::RuleNotFound`]: `(label, field)` is not currently indexed.
6236    pub fn disable_index(&mut self, label: &str, field: &str) -> Result<()> {
6237        if self.read_only {
6238            return Err(GraphError::ReadOnly);
6239        }
6240        if !self.prop_index.is_enabled(label, field) {
6241            return Err(GraphError::RuleNotFound {
6242                name: format!("index({label},{field})"),
6243            });
6244        }
6245        self.log_then_apply(WalRecord::DisableIndex {
6246            label: label.into(),
6247            field: field.into(),
6248        })
6249    }
6250
6251    /// Whether `(label, field)` currently has an equality index.
6252    pub fn is_index_enabled(&self, label: &str, field: &str) -> bool {
6253        self.prop_index.is_enabled(label, field)
6254    }
6255
6256    /// Search a full-text-indexed field.
6257    ///
6258    /// Returns `(node_key, match_count)` pairs sorted by match_count descending,
6259    /// ties broken by key (lexicographic).  Tombstoned nodes are excluded.
6260    ///
6261    /// **Query syntax:**
6262    /// - Space-separated terms are AND'd: `"foo bar"` requires both.
6263    /// - `OR` between terms forms disjunction: `"foo OR bar"` matches either.
6264    /// - Trailing `*` on a term is a prefix match: `"rust*"` matches `rustlang`, `rusty`.
6265    /// - `AND` keyword is accepted explicitly and is the default.
6266    /// - Tokenization is unicode-alphanumeric (same as index time); case-insensitive.
6267    ///
6268    /// **Unindexed field:** returns `Ok(vec![])` if `field` is not indexed.
6269    /// Pin: this is the documented, tested, stable behavior for v1.
6270    ///
6271    /// **Memory / performance:** O(postings) lookup; no scan.  The index is
6272    /// in-memory and proportional to total indexed text across all enabled fields.
6273    ///
6274    /// **v2 grammar:** supports `"phrase"`, `-negation`, `prefix*`, `OR`, `AND`.
6275    /// Results are BM25-scored (k1=1.2, b=0.75) and sorted by score descending,
6276    /// key ascending for deterministic tiebreaking.
6277    pub fn search(&self, field: &str, query: &str) -> Vec<(String, f64)> {
6278        // Resolve node_ids to keys (excluding tombstones) then re-sort by
6279        // (score DESC, key ASC) to give a deterministic, key-lexicographic
6280        // tiebreak.  FulltextIndex::search sorts by (score DESC, node_id ASC)
6281        // which diverges from key order when nodes were not inserted in key-lex order.
6282        let mut results: Vec<(String, f64)> = self
6283            .fulltext
6284            .search(field, query, 0)
6285            .into_iter()
6286            .filter_map(|(id, score)| self.ids.key_of(id).map(|key| (key.to_string(), score)))
6287            .collect();
6288        results.sort_by(|a, b| {
6289            b.1.partial_cmp(&a.1)
6290                .unwrap_or(std::cmp::Ordering::Equal)
6291                .then(a.0.cmp(&b.0))
6292        });
6293        results
6294    }
6295
6296    /// [`search`](Self::search), stopping at the `k` best hits.
6297    ///
6298    /// Same ranking and the same deterministic tiebreak, but the index drops
6299    /// everything past `k` before any key is resolved, so a caller that wants
6300    /// the top few out of a field that matched thousands does not pay to
6301    /// materialise and re-sort the tail. `k == 0` means no limit, exactly as
6302    /// [`search`](Self::search) behaves.
6303    ///
6304    /// The BM25 scoring itself is not bounded by `k` — every candidate is
6305    /// scored either way — so this trims the resolve and the sort, not the
6306    /// search.
6307    pub fn search_top(&self, field: &str, query: &str, k: usize) -> Vec<(String, f64)> {
6308        // A tombstoned id resolves to nothing, so asking the index for exactly
6309        // `k` could return fewer. Over-fetching a little and truncating after
6310        // the filter keeps the count right without unbounding the call.
6311        let want = if k == 0 { 0 } else { k.saturating_mul(2) };
6312        let mut results: Vec<(String, f64)> = self
6313            .fulltext
6314            .search(field, query, want)
6315            .into_iter()
6316            .filter_map(|(id, score)| self.ids.key_of(id).map(|key| (key.to_string(), score)))
6317            .collect();
6318        results.sort_by(|a, b| {
6319            b.1.partial_cmp(&a.1)
6320                .unwrap_or(std::cmp::Ordering::Equal)
6321                .then(a.0.cmp(&b.0))
6322        });
6323        if k > 0 {
6324            results.truncate(k);
6325        }
6326        results
6327    }
6328
6329    /// Hybrid search: Reciprocal Rank Fusion (RRF) over fulltext + vector results.
6330    ///
6331    /// Takes up to `4*k` fulltext hits for `(text_field, query_text)` and up to
6332    /// `4*k` vector hits for `(vector_field, query_vec, min=0.0)`, then fuses
6333    /// them with RRF using a fixed constant of 60.
6334    ///
6335    /// ```text
6336    /// score(d) = Σ  1 / (60 + rank_i(d))    (rank 1-based per list)
6337    /// ```
6338    ///
6339    /// Returns the top `k` nodes by fused score, ties broken by node key
6340    /// ascending (deterministic).
6341    ///
6342    /// # Vector leg fallback
6343    ///
6344    /// When `query_vec` is empty the vector leg is skipped entirely and
6345    /// results are ranked by the text list alone through the same RRF path
6346    /// (each text result scores `1/(60 + rank)` from that single list).
6347    ///
6348    /// When `label` is `None`, the vector leg **always** returns empty results.
6349    /// Internally `label` is mapped to `""`, which does not match any rule-created
6350    /// HNSW index (all such indexes are keyed to a specific non-empty label), and
6351    /// the brute-force fallback finds no nodes with an empty label.  The fused
6352    /// ranking is therefore text-only in this case.
6353    pub fn search_hybrid(
6354        &self,
6355        text_field: &str,
6356        query_text: &str,
6357        vector_field: &str,
6358        query_vec: &[f64],
6359        label: Option<&str>,
6360        k: usize,
6361    ) -> Vec<(String, f64)> {
6362        use std::collections::HashMap;
6363
6364        const RRF_K: f64 = 60.0;
6365        let pool = 4 * k;
6366
6367        // Accumulate per-node RRF scores.
6368        let mut scores: HashMap<String, f64> = HashMap::new();
6369
6370        // Text leg.
6371        let text_hits = self.search(text_field, query_text);
6372        for (rank0, (key, _count)) in text_hits.into_iter().take(pool).enumerate() {
6373            let rank = (rank0 + 1) as f64;
6374            *scores.entry(key).or_insert(0.0) += 1.0 / (RRF_K + rank);
6375        }
6376
6377        // Vector leg (skipped when query_vec is empty).
6378        if !query_vec.is_empty() {
6379            let vec_hits = self.find_similar_vector(vector_field, label, query_vec, pool, 0.0);
6380            for (rank0, (key, _sim)) in vec_hits.into_iter().enumerate() {
6381                let rank = (rank0 + 1) as f64;
6382                *scores.entry(key).or_insert(0.0) += 1.0 / (RRF_K + rank);
6383            }
6384        }
6385
6386        // Sort: score DESC, then key ASC for deterministic tie-breaking.
6387        let mut ranked: Vec<(String, f64)> = scores.into_iter().collect();
6388        ranked.sort_by(|a, b| {
6389            b.1.partial_cmp(&a.1)
6390                .unwrap_or(std::cmp::Ordering::Equal)
6391                .then(a.0.cmp(&b.0))
6392        });
6393        ranked.truncate(k);
6394        ranked
6395    }
6396
6397    /// For DST/testing: scratch BM25 search over live nodes without the index.
6398    /// Walks every live node, re-stems field tokens, computes corpus stats, and
6399    /// returns BM25-ranked results.
6400    ///
6401    /// The oracle: the ordered key list of `search(field, q)` must equal that of
6402    /// `scratch_search(field, q)` at every quiescent state.
6403    #[doc(hidden)]
6404    pub fn scratch_search(&self, field: &str, query: &str) -> Vec<(String, f64)> {
6405        use core_storage::fulltext::{parse_query, value_tokens_stemmed_with_positions};
6406        use std::collections::BTreeMap;
6407
6408        let groups = parse_query(query);
6409        if groups.is_empty() {
6410            return vec![];
6411        }
6412
6413        // --- Pass 1: collect all live indexed nodes with stemmed token data ---
6414        struct NodeData {
6415            key: String,
6416            /// stemmed_token → positions (sorted)
6417            tokens: BTreeMap<String, Vec<u32>>,
6418            dl: u32,
6419        }
6420
6421        let mut nodes: Vec<NodeData> = Vec::new();
6422        for id in 0..self.ids.len() as u32 {
6423            let Some(key) = self.ids.key_of(id) else {
6424                continue;
6425            };
6426            let Some(&sym) = self.labels.get(id as usize) else {
6427                continue;
6428            };
6429            if sym == u32::MAX {
6430                continue;
6431            }
6432            let label = match self.syms.resolve(sym) {
6433                Some(l) => l,
6434                None => continue,
6435            };
6436            if !self.fulltext.is_enabled(label, field) {
6437                continue;
6438            }
6439            let Some(value) = self.props_view().get(id, field).map(|vr| vr.into_value()) else {
6440                continue;
6441            };
6442            // Use value_tokens_stemmed_with_positions so list elements are
6443            // separated by POSITION_GAP — identical to the index path, which
6444            // prevents phrase queries from matching across element boundaries.
6445            let stemmed_with_pos = match &value {
6446                Value::Str(_) | Value::List(_) => value_tokens_stemmed_with_positions(&value),
6447                _ => continue,
6448            };
6449            let dl = stemmed_with_pos.len() as u32;
6450            let mut tok_map: BTreeMap<String, Vec<u32>> = BTreeMap::new();
6451            for (tok, pos) in stemmed_with_pos {
6452                tok_map.entry(tok).or_default().push(pos);
6453            }
6454            nodes.push(NodeData {
6455                key: key.to_string(),
6456                tokens: tok_map,
6457                dl,
6458            });
6459        }
6460
6461        if nodes.is_empty() {
6462            return vec![];
6463        }
6464
6465        // --- BM25 corpus stats ---
6466        let n = nodes.len() as f64;
6467        let avg_dl: f64 = nodes.iter().map(|nd| nd.dl as f64).sum::<f64>() / n;
6468        // df per stemmed token across all live indexed nodes.
6469        let mut df_map: BTreeMap<&str, f64> = BTreeMap::new();
6470        for nd in &nodes {
6471            for tok in nd.tokens.keys() {
6472                *df_map.entry(tok.as_str()).or_insert(0.0) += 1.0;
6473            }
6474        }
6475
6476        const K1: f64 = 1.2;
6477        const B: f64 = 0.75;
6478
6479        // --- Pass 2: score each node against each OR-group ---
6480        let mut results: Vec<(String, f64)> = Vec::new();
6481        for nd in &nodes {
6482            let dl = nd.dl as f64;
6483            let mut total_score = 0.0f64;
6484
6485            'group: for group in &groups {
6486                let mut group_score = 0.0f64;
6487
6488                for term in group {
6489                    if term.negated {
6490                        // Negated: if doc has this stemmed token → group fails.
6491                        let present = if term.prefix {
6492                            nd.tokens.keys().any(|t| t.starts_with(term.token.as_str()))
6493                        } else {
6494                            nd.tokens.contains_key(term.token.as_str())
6495                        };
6496                        if present {
6497                            continue 'group;
6498                        }
6499                        continue;
6500                    }
6501                    if term.prefix {
6502                        // Prefix: sum BM25 for all matching stemmed tokens.
6503                        let mut prefix_matched = false;
6504                        for (tok, positions) in &nd.tokens {
6505                            if tok.starts_with(term.token.as_str()) {
6506                                let tf = positions.len() as f64;
6507                                let df = df_map.get(tok.as_str()).copied().unwrap_or(1.0);
6508                                let idf = ((n - df + 0.5) / (df + 0.5) + 1.0).ln();
6509                                let tf_norm =
6510                                    tf * (K1 + 1.0) / (tf + K1 * (1.0 - B + B * dl / avg_dl));
6511                                group_score += idf * tf_norm;
6512                                prefix_matched = true;
6513                            }
6514                        }
6515                        if !prefix_matched {
6516                            continue 'group;
6517                        }
6518                    } else {
6519                        // term.token is already stemmed by parse_query; use directly.
6520                        match nd.tokens.get(term.token.as_str()) {
6521                            None => continue 'group,
6522                            Some(positions) => {
6523                                let tf = positions.len() as f64;
6524                                let df = df_map.get(term.token.as_str()).copied().unwrap_or(1.0);
6525                                let idf = ((n - df + 0.5) / (df + 0.5) + 1.0).ln();
6526                                let tf_norm =
6527                                    tf * (K1 + 1.0) / (tf + K1 * (1.0 - B + B * dl / avg_dl));
6528                                group_score += idf * tf_norm;
6529                            }
6530                        }
6531                    }
6532                }
6533
6534                if group_score > 0.0 {
6535                    total_score += group_score;
6536                }
6537            }
6538
6539            if total_score > 0.0 {
6540                results.push((nd.key.clone(), total_score));
6541            }
6542        }
6543
6544        results.sort_by(|a, b| {
6545            b.1.partial_cmp(&a.1)
6546                .unwrap_or(std::cmp::Ordering::Equal)
6547                .then(a.0.cmp(&b.0))
6548        });
6549        results
6550    }
6551
6552    /// Return the current view-maintained value of `view_prop` for node `key`.
6553    /// Equivalent to `get_prop` but documents that it reads a view-managed column.
6554    pub fn get_view_prop(&self, key: &str, view_prop: &str) -> Option<Value> {
6555        let id = self.ids.get(key)?;
6556        self.props_view()
6557            .get(id, view_prop)
6558            .map(|vr| vr.into_value())
6559    }
6560
6561    /// For testing / DST oracle: scratch recompute of a view value for one node.
6562    ///
6563    /// Returns `None` if the node does not exist, the view does not exist, or
6564    /// the view has no result for the node (e.g. Avg with no qualifying neighbors).
6565    #[doc(hidden)]
6566    pub fn scratch_view_value(&self, key: &str, view_name: &str) -> Option<Value> {
6567        let node = self.ids.get(key)?;
6568        let def = self.view_store.views().find(|v| v.name == view_name)?;
6569        // Use TopologyView so that NeighborAgg sees base + overlay edges
6570        // without materialising a temporary Topology (I1).
6571        let topo_view = self.topo_view();
6572        core_rules::views::compute_view_value(
6573            def,
6574            node,
6575            self.props_view(),
6576            &topo_view,
6577            &self.ids,
6578            &self.syms,
6579            &self.labels,
6580        )
6581    }
6582
6583    // -----------------------------------------------------------------------
6584    // Graph algorithm API
6585    // -----------------------------------------------------------------------
6586
6587    /// Run PageRank over the unified topology (manual + derived edges).
6588    ///
6589    /// Returns a [`PageRankReport`] with scores sorted descending (ties: key
6590    /// ascending).  Set `config.edge_type` to restrict to one edge type.
6591    /// `config.converged` is `true` only when the power iteration converged
6592    /// within `config.max_iters` and within any time budget.
6593    pub fn pagerank(&self, config: &crate::algo::PageRankConfig) -> crate::algo::PageRankReport {
6594        let topo = build_topo_view(&self.topo, &self.base);
6595        let edge_props = self.edge_props_view();
6596        crate::algo::pagerank(
6597            &topo,
6598            &self.ids,
6599            &self.syms,
6600            &self.labels,
6601            &edge_props,
6602            config,
6603        )
6604    }
6605
6606    /// Weakly-connected components over the unified topology (treated as
6607    /// undirected regardless of how edges were inserted).
6608    ///
6609    /// Component IDs are the key of the smallest member in the component
6610    /// (deterministic).  Result sorted by (component_id, key).
6611    pub fn connected_components(&self, config: &crate::algo::WccConfig) -> crate::algo::WccReport {
6612        let topo = build_topo_view(&self.topo, &self.base);
6613        let edge_props = self.edge_props_view();
6614        crate::algo::wcc(
6615            &topo,
6616            &self.ids,
6617            &self.syms,
6618            &self.labels,
6619            &edge_props,
6620            config,
6621        )
6622    }
6623
6624    /// Degree centrality for every live node.
6625    ///
6626    /// `direction`: `AlgoDir::Out` = out-degree, `AlgoDir::In` = in-degree,
6627    /// `AlgoDir::Both` = out + in (total directed degree).
6628    ///
6629    /// For one-shot ranking use this; for a live property updated on every
6630    /// write, create a Degree materialized view instead (see `docs/site/algorithms.md`).
6631    pub fn degree_centrality(
6632        &self,
6633        config: &crate::algo::DegreeConfig,
6634    ) -> crate::algo::DegreeReport {
6635        let topo = build_topo_view(&self.topo, &self.base);
6636        let edge_props = self.edge_props_view();
6637        crate::algo::degree_centrality(
6638            &topo,
6639            &self.ids,
6640            &self.syms,
6641            &self.labels,
6642            &edge_props,
6643            config,
6644        )
6645    }
6646
6647    /// Louvain community detection over the unified topology (undirected).
6648    ///
6649    /// See [`crate::algo::LouvainConfig`] for edge-type/weight/label
6650    /// restriction and [`crate::algo::CommunityReport`] for the shape of the
6651    /// result (communities sorted size-desc, then smallest member key asc).
6652    pub fn communities(&self, config: &crate::algo::LouvainConfig) -> crate::algo::CommunityReport {
6653        let topo = build_topo_view(&self.topo, &self.base);
6654        let edge_props = self.edge_props_view();
6655        crate::algo::louvain(
6656            &topo,
6657            &self.ids,
6658            &self.syms,
6659            &self.labels,
6660            &edge_props,
6661            config,
6662        )
6663    }
6664
6665    /// Write a vector of `(node_key, score)` pairs as `prop_name` on each node,
6666    /// atomically via a single write-batch (one WAL frame, one fsync).
6667    ///
6668    /// # Errors
6669    /// - [`GraphError::ReadOnly`]: called on an as-of instance.
6670    /// - [`GraphError::RuleInvalid`]: `prop_name` is managed by an existing view
6671    ///   (collision check mirrors `create_view`).
6672    /// - [`GraphError::KeyNotFound`]: a key in `scores` does not exist as a live node.
6673    pub fn write_scores(&mut self, prop_name: &str, scores: &[(String, f64)]) -> Result<()> {
6674        if self.read_only {
6675            return Err(GraphError::ReadOnly);
6676        }
6677        // Collision check: refuse if prop_name is view-managed.
6678        if let Some(view_name) = self.view_store.view_for_prop(prop_name) {
6679            return Err(GraphError::RuleInvalid {
6680                detail: format!(
6681                    "prop {:?} is managed by view {:?} and cannot be written as scores",
6682                    prop_name, view_name
6683                ),
6684            });
6685        }
6686        // Refuse if prop_name is a view name itself (confusing namespace collision).
6687        if self.view_store.has_view(prop_name) {
6688            return Err(GraphError::RuleInvalid {
6689                detail: format!(
6690                    "prop_name {:?} collides with an existing view name",
6691                    prop_name
6692                ),
6693            });
6694        }
6695        // Write all scores in a single crash-atomic batch.
6696        self.write_batch(|b| {
6697            for (key, score) in scores {
6698                b.set_prop(key, prop_name, Value::Float(*score));
6699            }
6700        })?;
6701        Ok(())
6702    }
6703
6704    /// Return the value of `field` for the node with key `key`, or `None` if
6705    /// the node or field is absent.  Reads through the overlay-over-base
6706    /// `ColumnsView`, materialising base values on demand (zero heap cost for
6707    /// overlay hits; one clone per base hit).
6708    pub fn get_prop(&self, key: &str, field: &str) -> Option<Value> {
6709        let id = self.ids.get(key)?;
6710        self.props_view().get(id, field).map(|vr| vr.into_value())
6711    }
6712
6713    pub fn has_node(&self, key: &str) -> bool {
6714        self.ids.get(key).is_some()
6715    }
6716
6717    /// Borrow the raw id map. Used by `NodeMask::from_keys` to resolve keys.
6718    pub(crate) fn ids(&self) -> &IdMap {
6719        &self.ids
6720    }
6721
6722    // -----------------------------------------------------------------------
6723    // Namespaces
6724    // -----------------------------------------------------------------------
6725
6726    /// The index `name` already has in `ns_names`, if any.
6727    fn ns_index_of(&self, name: &str) -> Option<u32> {
6728        self.ns_names
6729            .iter()
6730            .position(|n| n == name)
6731            .map(|i| i as u32)
6732    }
6733
6734    /// The index for `name`, appending it to `ns_names` when it is new.
6735    ///
6736    /// The table holds one entry per distinct namespace in the store — a
6737    /// tenant count, not a node count — so the linear scan is cheaper than a
6738    /// map and keeps `namespaces()` allocation-free of a second index.
6739    fn ns_index_for(&mut self, name: &str) -> u32 {
6740        match self.ns_index_of(name) {
6741            Some(i) => i,
6742            None => {
6743                self.ns_names.push(name.to_string());
6744                (self.ns_names.len() - 1) as u32
6745            }
6746        }
6747    }
6748
6749    /// The namespace name at `idx`, or [`NS_DEFAULT`] for an index this handle
6750    /// does not know (unreachable; the default is the narrowing answer).
6751    fn ns_name(&self, idx: u32) -> &str {
6752        self.ns_names
6753            .get(idx as usize)
6754            .map(String::as_str)
6755            .unwrap_or(NS_DEFAULT)
6756    }
6757
6758    /// The namespace index of dense node `id`, defaulting for an id with no
6759    /// entry (a node inserted before this handle rebuilt the array cannot
6760    /// exist: every insert path maintains it).
6761    fn node_ns_idx(&self, id: u32) -> u32 {
6762        self.node_ns
6763            .get(id as usize)
6764            .copied()
6765            .unwrap_or(NS_DEFAULT_IDX)
6766    }
6767
6768    /// File node `id` under namespace `name`, growing `node_ns` as `labels`
6769    /// grows. Called from `apply` for every node insert, live and replayed.
6770    fn set_node_ns(&mut self, id: u32, name: &str) {
6771        let idx = if name == NS_DEFAULT {
6772            NS_DEFAULT_IDX
6773        } else {
6774            self.ns_index_for(name)
6775        };
6776        if self.node_ns.len() <= id as usize {
6777            self.node_ns.resize(id as usize + 1, NS_DEFAULT_IDX);
6778        }
6779        self.node_ns[id as usize] = idx;
6780    }
6781
6782    /// Rebuild `node_ns` from the `ns` column — one pass, at the end of an
6783    /// open or a reload, after the snapshot is restored and the WAL replayed.
6784    ///
6785    /// A store with no `ns` column reads nothing: the column-name check fails
6786    /// and the vector is filled with one constant.
6787    fn rebuild_node_ns(&mut self) {
6788        let total = self.ids.len();
6789        self.ns_names.truncate(1);
6790        self.node_ns.clear();
6791        self.node_ns.resize(total, NS_DEFAULT_IDX);
6792        let has_ns_column = {
6793            let cv = self.props_view();
6794            cv.field_names().iter().any(|f| f == NS_PROP)
6795        };
6796        if !has_ns_column {
6797            return;
6798        }
6799        // Collected first so the props view is released before `ns_index_for`
6800        // takes `&mut self`.
6801        let named: Vec<(u32, String)> = {
6802            let cv = self.props_view();
6803            (0..total as u32)
6804                .filter_map(|id| match cv.get(id, NS_PROP).map(|vr| vr.into_value()) {
6805                    Some(Value::Str(s)) if s != NS_DEFAULT => Some((id, s)),
6806                    _ => None,
6807                })
6808                .collect()
6809        };
6810        for (id, name) in named {
6811            let idx = self.ns_index_for(&name);
6812            self.node_ns[id as usize] = idx;
6813        }
6814    }
6815
6816    /// Every namespace with at least one live node, in name order.
6817    ///
6818    /// `["default"]` on any store that has never named a namespace, including
6819    /// an empty one: a store is always at least its default namespace.
6820    pub fn namespaces(&self) -> Vec<String> {
6821        let mut out: BTreeSet<&str> = BTreeSet::new();
6822        out.insert(NS_DEFAULT);
6823        for (id, &idx) in self.node_ns.iter().enumerate() {
6824            if idx == NS_DEFAULT_IDX || !self.is_live_node(id as u32) {
6825                continue;
6826            }
6827            out.insert(self.ns_name(idx));
6828        }
6829        out.into_iter().map(str::to_string).collect()
6830    }
6831
6832    /// The namespace of `key`, or `None` when the key names no live node.
6833    pub fn namespace_of(&self, key: &str) -> Option<String> {
6834        let id = self.ids.get(key)?;
6835        if !self.is_live_node(id) {
6836            return None;
6837        }
6838        Some(self.ns_name(self.node_ns_idx(id)).to_string())
6839    }
6840
6841    /// Every live node in `namespace`, as a visibility mask.
6842    ///
6843    /// Built off `node_ns` on whichever handle this is, so on a temporal handle
6844    /// it is the namespace's membership at that commit. A name no node uses
6845    /// gives an empty mask — a namespace scope never widens.
6846    pub fn mask_for_namespace(&self, namespace: &str) -> crate::mask::NodeMask {
6847        let Some(idx) = self.ns_index_of(namespace) else {
6848            return crate::mask::NodeMask::from_ids(std::collections::HashSet::new());
6849        };
6850        let visible: std::collections::HashSet<u32> = (0..self.ids.len() as u32)
6851            .filter(|&id| self.node_ns_idx(id) == idx && self.is_live_node(id))
6852            .collect();
6853        crate::mask::NodeMask::from_ids(visible)
6854    }
6855
6856    /// Live-node test used by the namespace accessors: a deleted node keeps its
6857    /// dense id and its `node_ns` slot, and the label sentinel is what marks it
6858    /// gone — the same test `mask_for_role`'s label leg applies implicitly.
6859    fn is_live_node(&self, id: u32) -> bool {
6860        self.labels
6861            .get(id as usize)
6862            .is_some_and(|&sym| sym != u32::MAX)
6863            && self.ids.key_of(id).is_some()
6864    }
6865
6866    /// Per-namespace live node counts for [`Stats`], in name order.
6867    fn namespace_stats(&self) -> Vec<NamespaceStats> {
6868        let mut counts: BTreeMap<&str, usize> = BTreeMap::new();
6869        counts.insert(NS_DEFAULT, 0);
6870        for id in 0..self.ids.len() as u32 {
6871            if !self.is_live_node(id) {
6872                continue;
6873            }
6874            *counts
6875                .entry(self.ns_name(self.node_ns_idx(id)))
6876                .or_insert(0) += 1;
6877        }
6878        counts
6879            .into_iter()
6880            .filter(|&(name, n)| n > 0 || name == NS_DEFAULT)
6881            .map(|(name, nodes_live)| NamespaceStats {
6882                name: name.to_string(),
6883                nodes_live,
6884            })
6885            .collect()
6886    }
6887
6888    /// The namespace a create-class op would put its node in: the `ns` entry of
6889    /// the props it carries, normalised, with absent meaning [`NS_DEFAULT`].
6890    fn created_namespace<'a>(key: &str, props: &'a [(String, Value)]) -> Result<&'a str> {
6891        Ok(namespace_of_value(Self::sole_ns_entry(key, props)?))
6892    }
6893
6894    /// The one `ns` entry in a node's props, or `None` when it carries none.
6895    ///
6896    /// A props list naming `ns` twice is refused. Without that refusal the
6897    /// write path and the authorisation path can read the same list
6898    /// differently — one taking the first entry, the other the last — and
6899    /// `CREATE (n:L {ns: 'mine', ns: 'theirs'})` lands a node in a namespace
6900    /// the role was checked against the other of. One entry is the only shape
6901    /// where "the node's namespace" is a single fact, so it is the only shape
6902    /// accepted, and every reader of it agrees by construction.
6903    fn sole_ns_entry<'a>(key: &str, props: &'a [(String, Value)]) -> Result<Option<&'a Value>> {
6904        let mut found: Option<&'a Value> = None;
6905        for (field, value) in props {
6906            if field != NS_PROP {
6907                continue;
6908            }
6909            if found.is_some() {
6910                return Err(GraphError::RuleInvalid {
6911                    detail: format!(
6912                        "node {key}: {NS_PROP} is given more than once; a node has exactly \
6913                         one namespace"
6914                    ),
6915                });
6916            }
6917            found = Some(value);
6918        }
6919        Ok(found)
6920    }
6921
6922    /// The definition of the role a write authorisation names.
6923    ///
6924    /// `None` when `roles.json` was corrupt at open or the role has since been
6925    /// removed — neither can reach a write, because the authorisation carries a
6926    /// mask `mask_for_role` already resolved for that name.
6927    fn role_def_for(&self, role: &str) -> Option<&RoleDef> {
6928        self.roles.as_ref()?.iter().find(|r| r.name == role)
6929    }
6930
6931    /// Validate the `ns` entry of a node's props and drop an explicit default.
6932    ///
6933    /// Runs on the write path only (see `rewrite_wal_dense`), never on replay:
6934    /// a record that reached the WAL was already accepted here.
6935    fn normalise_insert_ns(
6936        key: &str,
6937        props: Vec<(String, Value)>,
6938    ) -> Result<(Vec<(String, Value)>, String)> {
6939        // One `ns` or none: this is where that is enforced, so every later
6940        // reader of the list — the authorisation gate, the two `apply` arms,
6941        // `node_ns` — is looking at a single entry and cannot disagree about
6942        // which one counts.
6943        Self::sole_ns_entry(key, &props)?;
6944        let mut name = NS_DEFAULT.to_string();
6945        let mut out = Vec::with_capacity(props.len());
6946        for (field, value) in props {
6947            if field != NS_PROP {
6948                out.push((field, value));
6949                continue;
6950            }
6951            let Value::Str(ref s) = value else {
6952                return Err(GraphError::RuleInvalid {
6953                    detail: format!(
6954                        "node {key}: {NS_PROP} must be a string naming a namespace, \
6955                         got {value:?}"
6956                    ),
6957                });
6958            };
6959            if !valid_namespace(s) {
6960                return Err(GraphError::RuleInvalid {
6961                    detail: format!(
6962                        "node {key}: {s:?} is not a valid namespace name — 1 to {NS_MAX_LEN} \
6963                         characters of [A-Za-z0-9_.-]"
6964                    ),
6965                });
6966            }
6967            name = s.clone();
6968            // An explicit default stores nothing, so a single-tenant store
6969            // never grows an `ns` column.
6970            if name != NS_DEFAULT {
6971                out.push((field, value));
6972            }
6973        }
6974        Ok((out, name))
6975    }
6976
6977    // -----------------------------------------------------------------------
6978    // RBAC role resolution
6979    // -----------------------------------------------------------------------
6980
6981    /// Parse `roles.json` bytes from `fs`.
6982    ///
6983    /// Return values:
6984    ///   `Ok(Some(roles))` — file absent (returns `vec![]`) **or** file present
6985    ///                       and valid; in both cases `mask_for_role` uses the
6986    ///                       list normally (an absent file means no roles defined).
6987    ///   `Ok(None)`        — file present but corrupt or unrecognised version
6988    ///                       → poisoned state; `mask_for_role` returns `Err` for
6989    ///                       any role name until the file is fixed and the DB
6990    ///                       re-opened (or `apply_schema` is called to repair it).
6991    ///
6992    /// Note: `None` signals corruption, not absence — the opposite of what an
6993    /// optional "file missing" convention would suggest.  The open path stores
6994    /// this result on `db.roles` directly.
6995    fn load_roles_from_fs(fs: &F) -> Result<Option<Vec<RoleDef>>> {
6996        let bytes = fs.read(FileId::Roles).map_err(GraphError::Io)?;
6997        if bytes.is_empty() {
6998            // Empty bytes means either the file is absent or zero-byte — both
6999            // are treated identically as "no roles defined".  A zero-byte
7000            // roles.json does NOT widen access: an absent file and a zero-byte
7001            // file both resolve to an empty role list (sees nothing by default).
7002            return Ok(Some(vec![]));
7003        }
7004        match serde_json::from_slice::<RolesFile>(&bytes) {
7005            Ok(f) if matches!(f.version, 1..=4) => Ok(Some(f.roles)),
7006            // Corrupt or unrecognised version (>4): poison the roles state.
7007            // Never widen: a version this binary does not know may carry a
7008            // narrowing this binary would not apply.
7009            _ => Ok(None),
7010        }
7011    }
7012
7013    /// Resolve a role to a node-visibility mask against the current graph state.
7014    ///
7015    /// Returns `Err` when:
7016    /// - `roles.json` was present but corrupt at open (poisoned state), or
7017    /// - `role` does not match any defined role name.
7018    ///
7019    /// The mask union is: explicit `keys` (unknown keys silently ignored) plus
7020    /// all live nodes carrying any label in `labels` that also pass the role's
7021    /// [`visible_where`](crate::roles::RoleDef::visible_where) predicate, if it
7022    /// has one.  Label resolution is live — new nodes of an allowed label are
7023    /// visible without re-applying the schema, and a property edited out of the
7024    /// predicate takes its node out of the mask on the next read.  An empty
7025    /// union = empty mask = sees nothing.
7026    ///
7027    /// This is the one resolver every read path calls, live and as-of alike, so
7028    /// the predicate applies everywhere at once.  On an as-of handle the role
7029    /// *definition* is the current one and the graph is the historical one: the
7030    /// predicate is evaluated against the property values at the commit being
7031    /// read.
7032    ///
7033    /// The result is memoised per `(role, commit_seq)`, so a scoped reader
7034    /// between two writes resolves the role once.  See
7035    /// [`RoleMaskCache`](crate::mask::RoleMaskCache) for why that cannot go
7036    /// stale.
7037    pub fn mask_for_role(&self, role: &str) -> Result<crate::mask::NodeMask> {
7038        self.role_masks
7039            .get_or_build(role, self.commit_seq, || self.build_mask_for_role(role))
7040            .map(|m| (*m).clone())
7041    }
7042
7043    /// The mask an [`AsOfScope`] names, resolved against this handle.
7044    ///
7045    /// Shared by [`GraphDb::query_at_scoped`] and
7046    /// [`GraphDb::query_at_scoped_in_namespace`] so one scope resolves one way
7047    /// however the namespace leg is added.
7048    fn mask_at_scope(&self, scope: AsOfScope<'_>) -> Result<crate::mask::NodeMask> {
7049        // One resolver answers "what may this role see" — `mask_for_role` — and
7050        // it runs against this handle, so on a temporal one the answer is the
7051        // as-of one.
7052        Ok(match scope {
7053            AsOfScope::Role(role) => self.mask_for_role(role)?,
7054            AsOfScope::Keys(keys) => {
7055                crate::mask::NodeMask::from_keys(self, keys.iter().map(String::as_str))
7056            }
7057            AsOfScope::RoleAndKeys(role, keys) => {
7058                self.mask_for_role(role)?
7059                    .intersect(&crate::mask::NodeMask::from_keys(
7060                        self,
7061                        keys.iter().map(String::as_str),
7062                    ))
7063            }
7064            AsOfScope::Namespace(namespace) => self.mask_for_namespace(namespace),
7065        })
7066    }
7067
7068    /// Resolve `role` against the current graph, ignoring the memo.
7069    fn build_mask_for_role(&self, role: &str) -> Result<crate::mask::NodeMask> {
7070        let roles = self.roles.as_ref().ok_or_else(|| GraphError::Corrupt {
7071            detail:
7072                "roles.json was corrupt at open; fix the file and re-open to restore role access"
7073                    .into(),
7074        })?;
7075        let def = roles
7076            .iter()
7077            .find(|r| r.name == role)
7078            .ok_or_else(|| GraphError::KeyNotFound {
7079                key: format!("role:{role}"),
7080            })?;
7081
7082        let mut visible = std::collections::HashSet::new();
7083
7084        // Key leg: resolve explicit keys to dense ids (unknown keys ignored).
7085        // An administrative grant, never narrowed by the predicate.
7086        for key in &def.keys {
7087            if let Some(id) = self.ids.get(key) {
7088                visible.insert(id);
7089            }
7090        }
7091
7092        // Label leg: live scan — iterate labels vec for matching symbol, and
7093        // when the role carries a predicate, test the property as well.  The
7094        // property comes from the store's own merged view (overlay over the
7095        // mmap'd base), so an as-of handle reads the values of its own commit.
7096        let props = def.visible_where.as_ref().map(|_| self.props_view());
7097        for label_name in &def.labels {
7098            if let Some(sym) = self.syms.get(label_name) {
7099                for (i, &s) in self.labels.iter().enumerate() {
7100                    if s != sym {
7101                        continue;
7102                    }
7103                    let id = i as u32;
7104                    match (&def.visible_where, &props) {
7105                        (Some(pred), Some(view)) => {
7106                            let value = view.get(id, &pred.field).map(|vr| vr.into_value());
7107                            if pred.holds(value.as_ref()) {
7108                                visible.insert(id);
7109                            }
7110                        }
7111                        _ => {
7112                            visible.insert(id);
7113                        }
7114                    }
7115                }
7116            }
7117        }
7118
7119        // Namespace leg: an intersection over the whole union, the key leg
7120        // included. A namespace is a tenancy boundary, so a key naming a node in
7121        // another tenant's namespace is not an administrative grant — and
7122        // `apply_schema` has already refused that role, so this only has to be
7123        // right about the node that moved into existence afterwards.
7124        if def.namespaces.is_some() {
7125            visible.retain(|&id| def.sees_namespace(self.ns_name(self.node_ns_idx(id))));
7126        }
7127
7128        Ok(crate::mask::NodeMask::from_ids(visible))
7129    }
7130
7131    /// Return the current list of role definitions.
7132    ///
7133    /// Returns an empty list when no roles are defined or when `roles.json`
7134    /// was corrupt at open (check [`mask_for_role`](Self::mask_for_role) for
7135    /// the fail-loud error in that case).
7136    pub fn roles(&self) -> Vec<RoleDef> {
7137        self.roles.as_deref().unwrap_or(&[]).to_vec()
7138    }
7139
7140    // ── Role-scoped write authz ───────────────────────────────────────────────
7141
7142    /// Execute `ops` with optional role-scoped write authorization.
7143    ///
7144    /// - `None` → full authority, identical to [`write_batch`](Self::write_batch)
7145    ///   (zero-cost bypass of all authz checks).
7146    /// - `Some(authz)` → the decision table is evaluated per-op BEFORE any WAL
7147    ///   record is built.  A denial returns an error with no WAL frame written
7148    ///   (all-or-nothing at the authz boundary, then at the MutPreview boundary).
7149    ///
7150    /// See the plan's "authz decision table" section for the full semantics.
7151    pub fn write_batch_authz(
7152        &mut self,
7153        authz: Option<&WriteAuthz>,
7154        ops: Vec<BatchOp>,
7155    ) -> Result<(usize, usize)> {
7156        // Thread authz as a direct parameter — never touches pending_write_authz.
7157        self.commit_logged_batch(ops, None, authz.cloned())
7158    }
7159
7160    /// Execute a Cypher write statement with role-scoped write authorization.
7161    ///
7162    /// Resolves scope + mask from `self.roles` inside the call (same write-guard
7163    /// lifetime as execution, satisfying §5 lock discipline).  The resolved
7164    /// `WriteAuthz` is stored as `pending_write_authz` for the duration of the
7165    /// call so that all inner `batch.commit()` calls are authz-checked.
7166    ///
7167    /// MERGE is handled specially: the MERGE scope precondition (§3.3) is
7168    /// checked in `exec_merge` BEFORE `has_node` to close the §6.2
7169    /// timing-oracle item (hidden ≡ absent for unscoped roles).
7170    ///
7171    /// Roles with `write: None` (v1 behavior) → `RoleWriteDenied` with
7172    /// "this endpoint is not permitted".
7173    pub fn query_write_authz(
7174        &mut self,
7175        role: &str,
7176        cypher: &str,
7177        params: &BTreeMap<String, Value>,
7178    ) -> Result<ResultSet> {
7179        // Resolve scope (fails fast if role has no write scope).
7180        // write:None → byte-identical v1 blanket-403 body (plan §v1-sidecar mandate).
7181        let scope =
7182            {
7183                let roles = self.roles.as_deref().ok_or_else(|| GraphError::Corrupt {
7184                    detail: "roles.json was corrupt at open; re-open to restore role access".into(),
7185                })?;
7186                let def = roles.iter().find(|r| r.name == role).ok_or_else(|| {
7187                    GraphError::KeyNotFound {
7188                        key: format!("role:{role}"),
7189                    }
7190                })?;
7191                def.write
7192                    .clone()
7193                    .ok_or_else(|| GraphError::RoleWriteDenied {
7194                        reason: "role-bound token: writes are not permitted".into(),
7195                    })?
7196            };
7197        // Resolve mask inside the call (same guard, §5 coherence).
7198        let mask = self.mask_for_role(role)?;
7199        self.pending_write_authz = Some(WriteAuthz {
7200            role: role.into(),
7201            scope,
7202            mask,
7203        });
7204        // RAII guard: always clears pending_write_authz on scope exit, including
7205        // on panic or early-return, mirroring the RestoreEmitDeltas precedent.
7206        struct ClearPendingAuthzOnDrop(*mut Option<WriteAuthz>);
7207        impl Drop for ClearPendingAuthzOnDrop {
7208            fn drop(&mut self) {
7209                // SAFETY: pointer into the owning GraphDb; guard is dropped
7210                // within this function's frame before it returns.
7211                unsafe { *self.0 = None };
7212            }
7213        }
7214        // SAFETY: raw pointer into self; guard dropped before this fn returns.
7215        let _authz_guard = ClearPendingAuthzOnDrop(&mut self.pending_write_authz as *mut _);
7216        let tokens = lex(cypher).map_err(|e| GraphError::QueryError {
7217            detail: format!("lex: {e}"),
7218        })?;
7219        let stmt = parse_write(&tokens).map_err(|e| GraphError::QueryError {
7220            detail: format!("parse: {e}"),
7221        })?;
7222        self.exec_write_stmt(stmt, params)
7223    }
7224
7225    /// Execute `ops` with optional role-scoped write authorization, suppressing
7226    /// fsync (for use inside the group-commit drain thread, which performs one
7227    /// group fsync after releasing the write lock).
7228    ///
7229    /// Identical to [`write_batch_authz`] except the fsync policy is temporarily
7230    /// forced to `Relaxed` for the duration of the call, matching the drain-thread
7231    /// contract established by [`commit_batch_nosync`].
7232    pub(crate) fn write_batch_authz_nosync(
7233        &mut self,
7234        authz: Option<&WriteAuthz>,
7235        ops: Vec<BatchOp>,
7236    ) -> Result<(usize, usize)> {
7237        let saved = self.fsync;
7238        struct RestoreFsync(*mut FsyncPolicy, FsyncPolicy);
7239        impl Drop for RestoreFsync {
7240            fn drop(&mut self) {
7241                // SAFETY: pointer into the owning GraphDb; guard is dropped
7242                // within the enclosing function's frame before it returns.
7243                unsafe { *self.0 = self.1 };
7244            }
7245        }
7246        // SAFETY: raw pointer into self; guard dropped before this fn returns.
7247        let _g = RestoreFsync(&mut self.fsync as *mut FsyncPolicy, saved);
7248        self.fsync = FsyncPolicy::Relaxed;
7249        self.commit_logged_batch(ops, None, authz.cloned())
7250    }
7251
7252    /// Execute a `/ingest` request with role-scoped write authorization.
7253    ///
7254    /// Resolves the role's `WriteScope` and `NodeMask` inside this call (same
7255    /// write-guard lifetime as the mutation, satisfying §5 lock discipline).
7256    /// Sets `pending_write_authz` for the duration of the call so that the
7257    /// `commit_ingest` → `commit_logged_batch` path picks up the authz context
7258    /// and evaluates the decision table per-op before any WAL write.
7259    ///
7260    /// §7.3: roles with empty `create_labels` will see every `InsertNode` op
7261    /// denied by the decision table with the appropriate §4.3 scope reason;
7262    /// no special HTTP-layer check is needed.
7263    ///
7264    /// Roles with `write: None` return `RoleWriteDenied` with
7265    /// "writes are not permitted" (byte-identical to v1 blanket 403).
7266    pub fn ingest_with_edges_authz(
7267        &mut self,
7268        role: &str,
7269        label: &str,
7270        rows: Vec<std::collections::BTreeMap<String, Value>>,
7271        opts: &crate::ingest::IngestOptions,
7272        edges: &[(String, String, String)],
7273    ) -> Result<crate::ingest::IngestReport> {
7274        // Resolve scope (fails fast if role has no write scope).
7275        // write:None → byte-identical v1 blanket-403 body (plan §v1-sidecar mandate).
7276        let scope =
7277            {
7278                let roles = self.roles.as_deref().ok_or_else(|| GraphError::Corrupt {
7279                    detail: "roles.json was corrupt at open; re-open to restore role access".into(),
7280                })?;
7281                let def = roles.iter().find(|r| r.name == role).ok_or_else(|| {
7282                    GraphError::KeyNotFound {
7283                        key: format!("role:{role}"),
7284                    }
7285                })?;
7286                def.write
7287                    .clone()
7288                    .ok_or_else(|| GraphError::RoleWriteDenied {
7289                        reason: "role-bound token: writes are not permitted".into(),
7290                    })?
7291            };
7292        let mask = self.mask_for_role(role)?;
7293        self.pending_write_authz = Some(WriteAuthz {
7294            role: role.into(),
7295            scope,
7296            mask,
7297        });
7298        // RAII guard: always clears pending_write_authz on scope exit, including
7299        // on panic or early-return, mirroring the RestoreEmitDeltas precedent.
7300        struct ClearPendingAuthzOnDrop(*mut Option<WriteAuthz>);
7301        impl Drop for ClearPendingAuthzOnDrop {
7302            fn drop(&mut self) {
7303                // SAFETY: pointer into the owning GraphDb; guard is dropped
7304                // within this function's frame before it returns.
7305                unsafe { *self.0 = None };
7306            }
7307        }
7308        // SAFETY: raw pointer into self; guard dropped before this fn returns.
7309        let _authz_guard = ClearPendingAuthzOnDrop(&mut self.pending_write_authz as *mut _);
7310        self.ingest_with_edges(label, rows, opts, edges)
7311    }
7312
7313    /// Evaluate the write-authz decision table for one `BatchOp`.
7314    ///
7315    /// Called by `commit_logged_batch` for each op when `pending_write_authz`
7316    /// is `Some`, BEFORE MutPreview.  A denial returns an error immediately;
7317    /// the remaining ops are not evaluated and no WAL frame is written.
7318    ///
7319    /// `batch_created` carries the key→label pairs of nodes that earlier ops in
7320    /// THIS batch will create.  Used by `InsertEdgeUpsert` to count same-batch
7321    /// placeholder nodes as visible (spec: "a placeholder endpoint the SAME
7322    /// batch creates counts as visible if its label passed the create-class gate").
7323    fn check_single_op_authz(
7324        &self,
7325        authz: &WriteAuthz,
7326        op: &BatchOp,
7327        batch_created: &BTreeMap<String, String>,
7328    ) -> Result<()> {
7329        // Helper: 3-way node status under the authz mask.
7330        //
7331        // Batch-created nodes (from earlier InsertNode in THIS batch) are treated
7332        // as Visible with their recorded label — their create gate already passed
7333        // and they are not yet in self.ids (not committed).  This fixes the
7334        // MERGE+ON CREATE SET case where InsertNode + SetProp arrive together:
7335        // the SetProp must not see the node as Absent.
7336        let node_status = |key: &str| -> NodeAuthzStatus {
7337            if let Some(label) = batch_created.get(key) {
7338                return NodeAuthzStatus::Visible(label.clone());
7339            }
7340            match self.ids.get(key) {
7341                None => NodeAuthzStatus::Absent,
7342                Some(id) if !authz.mask.contains_id(id) => NodeAuthzStatus::Hidden,
7343                Some(id) => {
7344                    let label = self
7345                        .labels
7346                        .get(id as usize)
7347                        .and_then(|&sym| {
7348                            if sym == u32::MAX {
7349                                None
7350                            } else {
7351                                self.syms.resolve(sym).map(str::to_string)
7352                            }
7353                        })
7354                        .unwrap_or_default();
7355                    NodeAuthzStatus::Visible(label)
7356                }
7357            }
7358        };
7359
7360        // Helper: is an InsertEdgeUpsert endpoint visible?
7361        // A same-batch placeholder counts as visible if its label passed
7362        // the create-class gate (spec "upsert placeholder-counts-as-visible").
7363        let upsert_ep_visible = |ep_key: &str, placeholder_label: &str| -> bool {
7364            // In store and visible?
7365            if let Some(id) = self.ids.get(ep_key) {
7366                return authz.mask.contains_id(id);
7367            }
7368            // Created by an earlier op in this batch?
7369            if let Some(created_label) = batch_created.get(ep_key) {
7370                return authz.scope.create_labels.contains(created_label);
7371            }
7372            // Will be created by THIS InsertEdgeUpsert: placeholder_label
7373            // must pass the create-class gate.
7374            authz
7375                .scope
7376                .create_labels
7377                .contains(&placeholder_label.to_string())
7378        };
7379
7380        match op {
7381            // RenameNode / CreateRule / DeleteRule: defense-in-depth gate.
7382            // These ops are never routed to role-scoped paths by the HTTP layer,
7383            // but we 403 them here to close any future bypass route.
7384            BatchOp::RenameNode { .. } | BatchOp::CreateRule(_) | BatchOp::DeleteRule { .. } => {
7385                return Err(GraphError::RoleWriteDenied {
7386                    reason: "role-bound token: this endpoint is not permitted".into(),
7387                });
7388            }
7389
7390            // ── CREATE-class: InsertNode ─────────────────────────────────────
7391            //
7392            // Decision table row 1 (scope-before-lookup): check label in
7393            // create_labels BEFORE any key lookup.  This is the structural
7394            // closure of the §6.2 timing-oracle item — the denial fires even
7395            // when the store is EMPTY (see test_create_scope_denied_empty_store).
7396            BatchOp::InsertNode { label, key, props } => {
7397                if !authz.scope.create_labels.contains(label) {
7398                    return Err(GraphError::RoleWriteDenied {
7399                        reason: format!(
7400                            "role-bound token: label '{}' not in write scope (create_labels)",
7401                            label
7402                        ),
7403                    });
7404                }
7405                // A role bound to namespaces may only create inside them. The
7406                // never-widen rule is about what a write makes visible to *any*
7407                // party, not only to the writer: a node this role could never
7408                // read back is a write into somebody else's tenancy. Also a
7409                // scope check, so it runs before the key lookup — it discloses
7410                // nothing about the store. Covers Cypher `CREATE` and the node
7411                // `MERGE` creates, both of which arrive as this op.
7412                // Resolved before the role lookup so a props list naming `ns`
7413                // twice is refused for every role, scoped or not: it is the same
7414                // malformed write the seam refuses, and leaving it to the seam
7415                // would mean the gate had already read one of the two.
7416                let target = Self::created_namespace(key, props)?;
7417                if let Some(def) = self.role_def_for(&authz.role) {
7418                    if !def.sees_namespace(target) {
7419                        return Err(GraphError::RoleWriteDenied {
7420                            reason: format!(
7421                                "role-bound token: namespace '{target}' not in the role's \
7422                                 namespaces"
7423                            ),
7424                        });
7425                    }
7426                }
7427                // Row 2/3: key lookup.
7428                match self.ids.get(key.as_str()) {
7429                    Some(id) if authz.mask.contains_id(id) => {
7430                        // Visible: DuplicateKey — let MutPreview handle this.
7431                    }
7432                    Some(_) => {
7433                        // Hidden: indistinguishable from absent to the role.
7434                        return Err(GraphError::RoleWriteDenied {
7435                            reason: "role-bound token: target node not visible".into(),
7436                        });
7437                    }
7438                    None => {
7439                        // Absent: proceed (create).
7440                    }
7441                }
7442            }
7443
7444            // ── UPDATE-class: SetProp, RemoveProp ────────────────────────────
7445            BatchOp::SetProp { key, .. } | BatchOp::RemoveProp { key, .. } => {
7446                if batch_created.contains_key(key.as_str()) {
7447                    // Batch-created node: create gate already passed this batch.
7448                    // Updating it in the same batch is always allowed, regardless
7449                    // of update_labels (ruling §3.5: "writer just created it").
7450                } else {
7451                    let label = match node_status(key) {
7452                        NodeAuthzStatus::Visible(lbl) => lbl,
7453                        _ => {
7454                            return Err(GraphError::RoleWriteDenied {
7455                                reason: "role-bound token: target node not visible".into(),
7456                            });
7457                        }
7458                    };
7459                    if !authz.scope.update_labels.contains(&label) {
7460                        return Err(GraphError::RoleWriteDenied {
7461                            reason: format!(
7462                                "role-bound token: label '{}' not in write scope (update_labels)",
7463                                label
7464                            ),
7465                        });
7466                    }
7467                }
7468            }
7469
7470            // ── DELETE-class: DeleteNode ─────────────────────────────────────
7471            BatchOp::DeleteNode { key } => {
7472                let label = match node_status(key) {
7473                    NodeAuthzStatus::Visible(lbl) => lbl,
7474                    _ => {
7475                        return Err(GraphError::RoleWriteDenied {
7476                            reason: "role-bound token: target node not visible".into(),
7477                        });
7478                    }
7479                };
7480                if !authz.scope.delete_labels.contains(&label) {
7481                    return Err(GraphError::RoleWriteDenied {
7482                        reason: format!(
7483                            "role-bound token: label '{}' not in write scope (delete_labels)",
7484                            label
7485                        ),
7486                    });
7487                }
7488            }
7489
7490            // ── DELETE-class: DeleteEdge ─────────────────────────────────────
7491            //
7492            // Derived-edge rejection runs BEFORE the delete_edge_types scope
7493            // check (spec §3.5: "existing derived-edge rejection precedes
7494            // delete_edge_types check").
7495            BatchOp::DeleteEdge {
7496                edge_type,
7497                src_key,
7498                dst_key,
7499            } => {
7500                // Check provenance ownership BEFORE scope (spec §3.5 ordering).
7501                if let (Some(src_id), Some(dst_id), Some(et_sym)) = (
7502                    self.ids.get(src_key.as_str()),
7503                    self.ids.get(dst_key.as_str()),
7504                    self.syms.get(edge_type.as_str()),
7505                ) {
7506                    if self.engine.is_owned(et_sym, src_id, dst_id) {
7507                        return Err(GraphError::RuleOwned {
7508                            detail: format!(
7509                                "edge {edge_type} {src_key}→{dst_key} is rule-owned; \
7510                                 delete or change the owning rule"
7511                            ),
7512                        });
7513                    }
7514                    // Also check would_derive via MutPreview (empty overlay, pre-batch).
7515                    let preview = MutPreview::new(self);
7516                    if preview.would_derive(edge_type, src_key, dst_key) {
7517                        return Err(GraphError::RuleOwned {
7518                            detail: format!(
7519                                "edge {edge_type} {src_key}→{dst_key} is rule-owned; \
7520                                 delete or change the owning rule, or a live rule would \
7521                                 re-derive it"
7522                            ),
7523                        });
7524                    }
7525                }
7526                // Scope check (AFTER derived-edge check, BEFORE endpoint visibility).
7527                if !authz.scope.delete_edge_types.contains(edge_type) {
7528                    return Err(GraphError::RoleWriteDenied {
7529                        reason: format!(
7530                            "role-bound token: edge type '{}' not in write scope (delete_edge_types)",
7531                            edge_type
7532                        ),
7533                    });
7534                }
7535                // Both endpoints must be visible.
7536                for ep_key in [src_key.as_str(), dst_key.as_str()] {
7537                    match self.ids.get(ep_key) {
7538                        None => {
7539                            return Err(GraphError::RoleWriteDenied {
7540                                reason: "role-bound token: edge endpoint not visible".into(),
7541                            });
7542                        }
7543                        Some(id) if !authz.mask.contains_id(id) => {
7544                            return Err(GraphError::RoleWriteDenied {
7545                                reason: "role-bound token: edge endpoint not visible".into(),
7546                            });
7547                        }
7548                        _ => {}
7549                    }
7550                }
7551            }
7552
7553            // ── EDGE-CREATE: InsertEdge ──────────────────────────────────────
7554            //
7555            // Scope check BEFORE endpoint lookup (preserves timing symmetry).
7556            BatchOp::InsertEdge {
7557                edge_type,
7558                src_key,
7559                dst_key,
7560            } => {
7561                if !authz.scope.create_edge_types.contains(edge_type) {
7562                    return Err(GraphError::RoleWriteDenied {
7563                        reason: format!(
7564                            "role-bound token: edge type '{}' not in write scope (create_edge_types)",
7565                            edge_type
7566                        ),
7567                    });
7568                }
7569                // Both endpoints must be visible. A node created by an earlier
7570                // InsertNode in the same batch (tracked in batch_created) counts
7571                // as visible if its label passed the create-class gate.
7572                for ep_key in [src_key.as_str(), dst_key.as_str()] {
7573                    if batch_created.contains_key(ep_key) {
7574                        // Created earlier this batch — already scope-checked.
7575                        continue;
7576                    }
7577                    match self.ids.get(ep_key) {
7578                        None => {
7579                            return Err(GraphError::RoleWriteDenied {
7580                                reason: "role-bound token: edge endpoint not visible".into(),
7581                            });
7582                        }
7583                        Some(id) if !authz.mask.contains_id(id) => {
7584                            return Err(GraphError::RoleWriteDenied {
7585                                reason: "role-bound token: edge endpoint not visible".into(),
7586                            });
7587                        }
7588                        _ => {}
7589                    }
7590                }
7591            }
7592
7593            // ── EDGE-CREATE: InsertEdgeUpsert ────────────────────────────────
7594            //
7595            // Scope check first; then endpoint visibility using same-batch
7596            // placeholder awareness (spec: "a placeholder endpoint the SAME
7597            // batch creates counts as visible if its label passed the
7598            // create-class gate").
7599            BatchOp::InsertEdgeUpsert {
7600                edge_type,
7601                src_key,
7602                dst_key,
7603                placeholder_label,
7604            } => {
7605                if !authz.scope.create_edge_types.contains(edge_type) {
7606                    return Err(GraphError::RoleWriteDenied {
7607                        reason: format!(
7608                            "role-bound token: edge type '{}' not in write scope (create_edge_types)",
7609                            edge_type
7610                        ),
7611                    });
7612                }
7613                // Check placeholder label against create_labels (create-class gate).
7614                // This ensures the auto-created endpoints are scope-allowed.
7615                for ep_key in [src_key.as_str(), dst_key.as_str()] {
7616                    if !upsert_ep_visible(ep_key, placeholder_label) {
7617                        return Err(GraphError::RoleWriteDenied {
7618                            reason: "role-bound token: edge endpoint not visible".into(),
7619                        });
7620                    }
7621                }
7622                // A placeholder is created with no props, so it lands in the
7623                // default namespace. A role that cannot read `default` must not
7624                // create one there, for the same reason it may not create a node
7625                // there outright.
7626                //
7627                // The refusal is byte-identical to the hidden-endpoint one above,
7628                // and deliberately so: this arm fires only for an endpoint that
7629                // does **not** exist, and the one above only for an endpoint that
7630                // does. Two different strings would make the pair an existence
7631                // oracle — ask for an upsert and read off whether the key is
7632                // taken. Hidden ≡ absent is the rule everywhere else in this
7633                // table and it holds here too.
7634                if let Some(def) = self.role_def_for(&authz.role) {
7635                    if !def.sees_namespace(NS_DEFAULT) {
7636                        for ep_key in [src_key.as_str(), dst_key.as_str()] {
7637                            if self.ids.get(ep_key).is_none() && !batch_created.contains_key(ep_key)
7638                            {
7639                                return Err(GraphError::RoleWriteDenied {
7640                                    reason: "role-bound token: edge endpoint not visible".into(),
7641                                });
7642                            }
7643                        }
7644                    }
7645                }
7646            }
7647        }
7648        Ok(())
7649    }
7650
7651    /// Write `roles` to `roles.json` atomically and update the in-memory list.
7652    ///
7653    /// Called by `apply_schema` when roles change. Never called on unchanged
7654    /// re-apply — this preserves byte-identical idempotency.
7655    pub(crate) fn commit_roles(&mut self, roles: Vec<RoleDef>) -> Result<()> {
7656        let file = RolesFile::new_versioned(roles.clone());
7657        let bytes = serde_json::to_vec(&file).map_err(|e| GraphError::Corrupt {
7658            detail: format!("roles serialization: {e}"),
7659        })?;
7660        self.fs
7661            .write_atomic(FileId::Roles, &bytes)
7662            .map_err(GraphError::Io)?;
7663        self.roles = Some(roles);
7664        // Rewriting the sidecar is not a commit, so `commit_seq` does not move
7665        // and a memoised mask would still match its version. Install a fresh
7666        // cache instead of clearing the shared one: a reader snapshot frozen
7667        // against the old definitions keeps the old `Arc` to itself and can
7668        // never publish an answer this handle would read back.
7669        self.role_masks = Arc::new(crate::mask::RoleMaskCache::new());
7670        // Refresh the MVCC frozen overlay so that reader() immediately sees the
7671        // updated role definitions without waiting for the next K-commit fold.
7672        self.fold_now();
7673        Ok(())
7674    }
7675
7676    fn view(&self) -> GraphView<'_> {
7677        GraphView {
7678            ids: &self.ids,
7679            syms: &self.syms,
7680            labels: &self.labels,
7681            props: self.props_view(),
7682            topo: self.topo_view(),
7683            edge_props: self.edge_props_view(),
7684            mask: None,
7685            prop_index: Some(&self.prop_index),
7686        }
7687    }
7688
7689    fn view_masked<'a>(&'a self, mask: &'a crate::mask::NodeMask) -> GraphView<'a> {
7690        GraphView {
7691            ids: &self.ids,
7692            syms: &self.syms,
7693            labels: &self.labels,
7694            props: self.props_view(),
7695            topo: self.topo_view(),
7696            edge_props: self.edge_props_view(),
7697            mask: Some(&mask.visible),
7698            prop_index: Some(&self.prop_index),
7699        }
7700    }
7701
7702    /// Execute a read-only Cypher query with a node visibility mask.
7703    ///
7704    /// Only nodes whose key is in `mask` are accessible: label scans, key
7705    /// lookups, and neighbor expansions all respect the mask. Edges where
7706    /// either endpoint is hidden are silently dropped.
7707    ///
7708    /// Returns `Err` with a "masked queries are read-only" message when
7709    /// `cypher` is a write statement (CREATE / MERGE / MATCH…SET / DELETE).
7710    pub fn query_masked(
7711        &self,
7712        cypher: &str,
7713        params: &std::collections::BTreeMap<String, Value>,
7714        mask: &crate::mask::NodeMask,
7715    ) -> Result<ResultSet> {
7716        // Reject write statements up front.
7717        let tokens = lex(cypher).map_err(|e| GraphError::QueryError {
7718            detail: format!("lex: {e}"),
7719        })?;
7720        if is_write_tokens(&tokens) {
7721            return Err(GraphError::MaskedReadOnly);
7722        }
7723        let union = parse_read(&tokens).map_err(|e| GraphError::QueryError {
7724            detail: format!("parse: {e}"),
7725        })?;
7726        // Each UNION part executes against the same masked view, so the mask
7727        // applies uniformly across the chain.
7728        execute_union(&self.view_masked(mask), &union, &Params(params)).map_err(|e| {
7729            GraphError::QueryError {
7730                detail: format!("execute: {e}"),
7731            }
7732        })
7733    }
7734
7735    pub fn node_ref(&self, key: &str) -> Option<NodeRef<'_, F>> {
7736        let id = self.ids.get(key)?;
7737        Some(NodeRef { db: self, id })
7738    }
7739
7740    /// BFS neighborhood expansion restricted to visible nodes in `mask`.
7741    ///
7742    /// Hidden nodes are never used as traversal intermediaries in either
7743    /// [`MaskMode::Omit`] or [`MaskMode::Stub`] — a visible node reachable
7744    /// only through a hidden node will not appear in results.
7745    ///
7746    /// In [`MaskMode::Stub`] mode, hidden nodes that are direct neighbours of
7747    /// a visited visible node are appended to the result as stub rows
7748    /// (`label` column is `null`, same key+depth columns as visible rows).
7749    /// They are NOT added to the BFS frontier.
7750    ///
7751    /// Returns `None` when `key` does not exist (caller should 404).
7752    ///
7753    /// **SECURITY**: role-token callers always pass an Omit-mode mask, so
7754    /// stub rows are never produced on the role path.
7755    pub fn neighborhood_masked(
7756        &self,
7757        key: &str,
7758        depth: u32,
7759        edge_types: Option<&[&str]>,
7760        dir: Dir,
7761        mask: &crate::mask::NodeMask,
7762    ) -> Option<ResultSet> {
7763        let start_id = self.ids.get(key)?;
7764        let view = self.view_masked(mask);
7765        let resolved: Option<Vec<u32>> = edge_types.map(|names| {
7766            names
7767                .iter()
7768                .filter_map(|name| view.syms.get(name))
7769                .collect()
7770        });
7771        let nb = neighborhood(&view, start_id, depth, resolved.as_deref(), dir);
7772        let mut rs = ResultSet::new(vec!["key".into(), "label".into(), "depth".into()]);
7773        // Collect visible BFS results (start_id at depth 0, BFS nodes after).
7774        let mut visited: Vec<(u32, u32)> = Vec::with_capacity(nb.nodes.len() + 1);
7775        visited.push((start_id, 0));
7776        for (nid, d) in &nb.nodes {
7777            let k = view.key_of(*nid);
7778            let label = view
7779                .label_of(*nid)
7780                .expect("real nodes always have a label; u32::MAX sentinel cannot occur");
7781            rs.push_row(vec![
7782                Some(Value::Str(k.to_string())),
7783                Some(Value::Str(label.to_string())),
7784                Some(Value::Int(*d as i64)),
7785            ]);
7786            visited.push((*nid, *d));
7787        }
7788        // Stub mode: add hidden direct neighbours of each visited node as stubs.
7789        // Hidden nodes are edge-endpoints only — they are not added to the BFS
7790        // frontier, so the BFS never expands through them.
7791        if mask.mode() == crate::mask::MaskMode::Stub {
7792            let raw_view = self.view();
7793            let mut seen: std::collections::HashSet<u32> =
7794                visited.iter().map(|(id, _)| *id).collect();
7795            for (node_id, node_depth) in &visited {
7796                if *node_depth >= depth {
7797                    continue;
7798                }
7799                for e in expand(&raw_view, *node_id, resolved.as_deref(), dir) {
7800                    let nbr = if e.src == *node_id { e.dst } else { e.src };
7801                    if !mask.contains_id(nbr) && seen.insert(nbr) {
7802                        if let Some(k) = self.ids.key_of(nbr) {
7803                            rs.push_row(vec![
7804                                Some(Value::Str(k.to_string())),
7805                                None,
7806                                Some(Value::Int((*node_depth + 1) as i64)),
7807                            ]);
7808                        }
7809                    }
7810                }
7811            }
7812        }
7813        Some(rs)
7814    }
7815
7816    /// Live node's key, label, and columnar props. Unknown or tombstoned → `None`.
7817    pub fn node_info(&self, key: &str) -> Option<NodeInfo> {
7818        let n = self.node_ref(key)?;
7819        Some(NodeInfo {
7820            key: n.key().to_string(),
7821            label: n.label().to_string(),
7822            props: n.props(),
7823        })
7824    }
7825
7826    /// Look up a node with mask awareness.
7827    ///
7828    /// | Key state         | Omit mode       | Stub mode              |
7829    /// |-------------------|-----------------|------------------------|
7830    /// | does not exist    | `None` (→ 404)  | `None` (→ 404)         |
7831    /// | exists, visible   | `Some(Visible)` | `Some(Visible)`        |
7832    /// | exists, hidden    | `None` (→ 404)  | `Some(Restricted)`     |
7833    ///
7834    /// **SECURITY**: only call from client-mask (full-token) paths.
7835    /// Role-token paths must use [`node_info`] after an explicit visibility check.
7836    pub fn node_info_masked(
7837        &self,
7838        key: &str,
7839        mask: &crate::mask::NodeMask,
7840    ) -> Option<MaskedNodeResult> {
7841        let id = self.ids.get(key)?;
7842        if mask.contains_id(id) {
7843            Some(MaskedNodeResult::Visible(self.node_info(key)?))
7844        } else {
7845            match mask.mode() {
7846                crate::mask::MaskMode::Stub => Some(MaskedNodeResult::Restricted),
7847                crate::mask::MaskMode::Omit => None,
7848            }
7849        }
7850    }
7851
7852    /// Get edges for `key` with mask-aware hidden-endpoint handling.
7853    ///
7854    /// - Omit mode: edges to hidden endpoints are excluded (same as role-path filtering).
7855    /// - Stub mode: edges to hidden endpoints are included; `src_restricted`/`dst_restricted`
7856    ///   is `true` for each hidden endpoint.
7857    ///
7858    /// Unknown key → [`GraphError::KeyNotFound`].
7859    ///
7860    /// **SECURITY**: only call from client-mask (full-token) paths.
7861    pub fn node_edges_masked(
7862        &self,
7863        key: &str,
7864        mask: &crate::mask::NodeMask,
7865    ) -> Result<Vec<MaskedEdge>> {
7866        self.ensure_v8_base_sections_loaded();
7867        let id = self
7868            .ids
7869            .get(key)
7870            .ok_or_else(|| GraphError::KeyNotFound { key: key.into() })?;
7871        let derived: BTreeSet<(u32, u32, u32)> = self
7872            .engine
7873            .provenance_touching(id)
7874            .map(|(_rule, etype, src, dst)| (etype, src, dst))
7875            .collect();
7876        let mut edges = Vec::new();
7877        let tv = self.topo_view();
7878        for etype in tv.etypes() {
7879            // etype comes from the archived CSR (access_unchecked, no eager CRC).
7880            // A bit-flip in the large TOPOLOGY section can produce an etype id
7881            // that is not in the interner.  Return Corrupt rather than panic.
7882            let edge_type = self
7883                .syms
7884                .resolve(etype)
7885                .ok_or_else(|| GraphError::Corrupt {
7886                    detail: format!("v8: topology etype {etype} not in interner"),
7887                })?
7888                .to_string();
7889            for dir in [Direction::Out, Direction::In] {
7890                for &nbr in tv.neighbors(etype, dir, id).as_ref() {
7891                    let nbr_restricted = !mask.contains_id(nbr);
7892                    if nbr_restricted && mask.mode() == crate::mask::MaskMode::Omit {
7893                        continue;
7894                    }
7895                    let nbr_key = self
7896                        .ids
7897                        .key_of(nbr)
7898                        .ok_or_else(|| GraphError::Corrupt {
7899                            detail: format!("topology id {nbr} has no key"),
7900                        })?
7901                        .to_string();
7902                    let (src_id, dst_id, src_key, dst_key, src_restricted, dst_restricted) =
7903                        match dir {
7904                            Direction::Out => {
7905                                (id, nbr, key.to_string(), nbr_key, false, nbr_restricted)
7906                            }
7907                            Direction::In => {
7908                                (nbr, id, nbr_key, key.to_string(), nbr_restricted, false)
7909                            }
7910                        };
7911                    edges.push(MaskedEdge {
7912                        edge_type: edge_type.clone(),
7913                        src_key,
7914                        src_restricted,
7915                        dst_key,
7916                        dst_restricted,
7917                        derived: derived.contains(&(etype, src_id, dst_id)),
7918                    });
7919                }
7920            }
7921        }
7922        edges.sort_by(|a, b| {
7923            a.edge_type
7924                .cmp(&b.edge_type)
7925                .then(a.src_key.cmp(&b.src_key))
7926                .then(a.dst_key.cmp(&b.dst_key))
7927        });
7928        edges.dedup_by(|a, b| {
7929            a.edge_type == b.edge_type && a.src_key == b.src_key && a.dst_key == b.dst_key
7930        });
7931        Ok(edges)
7932    }
7933
7934    /// Every directed edge incident on `key`, both directions, every etype.
7935    ///
7936    /// Walk is `topology.etypes()` × `{Out, In}` × `neighbors()`. `derived` is
7937    /// membership in [`RuleEngine::provenance_touching`] (O(degree) via the
7938    /// Plan-8 `by_node` index). Sorted by `(edge_type, src_key, dst_key)`.
7939    /// Unknown key → [`GraphError::KeyNotFound`].
7940    pub fn node_edges(&self, key: &str) -> Result<Vec<EdgeInfo>> {
7941        self.ensure_v8_base_sections_loaded();
7942        let id = self
7943            .ids
7944            .get(key)
7945            .ok_or_else(|| GraphError::KeyNotFound { key: key.into() })?;
7946        let derived: BTreeSet<(u32, u32, u32)> = self
7947            .engine
7948            .provenance_touching(id)
7949            .map(|(_rule, etype, src, dst)| (etype, src, dst))
7950            .collect();
7951        let mut edges = Vec::new();
7952        let tv = self.topo_view();
7953        for etype in tv.etypes() {
7954            // Same guard as node_edges_masked: etype from unchecked-CRC CSR.
7955            let edge_type = self
7956                .syms
7957                .resolve(etype)
7958                .ok_or_else(|| GraphError::Corrupt {
7959                    detail: format!("v8: topology etype {etype} not in interner"),
7960                })?
7961                .to_string();
7962            for dir in [Direction::Out, Direction::In] {
7963                for &nbr in tv.neighbors(etype, dir, id).as_ref() {
7964                    let (src, dst, src_key, dst_key) = match dir {
7965                        Direction::Out => (
7966                            id,
7967                            nbr,
7968                            key.to_string(),
7969                            self.ids
7970                                .key_of(nbr)
7971                                .ok_or_else(|| GraphError::Corrupt {
7972                                    detail: format!("topology id {nbr} has no key"),
7973                                })?
7974                                .to_string(),
7975                        ),
7976                        Direction::In => (
7977                            nbr,
7978                            id,
7979                            self.ids
7980                                .key_of(nbr)
7981                                .ok_or_else(|| GraphError::Corrupt {
7982                                    detail: format!("topology id {nbr} has no key"),
7983                                })?
7984                                .to_string(),
7985                            key.to_string(),
7986                        ),
7987                    };
7988                    edges.push(EdgeInfo {
7989                        edge_type: edge_type.clone(),
7990                        src_key,
7991                        dst_key,
7992                        derived: derived.contains(&(etype, src, dst)),
7993                    });
7994                }
7995            }
7996        }
7997        edges.sort_by(|a, b| {
7998            a.edge_type
7999                .cmp(&b.edge_type)
8000                .then(a.src_key.cmp(&b.src_key))
8001                .then(a.dst_key.cmp(&b.dst_key))
8002        });
8003        // Self-loops appear in both Out and In; sort makes the pair adjacent
8004        // (sort key matches PartialEq for this case) so one pass drops the dup.
8005        edges.dedup();
8006        Ok(edges)
8007    }
8008
8009    // ── Backup ────────────────────────────────────────────────────────────────
8010
8011    /// Copy this store to `dest` as a consistent, verified snapshot.
8012    ///
8013    /// Copies every durable file in the database directory — `snapshot.bin`,
8014    /// `wal.bin`, all `wal.<N>.archive` files, `wal.floor`, `wal.genesis`, and
8015    /// `roles.json` — into a freshly created `dest` directory using OS-level
8016    /// `copy` calls (no large in-process buffers).
8017    ///
8018    /// # Consistency guarantee
8019    ///
8020    /// The guarantee is **process-local**: the caller holds `&self`, which
8021    /// prevents any concurrent writer in the **same process** from modifying
8022    /// the files during the copy.  Running `mushroomdb backup` against a
8023    /// directory that is **concurrently being written by another process** (e.g.
8024    /// `mushroomdb serve`) is **unsafe** — the copy can be torn.  The post-copy
8025    /// `verified: true` result reduces but does not eliminate the risk of a
8026    /// silent corrupt backup (CRC catches many bit-flips; it cannot catch a
8027    /// consistent mid-write snapshot).
8028    ///
8029    /// **The safe path for a live-served store is `POST /backup` on the HTTP
8030    /// server.** That handler acquires the read lock on the shared database
8031    /// before calling this method, which is the correct cross-process
8032    /// synchronisation point because the server is the single process writing
8033    /// the files.
8034    ///
8035    /// After copying, opens the destination read-only and runs the CRC section
8036    /// verifier (`verify_snapshot`) to confirm byte-for-byte integrity.
8037    /// `BackupReport::verified` reflects whether both checks passed.
8038    ///
8039    /// Returns `Err` when `self` is not backed by a `RealFs` (e.g. `SimFs`).
8040    pub fn backup_to(&self, dest: &std::path::Path) -> Result<BackupReport> {
8041        // Derive source directory from snapshot_path (RealFs only).
8042        let src_dir = match self.fs.snapshot_path() {
8043            Some(p) => p.parent().map(|d| d.to_path_buf()).ok_or_else(|| {
8044                GraphError::Io(std::io::Error::other("snapshot has no parent dir"))
8045            })?,
8046            None => {
8047                return Err(GraphError::Io(std::io::Error::other(
8048                    "backup_to requires a real filesystem (RealFs)",
8049                )))
8050            }
8051        };
8052
8053        std::fs::create_dir_all(dest)?;
8054
8055        let mut files: Vec<String> = Vec::new();
8056        let mut bytes: u64 = 0;
8057
8058        // Helper: copy src_dir/name → dest/name if the file exists.
8059        let mut try_copy = |name: &str| -> std::io::Result<()> {
8060            let src_path = src_dir.join(name);
8061            if src_path.exists() {
8062                let n = std::fs::copy(&src_path, dest.join(name))?;
8063                bytes += n;
8064                files.push(name.to_string());
8065            }
8066            Ok(())
8067        };
8068
8069        try_copy("snapshot.bin")?;
8070        try_copy("snapshot.bin.bak")?;
8071        try_copy("wal.bin")?;
8072        try_copy("wal.floor")?;
8073        try_copy("wal.genesis")?;
8074        try_copy("roles.json")?;
8075
8076        // Copy WAL archives.
8077        let archives = self.fs.list_archives()?;
8078        for n in &archives {
8079            let name = format!("wal.{n}.archive");
8080            let n_bytes = std::fs::copy(src_dir.join(&name), dest.join(&name))?;
8081            bytes += n_bytes;
8082            files.push(name);
8083        }
8084
8085        files.sort();
8086
8087        // Post-copy verification: open dest and run CRC checks.
8088        let snap_in_dest = dest.join("snapshot.bin").exists();
8089        let crc_ok = if snap_in_dest {
8090            crate::verify_snapshot(dest)
8091                .map(|results| results.iter().all(|(_, _, _, r)| r.is_ok()))
8092                .unwrap_or(false)
8093        } else {
8094            true // WAL-only store: nothing to CRC-check in snapshot
8095        };
8096        let opens_ok = GraphDb::<core_storage::fs::RealFs>::open(dest).is_ok();
8097        let verified = crc_ok && opens_ok;
8098
8099        Ok(BackupReport {
8100            files,
8101            bytes,
8102            verified,
8103        })
8104    }
8105
8106    // ── Export helpers ────────────────────────────────────────────────────────
8107
8108    /// All live nodes, sorted by key (deterministic).
8109    ///
8110    /// Reads base + WAL overlay. Tombstoned nodes are excluded.
8111    pub fn all_nodes_for_export(&self) -> Vec<NodeInfo> {
8112        self.ensure_v8_base_sections_loaded();
8113        let pv = self.props_view();
8114        let mut nodes = Vec::new();
8115        for id in 0..self.ids.len() as u32 {
8116            let Some(key) = self.ids.key_of(id) else {
8117                continue;
8118            };
8119            let Some(&sym) = self.labels.get(id as usize) else {
8120                continue;
8121            };
8122            if sym == u32::MAX {
8123                continue; // tombstoned
8124            }
8125            let Some(label) = self.syms.resolve(sym) else {
8126                continue;
8127            };
8128            let mut props = BTreeMap::new();
8129            for field in pv.field_names() {
8130                if let Some(vr) = pv.get(id, &field) {
8131                    props.insert(field, vr.into_value());
8132                }
8133            }
8134            nodes.push(NodeInfo {
8135                key: key.to_string(),
8136                label: label.to_string(),
8137                props,
8138            });
8139        }
8140        nodes.sort_by(|a, b| a.key.cmp(&b.key));
8141        nodes
8142    }
8143
8144    /// All directed edges, sorted by `(edge_type, src, dst)`. Each edge appears once.
8145    ///
8146    /// Derived edges carry `derived: true` and the creating rule's name in `rule`.
8147    /// Manual edges carry `derived: false` and `rule: None`.
8148    /// `weight` is the creating rule's `weight_prop` value read off the edge
8149    /// (numeric only), mirroring the convention used by [`GraphDb::explain`]
8150    /// and [`GraphDb::weighted_edges`]. Deterministic across runs on the same
8151    /// store state.
8152    pub fn all_edges_for_export(&self) -> Vec<ExportEdge> {
8153        self.ensure_v8_base_sections_loaded();
8154
8155        // Build (etype_sym, src_id, dst_id) → rule_name for O(1) derivation lookup.
8156        let mut prov: HashMap<(u32, u32, u32), String> = HashMap::new();
8157        for (rule_name, triples) in self.engine.provenance() {
8158            for &(etype, src, dst) in triples {
8159                prov.insert((etype, src, dst), rule_name.clone());
8160            }
8161        }
8162
8163        // rule_name → weight_prop, for O(1) lookup per derived edge.
8164        let weight_props: HashMap<&str, Option<&str>> = self
8165            .engine
8166            .rules()
8167            .map(|r| (r.name.as_str(), r.weight_prop.as_deref()))
8168            .collect();
8169
8170        let tv = self.topo_view();
8171        let ep = self.edge_props_view();
8172        let mut edges = Vec::new();
8173
8174        for id in 0..self.ids.len() as u32 {
8175            let Some(key) = self.ids.key_of(id) else {
8176                continue;
8177            };
8178            let Some(&lsym) = self.labels.get(id as usize) else {
8179                continue;
8180            };
8181            if lsym == u32::MAX {
8182                continue; // tombstoned
8183            }
8184
8185            for etype_sym in tv.etypes() {
8186                // etype from archived CSR (access_unchecked, no eager CRC).
8187                // Skip edges whose etype is not in the interner; this can only
8188                // occur with a corrupt large TOPOLOGY section (bit-flip on an
8189                // etype field in the archived data).  The function returns Vec,
8190                // not Result, so we continue rather than propagate.
8191                let Some(edge_type) = self.syms.resolve(etype_sym) else {
8192                    continue;
8193                };
8194                let edge_type = edge_type.to_string();
8195                for &nbr in tv.neighbors(etype_sym, Direction::Out, id).as_ref() {
8196                    let Some(dst_key) = self.ids.key_of(nbr) else {
8197                        continue; // skip corrupt entries
8198                    };
8199                    let prov_key = (etype_sym, id, nbr);
8200                    let rule = prov.get(&prov_key).cloned();
8201                    let derived = rule.is_some();
8202                    let weight = rule
8203                        .as_deref()
8204                        .and_then(|rn| weight_props.get(rn).copied().flatten())
8205                        .and_then(|prop| match ep.get(etype_sym, id, nbr, prop) {
8206                            Some(Value::Float(f)) => Some(f),
8207                            Some(Value::Int(i)) => Some(i as f64),
8208                            _ => None,
8209                        });
8210                    edges.push(ExportEdge {
8211                        edge_type: edge_type.clone(),
8212                        src: key.to_string(),
8213                        dst: dst_key.to_string(),
8214                        derived,
8215                        rule,
8216                        weight,
8217                    });
8218                }
8219            }
8220        }
8221
8222        edges.sort_by(|a, b| {
8223            a.edge_type
8224                .cmp(&b.edge_type)
8225                .then(a.src.cmp(&b.src))
8226                .then(a.dst.cmp(&b.dst))
8227        });
8228        edges
8229    }
8230
8231    /// What each edge type *is*, without building one record per edge.
8232    ///
8233    /// [`all_edges_for_export`](Self::all_edges_for_export) answers the same
8234    /// question by materialising every edge — three `String`s apiece, a
8235    /// provenance `HashMap` over every derived edge, and a final sort. That is
8236    /// the right shape for an export, and the wrong one for a summary: on a
8237    /// store with 1.3 M derived edges it allocates hundreds of megabytes to
8238    /// produce nine lines. This walks the topology instead, summing neighbour
8239    /// slice lengths and collecting *label symbols* rather than label strings,
8240    /// so the per-edge cost is an integer add and a set insert on a set with
8241    /// as many members as the store has labels.
8242    ///
8243    /// The rule names come off the rule *definitions*, which each declare the
8244    /// `edge_type` they derive, so naming them costs one pass over the rules
8245    /// rather than one provenance lookup per edge. That is also why `rules`
8246    /// is a list: two rules may derive the same type — the association store
8247    /// derives `INDUSTRY_ALIGNMENT` from both a talent→company and a
8248    /// talent→job rule — and naming only one of them would be a half-truth.
8249    /// A type with no rules is one written by hand.
8250    ///
8251    /// `sample` is the first edge of the type in the store's own id order,
8252    /// which is insertion order: deterministic for a given store, and not the
8253    /// same as key order, which cannot be had without resolving a key per
8254    /// edge. Sorted by `edge_type`.
8255    pub fn edge_type_census(&self) -> Vec<EdgeTypeCensus> {
8256        self.ensure_v8_base_sections_loaded();
8257
8258        let mut rules_by_type: BTreeMap<&str, BTreeSet<&str>> = BTreeMap::new();
8259        for r in self.engine.rules() {
8260            rules_by_type
8261                .entry(r.edge_type.as_str())
8262                .or_default()
8263                .insert(r.name.as_str());
8264        }
8265
8266        let tv = self.topo_view();
8267        let node_count = self.ids.len() as u32;
8268        let mut out = Vec::new();
8269        for etype_sym in tv.etypes() {
8270            // An etype the interner cannot resolve means a corrupt TOPOLOGY
8271            // section; skip it rather than name it, as `all_edges_for_export`
8272            // does for the same reason.
8273            let Some(edge_type) = self.syms.resolve(etype_sym) else {
8274                continue;
8275            };
8276            let mut edges: u64 = 0;
8277            let mut src_syms: BTreeSet<u32> = BTreeSet::new();
8278            let mut dst_syms: BTreeSet<u32> = BTreeSet::new();
8279            let mut sample: Option<(u32, u32)> = None;
8280            for id in 0..node_count {
8281                let Some(&lsym) = self.labels.get(id as usize) else {
8282                    continue;
8283                };
8284                if lsym == u32::MAX {
8285                    continue; // tombstoned
8286                }
8287                let nbrs = tv.neighbors(etype_sym, Direction::Out, id);
8288                let nbrs = nbrs.as_ref();
8289                if nbrs.is_empty() {
8290                    continue;
8291                }
8292                edges += nbrs.len() as u64;
8293                src_syms.insert(lsym);
8294                for &nbr in nbrs {
8295                    if let Some(&dsym) = self.labels.get(nbr as usize) {
8296                        if dsym != u32::MAX {
8297                            dst_syms.insert(dsym);
8298                        }
8299                    }
8300                }
8301                if sample.is_none() {
8302                    sample = Some((id, nbrs[0]));
8303                }
8304            }
8305            let resolve = |syms: &BTreeSet<u32>| -> Vec<String> {
8306                syms.iter()
8307                    .filter_map(|&s| self.syms.resolve(s))
8308                    .map(ToString::to_string)
8309                    .collect()
8310            };
8311            out.push(EdgeTypeCensus {
8312                edge_type: edge_type.to_string(),
8313                edges,
8314                src_labels: resolve(&src_syms),
8315                dst_labels: resolve(&dst_syms),
8316                rules: rules_by_type
8317                    .get(edge_type)
8318                    .map(|rs| rs.iter().map(ToString::to_string).collect())
8319                    .unwrap_or_default(),
8320                sample: sample.and_then(|(s, d)| {
8321                    Some((
8322                        self.ids.key_of(s)?.to_string(),
8323                        self.ids.key_of(d)?.to_string(),
8324                    ))
8325                }),
8326            });
8327        }
8328        out.sort_by(|a, b| a.edge_type.cmp(&b.edge_type));
8329        out
8330    }
8331
8332    /// All directed edges of `edge_type`, with the raw value of `weight_prop`
8333    /// on each edge when given.
8334    ///
8335    /// `weight` is `Some(f)` only when `weight_prop` is set and the edge
8336    /// carries that property with a numeric (`Int`/`Float`) value; otherwise
8337    /// `None` — callers that want a default weight (e.g. `1.0` for missing
8338    /// props) apply it themselves, matching the convention used internally
8339    /// by [`GraphDb::pagerank`], [`GraphDb::connected_components`],
8340    /// [`GraphDb::degree_centrality`], and [`GraphDb::communities`].
8341    ///
8342    /// Sorted by `(src, dst)` for determinism. Reads the unified topology
8343    /// (manual + rule-derived edges).  An unknown `edge_type` returns an
8344    /// empty vec.
8345    pub fn weighted_edges(
8346        &self,
8347        edge_type: &str,
8348        weight_prop: Option<&str>,
8349    ) -> Vec<(String, String, Option<f64>)> {
8350        let Some(etype_sym) = self.syms.get(edge_type) else {
8351            return Vec::new();
8352        };
8353        let tv = self.topo_view();
8354        let ep = self.edge_props_view();
8355        let mut out = Vec::new();
8356        for id in 0..self.ids.len() as u32 {
8357            let Some(key) = self.ids.key_of(id) else {
8358                continue;
8359            };
8360            let Some(&sym) = self.labels.get(id as usize) else {
8361                continue;
8362            };
8363            if sym == u32::MAX {
8364                continue; // tombstoned
8365            }
8366            for &nbr in tv.neighbors(etype_sym, Direction::Out, id).as_ref() {
8367                let Some(dst_key) = self.ids.key_of(nbr) else {
8368                    continue;
8369                };
8370                let weight = weight_prop.and_then(|prop| match ep.get(etype_sym, id, nbr, prop) {
8371                    Some(Value::Float(f)) => Some(f),
8372                    Some(Value::Int(i)) => Some(i as f64),
8373                    _ => None,
8374                });
8375                out.push((key.to_string(), dst_key.to_string(), weight));
8376            }
8377        }
8378        out.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.cmp(&b.1)));
8379        out
8380    }
8381
8382    pub fn nodes_with_label(&self, label: &str) -> Vec<NodeRef<'_, F>> {
8383        self.view()
8384            .nodes_with_label(label)
8385            .into_iter()
8386            .map(|id| NodeRef { db: self, id })
8387            .collect()
8388    }
8389
8390    pub fn find_nodes(&self, label: &str, filter: &Filter) -> Vec<NodeRef<'_, F>> {
8391        let view = self.view();
8392        view.nodes_with_label(label)
8393            .into_iter()
8394            .filter(|&id| {
8395                eval_filter(filter, &|field| {
8396                    view.prop(id, field).map(|vr| vr.into_value())
8397                })
8398            })
8399            .map(|id| NodeRef { db: self, id })
8400            .collect()
8401    }
8402
8403    /// Returns `true` if any approximate (HNSW) VectorSimilar rule covers
8404    /// `field`.  Use as a capability probe: when `true`, `find_similar_vector`
8405    /// with `label = None` will use the native ANN path rather than the O(n)
8406    /// brute-force scan.
8407    pub fn has_vector_rule(&self, field: &str) -> bool {
8408        self.engine.hnsw_has_rule(field)
8409    }
8410
8411    /// How many HNSW graphs this handle has built from scratch since it was
8412    /// opened (one per side of an approximate rule).
8413    ///
8414    /// An open that restored every graph from the snapshot reports `0`.
8415    /// Exposed for tests that assert the open path reuses the persisted index
8416    /// rather than rebuilding it; not part of the stable surface.
8417    #[doc(hidden)]
8418    pub fn hnsw_build_count(&self) -> u64 {
8419        self.engine.hnsw_build_count()
8420    }
8421
8422    /// How many rules this handle still holds a lazily-decoded HNSW graph for.
8423    ///
8424    /// Zero before the first ANN query on a clean open, and again once the
8425    /// live indexes own the graphs. See [`core_rules::RuleEngine::lazy_hnsw_len`].
8426    /// Exposed for tests that assert the lazy copies are released; not part of
8427    /// the stable surface.
8428    #[doc(hidden)]
8429    pub fn lazy_hnsw_len(&self) -> usize {
8430        self.engine.lazy_hnsw_len()
8431    }
8432
8433    /// Find nodes whose `field` vector is most similar to `q` (cosine
8434    /// similarity), returning up to `k` results with similarity ≥ `min`,
8435    /// sorted descending.
8436    ///
8437    /// When `label` is `None` the search spans all labels (via
8438    /// `hnsw_search_any_dst` or a full brute-force scan); when `label` is
8439    /// `Some(lbl)` it restricts to nodes with that label.
8440    ///
8441    /// Uses the HNSW index when one is available (fast path); otherwise falls
8442    /// back to an O(n) brute-force scan.
8443    ///
8444    /// **The index supplies candidates, never scores.** Its own distances are
8445    /// `f32` (accurate to ~1e-6, so an exact duplicate scores 0.9999999), so
8446    /// every candidate is re-scored from the `f64` property vectors by
8447    /// [`exact_vector_similarity`] before `min`, the ordering and the reported
8448    /// score are decided. `k + VECTOR_RESCORE_MARGIN` candidates are fetched so
8449    /// the re-ordering cannot drop a true top-`k` member; see that constant for
8450    /// the rule. The score a caller receives is therefore the same number the
8451    /// brute-force path would have produced, to `f64` precision, and `min = 1.0`
8452    /// finds an exact duplicate.
8453    pub fn find_similar_vector(
8454        &self,
8455        field: &str,
8456        label: Option<&str>,
8457        q: &[f64],
8458        k: usize,
8459        min: f64,
8460    ) -> Vec<(String, f64)> {
8461        // Ensure any HNSW blobs retained from the snapshot are deserialized
8462        // before the first ANN query on a clean-open (no-WAL) path.  The
8463        // section read has to come first: on a clean open nothing else has
8464        // called it, so without it `retained_hnsw_blobs` is empty,
8465        // `ensure_hnsw_loaded` caches an empty map in its `OnceLock`, and every
8466        // approximate query on the handle runs brute force — correct results,
8467        // silently off the index.  Both calls are idempotent and cheap once hot.
8468        self.ensure_v8_base_sections_loaded();
8469        self.engine.ensure_hnsw_loaded();
8470        // L2-normalise query for cosine via dot product.
8471        let norm: f64 = q.iter().map(|x| x * x).sum::<f64>().sqrt();
8472        if norm == 0.0 {
8473            return vec![];
8474        }
8475        let q_unit: Vec<f64> = q.iter().map(|x| x / norm).collect();
8476
8477        // Try HNSW fast path.
8478        // `None` label searches across all VectorSimilar rules covering `field`
8479        // (merging their results); `Some(lbl)` restricts to rules whose
8480        // dst_label matches.  Returns `None` when no populated HNSW index
8481        // covers the request — the O(n) brute-force fallback handles that case.
8482        let over_k = k.saturating_add(VECTOR_RESCORE_MARGIN);
8483        let hnsw_hits = match label {
8484            Some(lbl) => self.engine.hnsw_search_dst(field, lbl, &q_unit, over_k),
8485            None => self.engine.hnsw_search_any_dst(field, &q_unit, over_k),
8486        };
8487        if let Some(hits) = hnsw_hits {
8488            // Candidates only: the index's `f32` similarity is discarded and
8489            // each hit is re-scored against the `f64` vectors.
8490            let view = self.view();
8491            let mut out: Vec<(String, f64)> = hits
8492                .into_iter()
8493                .filter_map(|(id, _)| {
8494                    let sim = exact_vector_similarity(&view, id, field, &q_unit)?;
8495                    if sim < min {
8496                        return None;
8497                    }
8498                    Some((self.ids.key_of(id)?.to_string(), sim))
8499                })
8500                .collect();
8501            out.sort_by(|a, b| {
8502                b.1.partial_cmp(&a.1)
8503                    .unwrap_or(std::cmp::Ordering::Equal)
8504                    .then_with(|| a.0.cmp(&b.0))
8505            });
8506            out.truncate(k);
8507            return out;
8508        }
8509
8510        // Brute-force fallback: O(n) scan (only reached when no HNSW index
8511        // covers the request).
8512        let view = self.view();
8513        let candidate_ids: Vec<u32> = match label {
8514            Some(lbl) => view.nodes_with_label(lbl),
8515            None => view.nodes_all(),
8516        };
8517        let mut scored: Vec<(String, f64)> = candidate_ids
8518            .into_iter()
8519            .filter_map(|id| {
8520                let dot = exact_vector_similarity(&view, id, field, &q_unit)?;
8521                if dot < min {
8522                    return None;
8523                }
8524                let key = self.ids.key_of(id)?.to_string();
8525                Some((key, dot))
8526            })
8527            .collect();
8528        scored.sort_by(|a, b| {
8529            b.1.partial_cmp(&a.1)
8530                .unwrap_or(std::cmp::Ordering::Equal)
8531                .then_with(|| a.0.cmp(&b.0))
8532        });
8533        scored.truncate(k);
8534        scored
8535    }
8536
8537    /// Like [`find_similar_vector`] but restricts results to nodes visible in
8538    /// `mask`. Hidden nodes never appear in results; the mask is applied
8539    /// **before** k-truncation so a caller still receives up to `k` visible
8540    /// hits.
8541    ///
8542    /// # HNSW path (over-fetch policy)
8543    ///
8544    /// When an HNSW index covers the request, this function fetches
8545    /// `4 * k + VECTOR_RESCORE_MARGIN` candidates from the index and discards
8546    /// hidden nodes in the post-filter step.  If fewer than `k` visible nodes
8547    /// remain after filtering the caller receives whatever is available — we do
8548    /// not re-query the index.  Every surviving candidate is re-scored from the
8549    /// `f64` property vectors, exactly as [`find_similar_vector`] does and for
8550    /// the same reason.  The 4×
8551    /// multiplier is a heuristic suited for sparsely masked graphs; callers
8552    /// operating under a very selective mask should register a VectorSimilar
8553    /// rule with a non-approximate index, or use the brute-force path (no HNSW
8554    /// rule) which exhaustively filters through the masked [`GraphView`].
8555    ///
8556    /// # Brute-force path
8557    ///
8558    /// When no HNSW index covers the request the function builds a masked
8559    /// [`GraphView`] so that `nodes_all` / `nodes_with_label` return only
8560    /// visible nodes, guaranteeing exact `k` results (or all visible nodes if
8561    /// fewer than `k` exist).
8562    pub fn find_similar_vector_masked(
8563        &self,
8564        field: &str,
8565        label: Option<&str>,
8566        q: &[f64],
8567        k: usize,
8568        min: f64,
8569        mask: &crate::mask::NodeMask,
8570    ) -> Vec<(String, f64)> {
8571        // Section read before the blob decode — see `find_similar_vector`.
8572        self.ensure_v8_base_sections_loaded();
8573        self.engine.ensure_hnsw_loaded();
8574        let norm: f64 = q.iter().map(|x| x * x).sum::<f64>().sqrt();
8575        if norm == 0.0 {
8576            return vec![];
8577        }
8578        let q_unit: Vec<f64> = q.iter().map(|x| x / norm).collect();
8579
8580        // HNSW fast path — over-fetch 4×k so post-masking still yields up to k
8581        // visible hits.  See doc comment above for the policy rationale.
8582        let over_k = k
8583            .saturating_mul(4)
8584            .max(k + 1)
8585            .saturating_add(VECTOR_RESCORE_MARGIN);
8586        let hnsw_hits = match label {
8587            Some(lbl) => self.engine.hnsw_search_dst(field, lbl, &q_unit, over_k),
8588            None => self.engine.hnsw_search_any_dst(field, &q_unit, over_k),
8589        };
8590        if let Some(hits) = hnsw_hits {
8591            // Candidates only — re-scored from the `f64` vectors before `min`,
8592            // the ordering or the reported score. The mask is applied first so a
8593            // hidden node is never scored.
8594            let view = self.view_masked(mask);
8595            let mut out: Vec<(String, f64)> = hits
8596                .into_iter()
8597                .filter(|&(id, _)| mask.visible.contains(&id))
8598                .filter_map(|(id, _)| {
8599                    let sim = exact_vector_similarity(&view, id, field, &q_unit)?;
8600                    if sim < min {
8601                        return None;
8602                    }
8603                    Some((self.ids.key_of(id)?.to_string(), sim))
8604                })
8605                .collect();
8606            out.sort_by(|a, b| {
8607                b.1.partial_cmp(&a.1)
8608                    .unwrap_or(std::cmp::Ordering::Equal)
8609                    .then_with(|| a.0.cmp(&b.0))
8610            });
8611            out.truncate(k);
8612            return out;
8613        }
8614
8615        // Brute-force fallback — masked view ensures only visible nodes are
8616        // enumerated by nodes_all(); nodes_with_label() does not filter by
8617        // mask so we apply view.visible() explicitly for the labeled case.
8618        let view = self.view_masked(mask);
8619        let candidate_ids: Vec<u32> = match label {
8620            Some(lbl) => view
8621                .nodes_with_label(lbl)
8622                .into_iter()
8623                .filter(|&id| view.visible(id))
8624                .collect(),
8625            None => view.nodes_all(),
8626        };
8627        let mut scored: Vec<(String, f64)> = candidate_ids
8628            .into_iter()
8629            .filter_map(|id| {
8630                let dot = exact_vector_similarity(&view, id, field, &q_unit)?;
8631                if dot < min {
8632                    return None;
8633                }
8634                let key = self.ids.key_of(id)?.to_string();
8635                Some((key, dot))
8636            })
8637            .collect();
8638        scored.sort_by(|a, b| {
8639            b.1.partial_cmp(&a.1)
8640                .unwrap_or(std::cmp::Ordering::Equal)
8641                .then_with(|| a.0.cmp(&b.0))
8642        });
8643        scored.truncate(k);
8644        scored
8645    }
8646
8647    /// Read a single property from an edge.
8648    ///
8649    /// Returns `None` when the edge does not exist, the field is absent, or any
8650    /// of the string keys cannot be resolved to interned ids.  Only edge props
8651    /// written by rules (weight fields) are accessible without a `set_edge_prop`
8652    /// binding; topology-only edges (no props set) return `None` for every field.
8653    pub fn get_edge_prop(
8654        &self,
8655        edge_type: &str,
8656        src_key: &str,
8657        dst_key: &str,
8658        field: &str,
8659    ) -> Option<Value> {
8660        let etype = self.syms.get(edge_type)?;
8661        let src = self.ids.get(src_key)?;
8662        let dst = self.ids.get(dst_key)?;
8663        self.edge_props_view().get(etype, src, dst, field)
8664    }
8665
8666    /// Lex → parse → plan → execute `cypher` over a read-only view.
8667    /// Every pipeline `Err(String)` becomes `GraphError::QueryError` with a
8668    /// stage prefix (`lex:` / `parse:` / `plan:` / `execute:`).
8669    pub fn query(&self, cypher: &str, params: &BTreeMap<String, Value>) -> Result<ResultSet> {
8670        let tokens = lex(cypher).map_err(|e| GraphError::QueryError {
8671            detail: format!("lex: {e}"),
8672        })?;
8673        let union = parse_read(&tokens).map_err(|e| GraphError::QueryError {
8674            detail: format!("parse: {e}"),
8675        })?;
8676        let t0 = std::time::Instant::now();
8677        let result = execute_union(&self.view(), &union, &Params(params)).map_err(|e| {
8678            GraphError::QueryError {
8679                detail: format!("execute: {e}"),
8680            }
8681        });
8682        let elapsed_ms = t0.elapsed().as_millis() as u64;
8683        let threshold = self.slow_query_threshold_ms;
8684        if threshold > 0 && elapsed_ms >= threshold {
8685            eprintln!("[mushroomdb] slow query ({elapsed_ms}ms): {cypher}");
8686            let entry = SlowQueryEntry {
8687                ms: elapsed_ms,
8688                query: cypher.to_string(),
8689                at_commit: self.commit_seq,
8690            };
8691            if let Ok(mut log) = self.slow_queries.lock() {
8692                if log.entries.len() == SLOW_QUERY_RING_CAP {
8693                    log.entries.pop_front();
8694                }
8695                log.entries.push_back(entry);
8696                log.total += 1;
8697            }
8698        }
8699        result
8700    }
8701
8702    /// Convenience entry-point that accepts a slice of `(name, value)` pairs
8703    /// instead of a pre-built `BTreeMap`.  Equivalent to building the map and
8704    /// calling [`GraphDb::query`].
8705    pub fn query_with_params(&self, cypher: &str, params: &[(&str, Value)]) -> Result<ResultSet> {
8706        let map: BTreeMap<String, Value> = params
8707            .iter()
8708            .map(|(k, v)| (k.to_string(), v.clone()))
8709            .collect();
8710        self.query(cypher, &map)
8711    }
8712
8713    /// Execute a Cypher write statement (CREATE / MATCH…SET / MATCH…DELETE / MERGE).
8714    ///
8715    /// All mutations flow through the same `insert_node` / `set_prop` /
8716    /// `delete_edge` / `insert_edge` path as the Rust API so the rule engine
8717    /// fires and the WAL captures everything with one fsync per statement.
8718    ///
8719    /// Returns a one-row [`ResultSet`] with columns `created`, `properties_set`,
8720    /// and `deleted` matching the write-result contract.
8721    ///
8722    /// **Mutation routing**: mutations are collected into a single
8723    /// [`BatchBuilder`] and committed atomically (one WAL `Batch` frame, one
8724    /// fsync). The MATCH phase for SET/DELETE uses a read-only `execute` call
8725    /// over `self.view()` — the borrow is dropped before the batch is opened.
8726    ///
8727    /// **Limitations (v1)**:
8728    /// - SET RHS must be a literal, `$param`, or arithmetic; bare property copy → named error.
8729    /// - `DETACH DELETE n` → calls `delete_node` for each matched node (removes all edges).
8730    /// - Bare `DELETE n` → error if n has any incident edges; succeeds for isolated nodes.
8731    /// - MERGE supports `ON CREATE SET` / `ON MATCH SET` in the same write batch.
8732    /// - Deleting a derived edge → named error "cannot delete derived edge".
8733    pub fn query_write(
8734        &mut self,
8735        cypher: &str,
8736        params: &BTreeMap<String, Value>,
8737    ) -> Result<ResultSet> {
8738        let tokens = lex(cypher).map_err(|e| GraphError::QueryError {
8739            detail: format!("lex: {e}"),
8740        })?;
8741        let stmt = parse_write(&tokens).map_err(|e| GraphError::QueryError {
8742            detail: format!("parse: {e}"),
8743        })?;
8744        self.exec_write_stmt(stmt, params)
8745    }
8746
8747    fn exec_write_stmt(
8748        &mut self,
8749        stmt: WriteStatement,
8750        params: &BTreeMap<String, Value>,
8751    ) -> Result<ResultSet> {
8752        match stmt {
8753            WriteStatement::Create(s) => self.exec_create(s, params),
8754            WriteStatement::MatchSet(s) => self.exec_match_set(s, params),
8755            WriteStatement::MatchDelete(s) => self.exec_match_delete(s, params),
8756            WriteStatement::MatchDeleteNode(s) => self.exec_match_delete_node(s, params),
8757            WriteStatement::Merge(s) => self.exec_merge(s, params),
8758        }
8759    }
8760
8761    fn exec_create(
8762        &mut self,
8763        stmt: core_query::cypher::CreateStmt,
8764        params: &BTreeMap<String, Value>,
8765    ) -> Result<ResultSet> {
8766        // Extract the node key from props: require a string-valued `id` field.
8767        let mut var_to_key: BTreeMap<String, String> = BTreeMap::new();
8768        for node in &stmt.nodes {
8769            let var = node.var.as_deref().unwrap_or("_cn0");
8770            let key = node
8771                .props
8772                .iter()
8773                .find(|(f, _)| f == "id")
8774                .and_then(|(_, v)| {
8775                    if let Value::Str(s) = v {
8776                        Some(s.clone())
8777                    } else {
8778                        None
8779                    }
8780                })
8781                .ok_or_else(|| GraphError::QueryError {
8782                    detail: format!(
8783                        "CREATE node ({}:{}) requires a string 'id' property",
8784                        var, node.label
8785                    ),
8786                })?;
8787            var_to_key.insert(var.to_string(), key);
8788        }
8789
8790        let mut batch = self.batch();
8791        let mut created: usize = 0;
8792        for node in &stmt.nodes {
8793            let var = node.var.as_deref().unwrap_or("_cn0");
8794            let key = &var_to_key[var];
8795            batch.insert_node(&node.label, key, node.props.clone());
8796            created += 1;
8797        }
8798        for edge in &stmt.edges {
8799            let src_key = var_to_key
8800                .get(&edge.src_var)
8801                .ok_or_else(|| GraphError::QueryError {
8802                    detail: format!("CREATE edge src variable '{}' is not bound", edge.src_var),
8803                })?;
8804            let dst_key = var_to_key
8805                .get(&edge.dst_var)
8806                .ok_or_else(|| GraphError::QueryError {
8807                    detail: format!("CREATE edge dst variable '{}' is not bound", edge.dst_var),
8808                })?;
8809            batch.insert_edge(&edge.etype, src_key, dst_key);
8810        }
8811        batch.commit()?;
8812
8813        // Optional RETURN clause: project created bindings as a read result.
8814        if let Some(returns) = stmt.returns {
8815            // Each created node is looked up by its key via a separate MATCH pattern.
8816            // Multiple single-node patterns cross-join to produce 1 output row with
8817            // all variables bound (each pattern returns exactly 1 row).
8818            let patterns: Vec<Pattern> = stmt
8819                .nodes
8820                .iter()
8821                .map(|node| {
8822                    let var = node.var.as_deref().unwrap_or("_cn0");
8823                    let key = var_to_key[var].clone();
8824                    Pattern {
8825                        start: NodePat {
8826                            var: Some(var.to_string()),
8827                            label: Some(node.label.clone()),
8828                            props: vec![("id".to_string(), Operand::Lit(Value::Str(key)))],
8829                        },
8830                        chain: vec![],
8831                        shortest: false,
8832                    }
8833                })
8834                .collect();
8835            let q = Query {
8836                matches: patterns,
8837                optional_clauses: vec![],
8838                where_expr: None,
8839                unwinds: vec![],
8840                post_unwind_where: None,
8841                stages: vec![],
8842                returns,
8843                distinct: false,
8844                order_by: vec![],
8845                skip: None,
8846                limit: None,
8847            };
8848            let ops = plan(&q).map_err(|e| GraphError::QueryError {
8849                detail: format!("plan: {e}"),
8850            })?;
8851            return execute(&self.view(), &ops, &Params(params)).map_err(|e| {
8852                GraphError::QueryError {
8853                    detail: format!("execute: {e}"),
8854                }
8855            });
8856        }
8857
8858        let mut rs = write_result_set();
8859        rs.push_row(vec![
8860            Some(Value::Int(created as i64)),
8861            Some(Value::Int(0)),
8862            Some(Value::Int(0)),
8863        ]);
8864        Ok(rs)
8865    }
8866
8867    fn exec_match_set(
8868        &mut self,
8869        stmt: core_query::cypher::MatchSetStmt,
8870        params: &BTreeMap<String, Value>,
8871    ) -> Result<ResultSet> {
8872        let project_returns = stmt.returns.clone();
8873        // Collect unique node vars targeted by SET clauses, plus RETURN bindings
8874        // so the post-write projection can look them up by key.
8875        let mut set_vars: Vec<String> = Vec::new();
8876        for s in &stmt.sets {
8877            if !set_vars.contains(&s.var) {
8878                set_vars.push(s.var.clone());
8879            }
8880        }
8881        let rel_vars = pattern_rel_vars(&stmt.matches);
8882        let mut lookup_vars = set_vars.clone();
8883        for v in pattern_node_vars(&stmt.matches) {
8884            add_var(&mut lookup_vars, &v);
8885        }
8886        if let Some(ref returns) = project_returns {
8887            for v in ret_node_vars(returns) {
8888                if !rel_vars.iter().any(|r| r == &v) {
8889                    add_var(&mut lookup_vars, &v);
8890                }
8891            }
8892        }
8893
8894        // Synthesize a read query: MATCH … WHERE … RETURN <lookup_vars>, <set_values…>
8895        // SET values are projected as ScalarExpr items so that arithmetic expressions
8896        // (e.g. `SET n.score = n.score * 1.5`) are evaluated in the matched-row context.
8897        let mut set_returns: Vec<RetItem> = lookup_vars
8898            .iter()
8899            .map(|v| RetItem {
8900                value: RetVal::Var(v.clone()),
8901                alias: None,
8902            })
8903            .collect();
8904        // One computed column per SET clause; alias is `__sv_<i>`.
8905        let set_val_cols: Vec<String> = stmt
8906            .sets
8907            .iter()
8908            .enumerate()
8909            .map(|(i, _)| format!("__sv_{i}"))
8910            .collect();
8911        for (sc, col) in stmt.sets.iter().zip(&set_val_cols) {
8912            set_returns.push(RetItem {
8913                value: RetVal::ScalarExpr(sc.value.clone()),
8914                alias: Some(col.clone()),
8915            });
8916        }
8917        // Capture relationship types while r is bound; SET does not change them.
8918        for r in &rel_vars {
8919            set_returns.push(RetItem {
8920                value: RetVal::FuncCall {
8921                    name: "type".into(),
8922                    args: vec![Operand::Var(r.clone())],
8923                },
8924                alias: Some(rel_type_alias(r)),
8925            });
8926        }
8927
8928        let read_q = Query {
8929            matches: stmt.matches.clone(),
8930            optional_clauses: vec![],
8931            where_expr: stmt.where_expr.clone(),
8932            unwinds: vec![],
8933            post_unwind_where: None,
8934            stages: vec![],
8935            returns: set_returns,
8936            distinct: false,
8937            order_by: vec![],
8938            skip: None,
8939            limit: None,
8940        };
8941        let ops = plan(&read_q).map_err(|e| GraphError::QueryError {
8942            detail: format!("plan: {e}"),
8943        })?;
8944        // MATCH phase is read-only; borrow ends before batch opens.
8945        //
8946        // When a role-scoped write is in flight, run the MATCH read through
8947        // view_masked so hidden nodes are invisible → hidden ≡ absent ≡
8948        // zero-rows (no SetProp ops generated, no existence-oracle 403).
8949        // Full-authority writes (pending_write_authz=None) keep view().
8950        let match_rs = {
8951            let mask_opt = self.pending_write_authz.as_ref().map(|a| a.mask.clone());
8952            if let Some(ref mask) = mask_opt {
8953                execute(&self.view_masked(mask), &ops, &Params(params))
8954            } else {
8955                execute(&self.view(), &ops, &Params(params))
8956            }
8957        }
8958        .map_err(|e| GraphError::QueryError {
8959            detail: format!("execute: {e}"),
8960        })?;
8961
8962        // Collect (key, field, value) for each matched row × each SET clause.
8963        let mut set_ops: Vec<(String, String, Value)> = Vec::new();
8964        for row_i in 0..match_rs.len() {
8965            for (sc, col) in stmt.sets.iter().zip(&set_val_cols) {
8966                let key = match match_rs.get(row_i, &sc.var) {
8967                    Some(Value::Str(k)) => k.clone(),
8968                    _ => {
8969                        return Err(GraphError::QueryError {
8970                            detail: format!(
8971                                "SET variable '{}' did not resolve to a node key",
8972                                sc.var
8973                            ),
8974                        })
8975                    }
8976                };
8977                // The SET value was already evaluated by the executor.
8978                let value = match match_rs.get(row_i, col) {
8979                    Some(v) => v.clone(),
8980                    None => {
8981                        return Err(GraphError::QueryError {
8982                            detail: format!(
8983                                "SET value for {}.{} evaluated to null",
8984                                sc.var, sc.field
8985                            ),
8986                        })
8987                    }
8988                };
8989                set_ops.push((key, sc.field.clone(), value));
8990            }
8991        }
8992
8993        // Apply as one atomic batch.
8994        let props_set = set_ops.len();
8995        let mut batch = self.batch();
8996        for (key, field, value) in set_ops {
8997            batch.set_prop(&key, &field, value);
8998        }
8999        batch.commit()?;
9000
9001        if let Some(returns) = project_returns {
9002            return project_set_return_rows(self, &rel_vars, &match_rs, &returns, params);
9003        }
9004
9005        let mut rs = write_result_set();
9006        rs.push_row(vec![
9007            Some(Value::Int(0)),
9008            Some(Value::Int(props_set as i64)),
9009            Some(Value::Int(0)),
9010        ]);
9011        Ok(rs)
9012    }
9013
9014    fn exec_match_delete(
9015        &mut self,
9016        stmt: core_query::cypher::MatchDeleteStmt,
9017        params: &BTreeMap<String, Value>,
9018    ) -> Result<ResultSet> {
9019        // Collect unique node vars needed to identify edge endpoints.
9020        let mut node_vars: Vec<String> = Vec::new();
9021        for ed in &stmt.deletes {
9022            if !node_vars.contains(&ed.src_var) {
9023                node_vars.push(ed.src_var.clone());
9024            }
9025            if !node_vars.contains(&ed.dst_var) {
9026                node_vars.push(ed.dst_var.clone());
9027            }
9028        }
9029
9030        // Synthesize read query.
9031        let returns: Vec<RetItem> = node_vars
9032            .iter()
9033            .map(|v| RetItem {
9034                value: RetVal::Var(v.clone()),
9035                alias: None,
9036            })
9037            .collect();
9038        let read_q = Query {
9039            matches: stmt.matches,
9040            optional_clauses: vec![],
9041            where_expr: stmt.where_expr,
9042            unwinds: vec![],
9043            post_unwind_where: None,
9044            stages: vec![],
9045            returns,
9046            distinct: false,
9047            order_by: vec![],
9048            skip: None,
9049            limit: None,
9050        };
9051        let ops = plan(&read_q).map_err(|e| GraphError::QueryError {
9052            detail: format!("plan: {e}"),
9053        })?;
9054        // Role-scoped writes: mask the MATCH read phase so hidden nodes are
9055        // invisible → hidden ≡ absent ≡ zero-rows (spec §3.1, hidden ≡ absent).
9056        let match_rs = {
9057            let mask_opt = self.pending_write_authz.as_ref().map(|a| a.mask.clone());
9058            if let Some(ref mask) = mask_opt {
9059                execute(&self.view_masked(mask), &ops, &Params(params))
9060            } else {
9061                execute(&self.view(), &ops, &Params(params))
9062            }
9063        }
9064        .map_err(|e| GraphError::QueryError {
9065            detail: format!("execute: {e}"),
9066        })?;
9067
9068        // Collect (etype, src_key, dst_key) for each row × each delete target.
9069        let mut del_ops: Vec<(String, String, String)> = Vec::new();
9070        for row_i in 0..match_rs.len() {
9071            for ed in &stmt.deletes {
9072                let src_key = match match_rs.get(row_i, &ed.src_var) {
9073                    Some(Value::Str(k)) => k.clone(),
9074                    _ => {
9075                        return Err(GraphError::QueryError {
9076                            detail: format!(
9077                                "DELETE src variable '{}' did not resolve to a node key",
9078                                ed.src_var
9079                            ),
9080                        })
9081                    }
9082                };
9083                let dst_key = match match_rs.get(row_i, &ed.dst_var) {
9084                    Some(Value::Str(k)) => k.clone(),
9085                    _ => {
9086                        return Err(GraphError::QueryError {
9087                            detail: format!(
9088                                "DELETE dst variable '{}' did not resolve to a node key",
9089                                ed.dst_var
9090                            ),
9091                        })
9092                    }
9093                };
9094                del_ops.push((ed.etype.clone(), src_key, dst_key));
9095            }
9096        }
9097
9098        // Apply as one atomic batch.
9099        let deleted = del_ops.len();
9100        let mut batch = self.batch();
9101        for (etype, src_key, dst_key) in del_ops {
9102            batch.delete_edge(&etype, &src_key, &dst_key);
9103        }
9104        batch.commit().map_err(|e| match e {
9105            GraphError::RuleOwned { .. } => GraphError::QueryError {
9106                detail: "cannot delete derived edge; retract via the rule or change the property"
9107                    .to_string(),
9108            },
9109            other => other,
9110        })?;
9111
9112        let mut rs = write_result_set();
9113        rs.push_row(vec![
9114            Some(Value::Int(0)),
9115            Some(Value::Int(0)),
9116            Some(Value::Int(deleted as i64)),
9117        ]);
9118        Ok(rs)
9119    }
9120
9121    /// Execute `MATCH … [DETACH] DELETE <node_var> [, …]`.
9122    ///
9123    /// Collects the matching node keys via an ephemeral read query, then calls
9124    /// `delete_node` on each one.  When `stmt.detach` is `false` (bare DELETE)
9125    /// the executor first checks that the node has no incident edges; if any
9126    /// remain it returns a named error matching openCypher semantics.
9127    fn exec_match_delete_node(
9128        &mut self,
9129        stmt: MatchDeleteNodeStmt,
9130        params: &BTreeMap<String, Value>,
9131    ) -> Result<ResultSet> {
9132        // Build a read query returning only the node keys we need.
9133        let returns: Vec<RetItem> = stmt
9134            .node_vars
9135            .iter()
9136            .map(|v| RetItem {
9137                value: RetVal::Var(v.clone()),
9138                alias: None,
9139            })
9140            .collect();
9141        let read_q = Query {
9142            matches: stmt.matches,
9143            optional_clauses: vec![],
9144            where_expr: stmt.where_expr,
9145            unwinds: vec![],
9146            post_unwind_where: None,
9147            stages: vec![],
9148            returns,
9149            distinct: false,
9150            order_by: vec![],
9151            skip: None,
9152            limit: None,
9153        };
9154        let ops = plan(&read_q).map_err(|e| GraphError::QueryError {
9155            detail: format!("plan: {e}"),
9156        })?;
9157        // Role-scoped writes: mask the MATCH read phase so hidden nodes are
9158        // invisible → hidden ≡ absent ≡ zero-rows (spec §3.1, hidden ≡ absent).
9159        let match_rs = {
9160            let mask_opt = self.pending_write_authz.as_ref().map(|a| a.mask.clone());
9161            if let Some(ref mask) = mask_opt {
9162                execute(&self.view_masked(mask), &ops, &Params(params))
9163            } else {
9164                execute(&self.view(), &ops, &Params(params))
9165            }
9166        }
9167        .map_err(|e| GraphError::QueryError {
9168            detail: format!("execute: {e}"),
9169        })?;
9170
9171        // Collect unique node keys to delete (deduplicate across rows × vars).
9172        let mut keys: Vec<String> = Vec::new();
9173        for row_i in 0..match_rs.len() {
9174            for var in &stmt.node_vars {
9175                if let Some(Value::Str(k)) = match_rs.get(row_i, var) {
9176                    if !keys.contains(k) {
9177                        keys.push(k.clone());
9178                    }
9179                }
9180            }
9181        }
9182
9183        if !stmt.detach {
9184            // openCypher bare DELETE: error if any matched node has incident edges.
9185            for key in &keys {
9186                if let Some(id) = self.ids.get(key) {
9187                    let tv = self.topo_view();
9188                    let has_edges = tv.etypes().any(|et| {
9189                        !tv.neighbors(et, Direction::Out, id).is_empty()
9190                            || !tv.neighbors(et, Direction::In, id).is_empty()
9191                    });
9192                    if has_edges {
9193                        return Err(GraphError::QueryError {
9194                            detail: format!(
9195                                "Cannot delete node `{key}` because it still has incident edges. \
9196                                 Use DETACH DELETE to remove the node and all its edges."
9197                            ),
9198                        });
9199                    }
9200                }
9201            }
9202        }
9203
9204        let mut nodes_deleted = 0i64;
9205        let mut edges_deleted = 0i64;
9206        for key in keys {
9207            match self.delete_node(&key) {
9208                Ok(report) => {
9209                    nodes_deleted += 1;
9210                    edges_deleted += (report.manual_edges + report.derived_edges) as i64;
9211                }
9212                Err(GraphError::KeyNotFound { .. }) => {
9213                    // Node may have been deleted by an earlier iteration (e.g., via
9214                    // multiple MATCH rows for the same node).  Safe to skip.
9215                }
9216                Err(e) => return Err(e),
9217            }
9218        }
9219
9220        let mut rs = write_result_set();
9221        rs.push_row(vec![
9222            Some(Value::Int(0)),
9223            Some(Value::Int(0)),
9224            Some(Value::Int(nodes_deleted + edges_deleted)),
9225        ]);
9226        Ok(rs)
9227    }
9228
9229    fn exec_merge(
9230        &mut self,
9231        stmt: core_query::cypher::MergeStmt,
9232        params: &BTreeMap<String, Value>,
9233    ) -> Result<ResultSet> {
9234        // MERGE: check if a node with the given key already exists.
9235        let key = match &stmt.key_value {
9236            Value::Str(s) => s.clone(),
9237            _ => {
9238                return Err(GraphError::QueryError {
9239                    detail: format!(
9240                        "MERGE key value must be a string (got {:?})",
9241                        stmt.key_value
9242                    ),
9243                })
9244            }
9245        };
9246
9247        if let Some(var) = stmt.var.as_deref() {
9248            for sc in stmt.on_create.iter().chain(&stmt.on_match) {
9249                if sc.var != var {
9250                    return Err(GraphError::QueryError {
9251                        detail: format!(
9252                            "SET variable '{}' does not match MERGE variable '{var}'",
9253                            sc.var
9254                        ),
9255                    });
9256                }
9257            }
9258        }
9259
9260        // ── MERGE authz pre-check (when role-scoped) ─────────────────────────
9261        //
9262        // MERGE scope precondition: check create OR update scope for the
9263        // declared label BEFORE calling `has_node` (timing-oracle closure,
9264        // spec §6.2 "MERGE visibility oracle" item: hidden ≡ absent for
9265        // unscoped roles — the scope denial fires without touching the key store).
9266        //
9267        // Clone to avoid holding a borrow on `self.pending_write_authz` while
9268        // also calling `self.ids.get(key)`.
9269        let merge_existed: bool = if let Some(authz) = self.pending_write_authz.clone() {
9270            let has_create = authz.scope.create_labels.contains(&stmt.label);
9271            let has_update = authz.scope.update_labels.contains(&stmt.label);
9272            if !has_create && !has_update {
9273                // Scope-before-lookup: 403 without has_node call (timing oracle
9274                // closure — see test_merge_unscoped_no_key_lookup).
9275                return Err(GraphError::RoleWriteDenied {
9276                    reason: format!(
9277                        "role-bound token: label '{}' not in write scope (create_labels)",
9278                        stmt.label
9279                    ),
9280                });
9281            }
9282            // Key lookup under mask.
9283            match self.ids.get(key.as_str()) {
9284                Some(id) if authz.mask.contains_id(id) => {
9285                    // Visible: must have update scope to proceed to match arm.
9286                    if !has_update {
9287                        return Err(GraphError::RoleWriteDenied {
9288                            reason: format!(
9289                                "role-bound token: label '{}' not in write scope (update_labels)",
9290                                stmt.label
9291                            ),
9292                        });
9293                    }
9294                    true // existed = true → match arm
9295                }
9296                Some(_) => {
9297                    // Hidden: same error as absent to the role (spec §3.1/§3.3).
9298                    return Err(GraphError::RoleWriteDenied {
9299                        reason: "role-bound token: target node not visible".into(),
9300                    });
9301                }
9302                None => {
9303                    // Absent: must have create scope to proceed to the create arm.
9304                    //
9305                    // Update-only roles (create_labels empty, update_labels set):
9306                    // return the SAME "not visible" error as the hidden-key branch
9307                    // so hidden ≡ absent — no distinguishing oracle (spec §6.1
9308                    // "confirm existence of hidden nodes: No").
9309                    //
9310                    // Create-scoped roles (has_create=true): absent → create arm
9311                    // as before.  The accepted structural key-existence disclosure
9312                    // (§THREAT-MODEL) applies only when the role holds create scope.
9313                    if !has_create {
9314                        return Err(GraphError::RoleWriteDenied {
9315                            reason: "role-bound token: target node not visible".into(),
9316                        });
9317                    }
9318                    false // existed = false → create arm
9319                }
9320            }
9321        } else {
9322            // Full authority: use the existing non-masked has_node check.
9323            self.has_node(&key)
9324        };
9325
9326        let existed = merge_existed;
9327        let mut created = 0i64;
9328        if !existed || !stmt.on_match.is_empty() {
9329            let mut batch = self.batch();
9330            if !existed {
9331                let props = vec![(stmt.key_field.clone(), stmt.key_value.clone())];
9332                batch.insert_node(&stmt.label, &key, props);
9333                for sc in &stmt.on_create {
9334                    let value = resolve_merge_set_value(&sc.value, params)?;
9335                    batch.set_prop(&key, &sc.field, value);
9336                }
9337                created = 1;
9338            } else {
9339                for sc in &stmt.on_match {
9340                    let value = resolve_merge_set_value(&sc.value, params)?;
9341                    batch.set_prop(&key, &sc.field, value);
9342                }
9343            }
9344            batch.commit()?;
9345        }
9346
9347        // Refresh the role mask so the just-created node is visible to this
9348        // statement's RETURN (read-after-write). Safe: create_labels ⊆ read labels
9349        // (apply_schema subset rule), so the new node's label is already in the
9350        // role's read scope — this never widens beyond the role's declared labels.
9351        if !existed {
9352            if let Some(role) = self.pending_write_authz.as_ref().map(|a| a.role.clone()) {
9353                let new_mask = self.mask_for_role(&role)?;
9354                if let Some(a) = self.pending_write_authz.as_mut() {
9355                    a.mask = new_mask;
9356                }
9357            }
9358        }
9359
9360        // Optional RETURN clause: project the node (created or matched) as a read result.
9361        if let Some(returns) = stmt.returns {
9362            let var = stmt.var.as_deref().unwrap_or("_mn0");
9363            let q = Query {
9364                matches: vec![Pattern {
9365                    start: NodePat {
9366                        var: Some(var.to_string()),
9367                        label: Some(stmt.label.clone()),
9368                        props: vec![("id".to_string(), Operand::Lit(stmt.key_value.clone()))],
9369                    },
9370                    chain: vec![],
9371                    shortest: false,
9372                }],
9373                optional_clauses: vec![],
9374                where_expr: None,
9375                unwinds: vec![],
9376                post_unwind_where: None,
9377                stages: vec![],
9378                returns,
9379                distinct: false,
9380                order_by: vec![],
9381                skip: None,
9382                limit: None,
9383            };
9384            let ops = plan(&q).map_err(|e| GraphError::QueryError {
9385                detail: format!("plan: {e}"),
9386            })?;
9387            // Use view_masked when a role-scoped write is in flight so the
9388            // post-merge projection is consistent with the masked read phase.
9389            let mask_opt = self.pending_write_authz.as_ref().map(|a| a.mask.clone());
9390            return (if let Some(ref mask) = mask_opt {
9391                execute(&self.view_masked(mask), &ops, &Params(params))
9392            } else {
9393                execute(&self.view(), &ops, &Params(params))
9394            })
9395            .map_err(|e| GraphError::QueryError {
9396                detail: format!("execute: {e}"),
9397            });
9398        }
9399
9400        let mut rs = write_result_set();
9401        rs.push_row(vec![
9402            Some(Value::Int(created)),
9403            Some(Value::Int(0)),
9404            Some(Value::Int(0)),
9405        ]);
9406        Ok(rs)
9407    }
9408
9409    /// Return all rule-owned edges between `key_a` and `key_b` (either direction),
9410    /// annotated with rule name, edge type, direction, and weight.
9411    /// Results are sorted by (rule, edge_type).
9412    /// Returns `Err(KeyNotFound)` if either key is unknown.
9413    pub fn explain(&self, key_a: &str, key_b: &str) -> Result<Vec<Explanation>> {
9414        self.ensure_v8_base_sections_loaded();
9415        let id_a = self
9416            .ids
9417            .get(key_a)
9418            .ok_or_else(|| GraphError::KeyNotFound { key: key_a.into() })?;
9419        let id_b = self
9420            .ids
9421            .get(key_b)
9422            .ok_or_else(|| GraphError::KeyNotFound { key: key_b.into() })?;
9423
9424        let mut results = Vec::new();
9425
9426        // Walk the smaller incident set so explain is O(min(deg(a), deg(b)))
9427        // rather than O(total provenance).
9428        let scan = if self.engine.provenance_touching_len(id_a)
9429            <= self.engine.provenance_touching_len(id_b)
9430        {
9431            id_a
9432        } else {
9433            id_b
9434        };
9435        for (rule_name, etype, src, dst) in self.engine.provenance_touching(scan) {
9436            if !((src == id_a && dst == id_b) || (src == id_b && dst == id_a)) {
9437                continue;
9438            }
9439            let Some(rule_def) = self.engine.rules().find(|r| r.name == rule_name) else {
9440                continue;
9441            };
9442            let edge_type = match self.syms.resolve(etype) {
9443                Some(s) => s.to_string(),
9444                None => continue,
9445            };
9446            // Provenance (src, dst) ids come from the archived PROVENANCE section
9447            // (large, no eager CRC).  A corrupt section can produce ids that are
9448            // out of range; return Corrupt rather than panic.
9449            let src_key = self
9450                .ids
9451                .key_of(src)
9452                .ok_or_else(|| GraphError::Corrupt {
9453                    detail: format!("v8: provenance src id {src} not in id table"),
9454                })?
9455                .to_string();
9456            let dst_key = self
9457                .ids
9458                .key_of(dst)
9459                .ok_or_else(|| GraphError::Corrupt {
9460                    detail: format!("v8: provenance dst id {dst} not in id table"),
9461                })?
9462                .to_string();
9463            let stored = rule_def.weight_prop.as_deref().and_then(|prop| {
9464                self.edge_props_view()
9465                    .get(etype, src, dst, prop)
9466                    .and_then(|v| {
9467                        if let Value::Float(f) = v {
9468                            Some(f)
9469                        } else {
9470                            None
9471                        }
9472                    })
9473            });
9474            // Rules that store no weight (KeyMatch/FieldEqual defaults, auto-FK)
9475            // still have a score: recompute it from the predicate so explain
9476            // never reports "no score" for an edge the engine scored.  Via-hop
9477            // rules score over their via set, not over (src, dst), so leave
9478            // those None rather than report a number the rule did not produce.
9479            let weight = stored.or_else(|| {
9480                if rule_def.via_edge.is_some() {
9481                    return None;
9482                }
9483                let props_view = build_props_view(&self.props, &self.base);
9484                let src_get = |field: &str| props_view.get(src, field).map(|vr| vr.into_value());
9485                let dst_get = |field: &str| props_view.get(dst, field).map(|vr| vr.into_value());
9486                let src_view = NodeView {
9487                    key: &src_key,
9488                    props: &src_get,
9489                };
9490                let dst_view = NodeView {
9491                    key: &dst_key,
9492                    props: &dst_get,
9493                };
9494                evaluate(&rule_def.predicate, &src_view, &dst_view)
9495            });
9496            results.push(Explanation {
9497                rule: rule_name.to_string(),
9498                edge_type,
9499                src_key,
9500                dst_key,
9501                weight,
9502                predicate: PredicateSummary {
9503                    approximate: rule_def.approximate,
9504                    ..PredicateSummary::from(&rule_def.predicate)
9505                },
9506                via_edge: rule_def.via_edge.clone(),
9507            });
9508        }
9509
9510        results.sort_by(|a, b| a.rule.cmp(&b.rule).then(a.edge_type.cmp(&b.edge_type)));
9511        Ok(results)
9512    }
9513
9514    pub fn neighbors(&self, key: &str, edge_type: &str, dir: Direction) -> Result<Vec<String>> {
9515        let id = self
9516            .ids
9517            .get(key)
9518            .ok_or_else(|| GraphError::KeyNotFound { key: key.into() })?;
9519        let Some(sym) = self.syms.get(edge_type) else {
9520            return Ok(Vec::new());
9521        };
9522        self.topo_view()
9523            .neighbors(sym, dir, id)
9524            .iter()
9525            .map(|&n| {
9526                self.ids
9527                    .key_of(n)
9528                    .map(|k| k.to_string())
9529                    .ok_or_else(|| GraphError::Corrupt {
9530                        detail: format!("topology id {n} has no key"),
9531                    })
9532            })
9533            .collect::<Result<Vec<_>>>()
9534    }
9535
9536    /// Return the last-change commit sequence for `key`, or `None` if the node
9537    /// does not exist or has never been mutated since the last V5-V7 snapshot
9538    /// (horizon-bounded for legacy stores).
9539    ///
9540    /// The returned sequence is a monotonically increasing counter that starts
9541    /// at 1 for the first commit after `open` and increments with every
9542    /// successful write.  WAL replay at open also assigns sequences (1..N for N
9543    /// replayed frames), so sequences are consistent across snapshot+WAL cycles.
9544    ///
9545    /// For V5-V7 stores opened without a V8 snapshot, nodes that were present
9546    /// in the snapshot but not touched by any WAL frame will return `None`
9547    /// (horizon-bounded: CAS against such nodes is only safe after the first
9548    /// V8 snapshot or after the node is next mutated).
9549    pub fn last_changed(&self, key: &str) -> Option<u64> {
9550        let id = self.ids.get(key)?;
9551        self.last_change.get(&id).copied()
9552    }
9553
9554    /// The current commit sequence (number of successful commits since open,
9555    /// including WAL replay frames).  Useful for recording a baseline before
9556    /// a read-modify-write cycle.
9557    pub fn commit_seq(&self) -> u64 {
9558        self.commit_seq
9559    }
9560
9561    /// Check that all `preconds` are satisfied against the current db state.
9562    /// Returns `Err(GraphError::CasConflict)` on the first failing precondition.
9563    pub(crate) fn check_preconditions(&self, preconds: &[Precondition]) -> Result<()> {
9564        for precond in preconds {
9565            match precond {
9566                Precondition::NodeUnchangedSince { key, expected } => {
9567                    // Missing entry means the node predates the WAL window or
9568                    // does not exist; treat as 0 (before any commit).
9569                    let actual = self.last_changed(key).unwrap_or_default();
9570                    if actual != *expected {
9571                        return Err(GraphError::CasConflict {
9572                            key: key.clone(),
9573                            expected: *expected,
9574                            actual,
9575                        });
9576                    }
9577                }
9578                Precondition::NodeAbsent { key } => {
9579                    // Node must not exist (not live).
9580                    if self.ids.get(key).is_some() {
9581                        let actual = self.last_changed(key).unwrap_or(0);
9582                        return Err(GraphError::CasConflict {
9583                            key: key.clone(),
9584                            expected: u64::MAX,
9585                            actual,
9586                        });
9587                    }
9588                }
9589            }
9590        }
9591        Ok(())
9592    }
9593
9594    /// Apply a batch of mutations with compare-and-set preconditions.
9595    ///
9596    /// All preconditions are checked atomically before any operation is applied.
9597    /// If any precondition fails, the entire batch is rejected with
9598    /// [`GraphError::CasConflict`] and no WAL frame is written.
9599    ///
9600    /// # Returns
9601    /// `(nodes_inserted, edges_inserted)` on success, same as [`write_batch`].
9602    ///
9603    /// # Errors
9604    /// - [`GraphError::CasConflict`] if any precondition is not satisfied.
9605    /// - Any error that [`write_batch`] would return for the ops themselves.
9606    pub fn write_batch_cas(
9607        &mut self,
9608        preconds: Vec<Precondition>,
9609        ops: Vec<BatchOp>,
9610    ) -> Result<(usize, usize)> {
9611        self.check_preconditions(&preconds)?;
9612        self.commit_logged_batch(ops, None, None)
9613    }
9614
9615    /// Update the per-node last-change map for a WAL record at commit `seq`.
9616    ///
9617    /// Called after a successful apply to record which nodes were touched.
9618    /// For replay, called with the WAL-frame's replayed seq.
9619    ///
9620    /// Touch definition (see [`Precondition`] doc):
9621    /// - InsertNode / InsertNodeId / SetProp / SetPropId / RemoveProp → the node.
9622    /// - InsertEdge / InsertEdgeId / DeleteEdge → both src and dst.
9623    /// - DeleteNode → node tombstoned; last_changed() returns None so no update needed.
9624    /// - DerivedEdge markers, Intern, rule/view records → no-ops.
9625    /// - Batch → recurse into inner records.
9626    fn update_last_change_from_rec(&mut self, rec: &WalRecord, seq: u64) {
9627        match rec {
9628            WalRecord::InsertNode { key, .. }
9629            | WalRecord::SetProp { key, .. }
9630            | WalRecord::RemoveProp { key, .. } => {
9631                if let Some(id) = self.ids.get(key) {
9632                    self.last_change.insert(id, seq);
9633                }
9634            }
9635            WalRecord::InsertNodeId { key, .. } => {
9636                if let Some(id) = self.ids.get(key) {
9637                    self.last_change.insert(id, seq);
9638                }
9639            }
9640            WalRecord::SetPropId { id, .. } => {
9641                self.last_change.insert(*id, seq);
9642            }
9643            WalRecord::InsertEdge {
9644                src_key, dst_key, ..
9645            }
9646            | WalRecord::DeleteEdge {
9647                src_key, dst_key, ..
9648            } => {
9649                if let Some(src_id) = self.ids.get(src_key) {
9650                    self.last_change.insert(src_id, seq);
9651                }
9652                if let Some(dst_id) = self.ids.get(dst_key) {
9653                    self.last_change.insert(dst_id, seq);
9654                }
9655            }
9656            WalRecord::InsertEdgeId { src, dst, .. } => {
9657                self.last_change.insert(*src, seq);
9658                self.last_change.insert(*dst, seq);
9659            }
9660            // DeleteNode: node is tombstoned; last_changed(key) returns None for
9661            // deleted keys (ids.get() returns None post-tombstone), so no update needed.
9662            // History markers: state no-ops; the underlying mutation already
9663            // touched the relevant nodes' last_change entries.
9664            WalRecord::DeleteNode { .. }
9665            | WalRecord::DerivedEdgeAdded { .. }
9666            | WalRecord::DerivedEdgeRetracted { .. }
9667            | WalRecord::Intern { .. }
9668            | WalRecord::CreateRule { .. }
9669            | WalRecord::DeleteRule { .. }
9670            | WalRecord::RebuildRule { .. }
9671            | WalRecord::CreateView { .. }
9672            | WalRecord::DeleteView { .. }
9673            | WalRecord::EnableFulltext { .. }
9674            | WalRecord::DisableFulltext { .. }
9675            | WalRecord::EnableIndex { .. }
9676            | WalRecord::DisableIndex { .. } => {}
9677            // RenameNode: node id is stable; update last_change via the new key.
9678            // Called after apply(), so ids already reflects new_key.
9679            WalRecord::RenameNode { new_key, .. } => {
9680                if let Some(id) = self.ids.get(new_key) {
9681                    self.last_change.insert(id, seq);
9682                }
9683            }
9684            WalRecord::Batch(inner) => {
9685                for inner_rec in inner {
9686                    self.update_last_change_from_rec(inner_rec, seq);
9687                }
9688            }
9689        }
9690    }
9691
9692    pub fn node_count(&self) -> usize {
9693        self.ids.len()
9694    }
9695
9696    /// Configure archive retention: keep the `N` newest WAL archives at each
9697    /// [`snapshot_with`] call when `archive_wal: true`.
9698    ///
9699    /// `Some(N)` where N > 0 → prune oldest archives keeping the newest N.
9700    /// `Some(0)` or `None` → unlimited (no pruning).
9701    ///
9702    /// Pruning only ever happens inside [`snapshot_with`]; this method only
9703    /// stores the policy.  Archives below the retention limit are deleted
9704    /// oldest-first.  The horizon floor is updated so that
9705    /// [`was_linked`] / history APIs return `CommitOutOfRange` for commits
9706    /// in pruned archives rather than silently returning wrong data.
9707    pub fn set_wal_archive_retention(&mut self, keep: Option<u32>) {
9708        self.wal_archive_retention = keep;
9709    }
9710
9711    /// Delete any WAL archives that are fully below the current horizon floor.
9712    ///
9713    /// Orphaned archives arise when the floor is written first during retention
9714    /// pruning and then a crash interrupts the archive-delete sequence.  The
9715    /// opening cleanup ensures no subsequent read path sees stale data.
9716    ///
9717    /// Under the monotonic naming scheme, the archive name N equals the
9718    /// cumulative end-frame index of the archive in global commit space (i.e.
9719    /// the archive covers global frames `[prev_n, N)`).  An archive is
9720    /// fully orphaned when `N <= wal_horizon_floor`: all of its frames fall
9721    /// below the floor and have already been counted in it.
9722    fn cleanup_orphaned_archives(&mut self) -> Result<()> {
9723        if self.wal_horizon_floor == 0 {
9724            // Floor at 0 means no pruning has ever occurred; nothing to clean.
9725            return Ok(());
9726        }
9727        let archive_ns = self.fs.list_archives()?;
9728        for n in archive_ns {
9729            if n <= self.wal_horizon_floor {
9730                // Archive N ends at global frame N; all its frames are below
9731                // the floor (floor already accounts for them) → orphaned.
9732                self.fs.delete_archive(n).map_err(GraphError::Io)?;
9733            } else {
9734                // Archives are sorted ascending; first one above floor stops scan.
9735                break;
9736            }
9737        }
9738        Ok(())
9739    }
9740
9741    /// Collect all WAL frames from surviving archives (oldest-first) then the
9742    /// live WAL into one flat list, and return the total along with the number
9743    /// of archive frames at the front of the list.
9744    ///
9745    /// Commit indices into the returned list are LOCAL (0 = first frame of
9746    /// oldest surviving archive).  To obtain the GLOBAL index add
9747    /// `self.wal_horizon_floor`.
9748    fn all_frames(&self) -> Result<(Vec<WalRecord>, u64)> {
9749        let archive_ns = self.fs.list_archives()?;
9750        let mut all: Vec<WalRecord> = Vec::new();
9751        for n in archive_ns {
9752            let bytes = self.fs.read_archive(n)?;
9753            let (frames, _) = decode_all(&bytes);
9754            all.extend(frames);
9755        }
9756        let archive_count = all.len() as u64;
9757        let live_bytes = self.fs.read(FileId::Wal)?;
9758        let (live_frames, _) = decode_all(&live_bytes);
9759        all.extend(live_frames);
9760        Ok((all, archive_count))
9761    }
9762
9763    /// Return the total number of committed WAL frames visible in the current
9764    /// horizon window, including frames in surviving WAL archives.
9765    ///
9766    /// This is the exclusive upper bound for valid `at_commit` indices in
9767    /// `was_linked`. Valid indices are `wal_horizon_floor()..wal_total_commits()`.
9768    ///
9769    /// Returns the horizon floor when all surviving history is empty.
9770    pub fn wal_total_commits(&self) -> Result<u64> {
9771        let (frames, _) = self.all_frames()?;
9772        Ok(self.wal_horizon_floor + frames.len() as u64)
9773    }
9774
9775    /// The global frame index of the first commit reachable through surviving
9776    /// archives (0 when no archives have been pruned).
9777    pub fn wal_horizon_floor(&self) -> u64 {
9778        self.wal_horizon_floor
9779    }
9780
9781    /// Return the per-node change history for `key` by scanning the on-disk WAL.
9782    ///
9783    /// ## Horizon
9784    ///
9785    /// History reaches back only to the last WAL-truncating snapshot, exactly like `open_at`.
9786    /// Snapshots written with `keep_wal: true` preserve deeper history. This is the honest,
9787    /// zero-cost contract; a durable history log is out of scope.
9788    ///
9789    /// ## Derived edges
9790    ///
9791    /// Rule-created (derived) edges are **not** in the WAL and therefore do not appear in
9792    /// history. Only edges written directly by the application are recorded.
9793    ///
9794    /// ## Deleted nodes
9795    ///
9796    /// For nodes that have been deleted, dense-id records (SetPropId, InsertEdgeId) that
9797    /// predate the deletion may not resolve (the id is tombstoned in the live map). The
9798    /// string-keyed `DeleteNode` record still matches and produces a `NodeDeleted` entry.
9799    /// Prop/edge history of a deleted node may therefore be partially unresolvable.
9800    ///
9801    /// ## Dense-id edge entries and tombstoned partners
9802    ///
9803    /// Edge entries from dense-id WAL records (`InsertEdgeId`) are omitted when the partner
9804    /// endpoint's dense id is tombstoned. As a result, a live node's history can contain an
9805    /// `EdgeRemoved` (string-keyed, always resolves) without a corresponding `EdgeAdded`.
9806    /// Build commit-bounded alias intervals for `queried_key`.
9807    ///
9808    /// Returns a list of `(key, valid_from_inclusive, valid_until_exclusive)` tuples.
9809    /// A record written under `key` at commit `c` matches the queried identity iff
9810    /// `c >= valid_from && (valid_until.is_none() || c < valid_until)`.
9811    ///
9812    /// Each alias entry carries both a lower and an upper bound so that key-reuse
9813    /// after a rename is handled correctly: if "a" is renamed to "b" at commit 5,
9814    /// then a NEW node is created as "a" at commit 7 and renamed to "c" at commit 10,
9815    /// querying "c" must NOT surface identity-1's events (commits 0–4 under "a");
9816    /// only identity-2's events (commits 7–9 under "a") are in scope.
9817    ///
9818    /// Only **forward aliasing**: querying the *new* key surfaces events written
9819    /// under the *old* key.  The reverse direction is not supported.
9820    fn build_key_alias_intervals(
9821        &self,
9822        frames: &[core_storage::wal::WalRecord],
9823        queried_key: &str,
9824    ) -> Vec<(String, u64, Option<u64>)> {
9825        use core_storage::wal::WalRecord;
9826
9827        // Pre-pass: build reverse_rename and key_starts maps.
9828        let mut reverse_rename: HashMap<String, (String, u64)> = HashMap::new();
9829        let mut key_starts: HashMap<String, Vec<u64>> = HashMap::new();
9830
9831        for (local_i, frame) in frames.iter().enumerate() {
9832            let commit = self.wal_horizon_floor + local_i as u64;
9833            let records: &[WalRecord] = match frame {
9834                WalRecord::Batch(inner) => inner.as_slice(),
9835                single => std::slice::from_ref(single),
9836            };
9837            for rec in records {
9838                match rec {
9839                    WalRecord::InsertNode { key, .. } | WalRecord::InsertNodeId { key, .. } => {
9840                        key_starts.entry(key.clone()).or_default().push(commit);
9841                    }
9842                    WalRecord::RenameNode { old_key, new_key } => {
9843                        // new_key came into existence at this commit.
9844                        key_starts.entry(new_key.clone()).or_default().push(commit);
9845                        // Record the reverse rename: new_key was introduced by renaming old_key.
9846                        reverse_rename.insert(new_key.clone(), (old_key.clone(), commit));
9847                    }
9848                    _ => {}
9849                }
9850            }
9851        }
9852
9853        // Build alias intervals by following the reverse rename chain.
9854        let mut result: Vec<(String, u64, Option<u64>)> = Vec::new();
9855        let mut current_key = queried_key.to_string();
9856        let mut current_valid_until: Option<u64> = None;
9857
9858        loop {
9859            // valid_from: the most recent commit where current_key was assigned to this
9860            // identity.  For aliases (valid_until = Some(vu)), find the last start event
9861            // for the key strictly before vu — this is where the alias's occupancy by
9862            // this identity began, correctly excluding prior identities that reused the key.
9863            let valid_from = if let Some(vu) = current_valid_until {
9864                key_starts
9865                    .get(&current_key)
9866                    .and_then(|starts| starts.iter().rev().find(|&&s| s < vu).copied())
9867                    .unwrap_or(self.wal_horizon_floor)
9868            } else {
9869                // Queried key — no upper bound; may have been introduced at any commit.
9870                self.wal_horizon_floor
9871            };
9872
9873            result.push((current_key.clone(), valid_from, current_valid_until));
9874
9875            match reverse_rename.get(&current_key) {
9876                Some((old_key, rename_commit)) => {
9877                    current_valid_until = Some(*rename_commit);
9878                    current_key = old_key.clone();
9879                }
9880                None => break,
9881            }
9882        }
9883
9884        result
9885    }
9886
9887    /// Returns true if `record_key` matches any alias interval that covers `commit`.
9888    fn aliases_match(
9889        intervals: &[(String, u64, Option<u64>)],
9890        record_key: &str,
9891        commit: u64,
9892    ) -> bool {
9893        intervals
9894            .iter()
9895            .any(|(k, vf, vu)| k == record_key && commit >= *vf && vu.is_none_or(|u| commit < u))
9896    }
9897
9898    /// Return the change history of node `key` by scanning the on-disk WAL.
9899    ///
9900    /// ## Horizon
9901    ///
9902    /// History reaches back only as far as the retained WAL. The returned
9903    /// [`HistoryResult`](crate::history::HistoryResult) carries `total_commits`
9904    /// (the exclusive upper bound for valid commit indices) and `horizon` (the
9905    /// oldest commit still reachable). When `horizon > 0`, older events were
9906    /// pruned and are not in `items`.
9907    pub fn node_history(
9908        &self,
9909        key: &str,
9910    ) -> Result<crate::history::HistoryResult<crate::history::HistoryEntry>> {
9911        use crate::history::{HistoryChange, HistoryEntry, HistoryResult};
9912        use core_storage::wal::WalRecord;
9913
9914        let (frames, _) = self.all_frames()?;
9915        let total_commits = self.wal_horizon_floor + frames.len() as u64;
9916
9917        // Resolve commit-bounded alias intervals for `key` (handles renames in the WAL).
9918        let alias_intervals = self.build_key_alias_intervals(&frames, key);
9919
9920        let mut out: Vec<HistoryEntry> = Vec::new();
9921
9922        for (local_i, frame) in frames.iter().enumerate() {
9923            let commit = self.wal_horizon_floor + local_i as u64;
9924            // Collect the inner records to process — Batch is one commit, single records are one commit.
9925            let records: &[WalRecord] = match frame {
9926                WalRecord::Batch(inner) => inner.as_slice(),
9927                single => std::slice::from_ref(single),
9928            };
9929
9930            for rec in records {
9931                let change = match rec {
9932                    WalRecord::InsertNode { label, key: k, .. }
9933                        if Self::aliases_match(&alias_intervals, k, commit) =>
9934                    {
9935                        Some(HistoryChange::NodeInserted {
9936                            label: label.clone(),
9937                        })
9938                    }
9939                    WalRecord::InsertNodeId { label, key: k, .. }
9940                        if Self::aliases_match(&alias_intervals, k, commit) =>
9941                    {
9942                        let label_str = match self.syms.resolve(*label) {
9943                            Some(s) => s.to_string(),
9944                            None => continue,
9945                        };
9946                        Some(HistoryChange::NodeInserted { label: label_str })
9947                    }
9948                    WalRecord::SetProp {
9949                        key: k,
9950                        field,
9951                        value,
9952                    } if Self::aliases_match(&alias_intervals, k, commit) => {
9953                        Some(HistoryChange::PropSet {
9954                            field: field.clone(),
9955                            value: value.clone(),
9956                        })
9957                    }
9958                    WalRecord::SetPropId { id, field, value } => {
9959                        // Use key_of_historical (not key_of) so a node's prop_set
9960                        // events remain visible after the node is later deleted:
9961                        // key_of returns None for a tombstoned id, which would
9962                        // silently drop every PropSet between insert and delete.
9963                        // Mirrors the InsertEdgeId arm below and edge_history's
9964                        // own id-keyed arms.
9965                        match self.ids.key_of_historical(*id) {
9966                            // key_of_historical returns the last-known (possibly
9967                            // post-rename, possibly post-delete) key; compare to queried key.
9968                            Some(resolved) if resolved == key => {
9969                                let field_str = match self.syms.resolve(*field) {
9970                                    Some(s) => s.to_string(),
9971                                    None => continue,
9972                                };
9973                                Some(HistoryChange::PropSet {
9974                                    field: field_str,
9975                                    value: value.clone(),
9976                                })
9977                            }
9978                            _ => None,
9979                        }
9980                    }
9981                    WalRecord::RemoveProp { key: k, field }
9982                        if Self::aliases_match(&alias_intervals, k, commit) =>
9983                    {
9984                        Some(HistoryChange::PropRemoved {
9985                            field: field.clone(),
9986                        })
9987                    }
9988                    WalRecord::InsertEdge {
9989                        edge_type,
9990                        src_key,
9991                        dst_key,
9992                    } => {
9993                        if Self::aliases_match(&alias_intervals, src_key, commit) {
9994                            Some(HistoryChange::EdgeAdded {
9995                                edge_type: edge_type.clone(),
9996                                other: dst_key.clone(),
9997                                outgoing: true,
9998                            })
9999                        } else if Self::aliases_match(&alias_intervals, dst_key, commit) {
10000                            Some(HistoryChange::EdgeAdded {
10001                                edge_type: edge_type.clone(),
10002                                other: src_key.clone(),
10003                                outgoing: false,
10004                            })
10005                        } else {
10006                            None
10007                        }
10008                    }
10009                    WalRecord::InsertEdgeId { etype, src, dst } => {
10010                        let etype_str = match self.syms.resolve(*etype) {
10011                            Some(s) => s.to_string(),
10012                            None => continue,
10013                        };
10014                        // key_of_historical (not key_of): an edge added before
10015                        // either endpoint was later deleted must still resolve —
10016                        // see the SetPropId arm above and edge_history's
10017                        // InsertEdgeId arm, which use the same lookup for the
10018                        // same reason.
10019                        let src_key = self.ids.key_of_historical(*src);
10020                        let dst_key = self.ids.key_of_historical(*dst);
10021                        if src_key == Some(key) {
10022                            let other = match dst_key {
10023                                Some(s) => s.to_string(),
10024                                None => continue,
10025                            };
10026                            Some(HistoryChange::EdgeAdded {
10027                                edge_type: etype_str,
10028                                other,
10029                                outgoing: true,
10030                            })
10031                        } else if dst_key == Some(key) {
10032                            let other = match src_key {
10033                                Some(s) => s.to_string(),
10034                                None => continue,
10035                            };
10036                            Some(HistoryChange::EdgeAdded {
10037                                edge_type: etype_str,
10038                                other,
10039                                outgoing: false,
10040                            })
10041                        } else {
10042                            None
10043                        }
10044                    }
10045                    WalRecord::DeleteEdge {
10046                        edge_type,
10047                        src_key,
10048                        dst_key,
10049                    } => {
10050                        if Self::aliases_match(&alias_intervals, src_key, commit) {
10051                            Some(HistoryChange::EdgeRemoved {
10052                                edge_type: edge_type.clone(),
10053                                other: dst_key.clone(),
10054                                outgoing: true,
10055                            })
10056                        } else if Self::aliases_match(&alias_intervals, dst_key, commit) {
10057                            Some(HistoryChange::EdgeRemoved {
10058                                edge_type: edge_type.clone(),
10059                                other: src_key.clone(),
10060                                outgoing: false,
10061                            })
10062                        } else {
10063                            None
10064                        }
10065                    }
10066                    WalRecord::DeleteNode { key: k }
10067                        if Self::aliases_match(&alias_intervals, k, commit) =>
10068                    {
10069                        Some(HistoryChange::NodeDeleted)
10070                    }
10071                    // Skip: rule/view/fulltext/intern metadata; Batch wrapper handled above.
10072                    _ => None,
10073                };
10074
10075                if let Some(change) = change {
10076                    out.push(HistoryEntry { commit, change });
10077                }
10078            }
10079        }
10080
10081        Ok(HistoryResult {
10082            items: out,
10083            total_commits,
10084            horizon: self.wal_horizon_floor,
10085        })
10086    }
10087
10088    /// Return the per-edge change history between nodes `a` and `b` by scanning
10089    /// the on-disk WAL.
10090    ///
10091    /// ## Horizon
10092    ///
10093    /// History reaches back only to the last WAL-truncating snapshot, exactly
10094    /// like `node_history` and `open_at`. The returned [`HistoryResult`] carries
10095    /// `total_commits` (= number of WAL frames), which is the exclusive upper
10096    /// bound for valid commit indices.
10097    ///
10098    /// ## Derived edges
10099    ///
10100    /// Rule-derived edges appear via `DerivedEdgeAdded` / `DerivedEdgeRetracted`
10101    /// WAL markers written by `log_then_apply_with` after each rule-firing
10102    /// mutation. The `rule` field of those events carries the rule name.
10103    ///
10104    /// ## DeleteNode
10105    ///
10106    /// When a node is deleted, its manual incident edges are swept inline without
10107    /// individual `DeleteEdge` WAL records. `edge_history` detects `DeleteNode`
10108    /// events for either endpoint and synthesises `Retracted(rule:None)` events
10109    /// for each manual edge that was active at that point. Derived edges active at
10110    /// the time of deletion are handled by the `DerivedEdgeRetracted` marker that
10111    /// the engine appends immediately after the `DeleteNode` record; those events
10112    /// carry correct rule attribution and are emitted by the marker arm, not the
10113    /// synthetic sweep.
10114    ///
10115    /// ## Masks
10116    ///
10117    /// Like `node_history`, this method has no mask parameter and returns WAL
10118    /// history regardless of any role mask. For masked history semantics, apply
10119    /// the mask at the caller level.
10120    pub fn edge_history(
10121        &self,
10122        a: &str,
10123        b: &str,
10124    ) -> Result<crate::history::HistoryResult<crate::history::EdgeHistoryEvent>> {
10125        use crate::history::{EdgeEvent, EdgeHistoryEvent, HistoryResult};
10126        use core_storage::wal::WalRecord;
10127
10128        let (frames, _) = self.all_frames()?;
10129        let total_commits = self.wal_horizon_floor + frames.len() as u64;
10130
10131        // Resolve all historical names for a and b (handles RenameNode in the WAL).
10132        // Intervals are commit-bounded so recycled keys don't contaminate histories.
10133        let alias_a = self.build_key_alias_intervals(&frames, a);
10134        let alias_b = self.build_key_alias_intervals(&frames, b);
10135
10136        // Active edges between a and b tracked as (edge_type, src_key, dst_key, is_derived).
10137        // The is_derived flag is used by the DeleteNode sweep: manual edges are
10138        // swept with a synthetic Retracted(rule:None); derived edges are skipped
10139        // because the engine writes a DerivedEdgeRetracted marker immediately after
10140        // the DeleteNode record, which carries the correct rule attribution.
10141        let mut active: Vec<(String, String, String, bool)> = Vec::new();
10142        let mut out: Vec<EdgeHistoryEvent> = Vec::new();
10143
10144        for (local_i, frame) in frames.iter().enumerate() {
10145            let commit = self.wal_horizon_floor + local_i as u64;
10146            let records: &[WalRecord] = match frame {
10147                WalRecord::Batch(inner) => inner.as_slice(),
10148                single => std::slice::from_ref(single),
10149            };
10150
10151            for rec in records {
10152                match rec {
10153                    WalRecord::InsertEdge {
10154                        edge_type,
10155                        src_key,
10156                        dst_key,
10157                    } => {
10158                        let is_ab = Self::aliases_match(&alias_a, src_key, commit)
10159                            && Self::aliases_match(&alias_b, dst_key, commit);
10160                        let is_ba = Self::aliases_match(&alias_b, src_key, commit)
10161                            && Self::aliases_match(&alias_a, dst_key, commit);
10162                        if is_ab || is_ba {
10163                            active.push((
10164                                edge_type.clone(),
10165                                src_key.clone(),
10166                                dst_key.clone(),
10167                                false,
10168                            ));
10169                            out.push(EdgeHistoryEvent {
10170                                edge_type: edge_type.clone(),
10171                                commit,
10172                                event: EdgeEvent::Added,
10173                                rule: None,
10174                            });
10175                        }
10176                    }
10177                    WalRecord::InsertEdgeId { etype, src, dst } => {
10178                        let etype_str = match self.syms.resolve(*etype) {
10179                            Some(s) => s.to_string(),
10180                            None => continue,
10181                        };
10182                        // Use key_of_historical so tombstoned nodes (deleted
10183                        // later in the WAL) still resolve during the scan.
10184                        let src_key = self.ids.key_of_historical(*src);
10185                        let dst_key = self.ids.key_of_historical(*dst);
10186                        let is_ab = src_key == Some(a) && dst_key == Some(b);
10187                        let is_ba = src_key == Some(b) && dst_key == Some(a);
10188                        if is_ab || is_ba {
10189                            let src_str = src_key.unwrap().to_string();
10190                            let dst_str = dst_key.unwrap().to_string();
10191                            active.push((etype_str.clone(), src_str, dst_str, false));
10192                            out.push(EdgeHistoryEvent {
10193                                edge_type: etype_str,
10194                                commit,
10195                                event: EdgeEvent::Added,
10196                                rule: None,
10197                            });
10198                        }
10199                    }
10200                    WalRecord::DeleteEdge {
10201                        edge_type,
10202                        src_key,
10203                        dst_key,
10204                    } => {
10205                        let is_ab = Self::aliases_match(&alias_a, src_key, commit)
10206                            && Self::aliases_match(&alias_b, dst_key, commit);
10207                        let is_ba = Self::aliases_match(&alias_b, src_key, commit)
10208                            && Self::aliases_match(&alias_a, dst_key, commit);
10209                        if is_ab || is_ba {
10210                            // Remove the first matching active entry (flag ignored).
10211                            if let Some(pos) = active.iter().position(|(et, s, d, _)| {
10212                                et == edge_type && s == src_key && d == dst_key
10213                            }) {
10214                                active.remove(pos);
10215                            }
10216                            out.push(EdgeHistoryEvent {
10217                                edge_type: edge_type.clone(),
10218                                commit,
10219                                event: EdgeEvent::Retracted,
10220                                rule: None,
10221                            });
10222                        }
10223                    }
10224                    WalRecord::DeleteNode { key: k }
10225                        if Self::aliases_match(&alias_a, k, commit)
10226                            || Self::aliases_match(&alias_b, k, commit) =>
10227                    {
10228                        // Sweep: implicitly retract only MANUAL active edges.
10229                        // Derived active edges are skipped here because the rule
10230                        // engine appends a DerivedEdgeRetracted marker immediately
10231                        // after this DeleteNode record; that marker produces the
10232                        // single correctly-attributed Retracted event.  Derived
10233                        // entries are dropped from `active` (the marker arm's
10234                        // idempotent retain finds nothing to remove).
10235                        for (et, _, _, is_derived) in active.drain(..) {
10236                            if !is_derived {
10237                                out.push(EdgeHistoryEvent {
10238                                    edge_type: et,
10239                                    commit,
10240                                    event: EdgeEvent::Retracted,
10241                                    rule: None,
10242                                });
10243                            }
10244                            // Derived: drop silently; marker carries the Retracted event.
10245                        }
10246                    }
10247                    WalRecord::DerivedEdgeAdded {
10248                        rule,
10249                        edge_type: et,
10250                        src_key,
10251                        dst_key,
10252                    } => {
10253                        let is_ab = Self::aliases_match(&alias_a, src_key, commit)
10254                            && Self::aliases_match(&alias_b, dst_key, commit);
10255                        let is_ba = Self::aliases_match(&alias_b, src_key, commit)
10256                            && Self::aliases_match(&alias_a, dst_key, commit);
10257                        if is_ab || is_ba {
10258                            active.push((et.clone(), src_key.clone(), dst_key.clone(), true));
10259                            out.push(EdgeHistoryEvent {
10260                                edge_type: et.clone(),
10261                                commit,
10262                                event: EdgeEvent::Added,
10263                                rule: Some(rule.clone()),
10264                            });
10265                        }
10266                    }
10267                    WalRecord::DerivedEdgeRetracted {
10268                        rule,
10269                        edge_type: et,
10270                        src_key,
10271                        dst_key,
10272                    } => {
10273                        let is_ab = Self::aliases_match(&alias_a, src_key, commit)
10274                            && Self::aliases_match(&alias_b, dst_key, commit);
10275                        let is_ba = Self::aliases_match(&alias_b, src_key, commit)
10276                            && Self::aliases_match(&alias_a, dst_key, commit);
10277                        if is_ab || is_ba {
10278                            // Push unconditionally: a derived edge whose Added marker
10279                            // predates the history horizon has no `active` entry, but
10280                            // the retraction is still a real in-window event.
10281                            // Remove from active idempotently if present.
10282                            active.retain(|(aet, s, d, _)| {
10283                                !(aet == et && s == src_key && d == dst_key)
10284                            });
10285                            out.push(EdgeHistoryEvent {
10286                                edge_type: et.clone(),
10287                                commit,
10288                                event: EdgeEvent::Retracted,
10289                                rule: Some(rule.clone()),
10290                            });
10291                        }
10292                    }
10293                    // All other records (InsertNode, SetProp, CreateRule, etc.)
10294                    // do not affect edges between a and b.
10295                    _ => {}
10296                }
10297            }
10298        }
10299
10300        Ok(HistoryResult {
10301            items: out,
10302            total_commits,
10303            horizon: self.wal_horizon_floor,
10304        })
10305    }
10306
10307    /// Return `true` iff an edge of `edge_type` existed between `a` and `b`
10308    /// (in either direction) at the WAL commit `at_commit`.
10309    ///
10310    /// ## Horizon
10311    ///
10312    /// Valid commit indices are `0..total_commits` where `total_commits` is the
10313    /// number of WAL frames. An `at_commit >= total_commits` is outside the
10314    /// visible horizon and returns [`GraphError::CommitOutOfRange`].
10315    ///
10316    /// ## Derived edges
10317    ///
10318    /// Rule-derived edges are tracked via `DerivedEdgeAdded` / `DerivedEdgeRetracted`
10319    /// WAL markers appended at firing time (Task 1). `was_linked` reads these markers
10320    /// and therefore includes derived edges in its point-in-time evaluation,
10321    /// matching `edge_history`'s fidelity.
10322    pub fn was_linked(&self, a: &str, b: &str, edge_type: &str, at_commit: u64) -> Result<bool> {
10323        use core_storage::wal::WalRecord;
10324
10325        let (frames, _) = self.all_frames()?;
10326        let total_commits = self.wal_horizon_floor + frames.len() as u64;
10327
10328        // Horizon floor: commits in pruned archives are unreachable.
10329        if at_commit < self.wal_horizon_floor {
10330            return Err(GraphError::CommitOutOfRange {
10331                commit: at_commit,
10332                total: total_commits,
10333                floor: self.wal_horizon_floor,
10334            });
10335        }
10336        if at_commit >= total_commits {
10337            return Err(GraphError::CommitOutOfRange {
10338                commit: at_commit,
10339                total: total_commits,
10340                floor: self.wal_horizon_floor,
10341            });
10342        }
10343
10344        // Resolve all historical names for a and b (handles RenameNode in the WAL).
10345        // Intervals are commit-bounded so recycled keys don't contaminate point-in-time reads.
10346        let alias_a = self.build_key_alias_intervals(&frames, a);
10347        let alias_b = self.build_key_alias_intervals(&frames, b);
10348
10349        // Local index into surviving frames (0 = first frame of oldest archive).
10350        let local_commit = at_commit - self.wal_horizon_floor;
10351
10352        // Replay local frames 0..=local_commit, tracking active edges.
10353        let mut active: BTreeSet<(String, String, String)> = BTreeSet::new();
10354
10355        for (local_i, frame) in frames.iter().enumerate().take((local_commit + 1) as usize) {
10356            let commit = self.wal_horizon_floor + local_i as u64;
10357            let records: &[WalRecord] = match frame {
10358                WalRecord::Batch(inner) => inner.as_slice(),
10359                single => std::slice::from_ref(single),
10360            };
10361
10362            for rec in records {
10363                match rec {
10364                    WalRecord::InsertEdge {
10365                        edge_type: et,
10366                        src_key,
10367                        dst_key,
10368                    } => {
10369                        let is_ab = Self::aliases_match(&alias_a, src_key, commit)
10370                            && Self::aliases_match(&alias_b, dst_key, commit);
10371                        let is_ba = Self::aliases_match(&alias_b, src_key, commit)
10372                            && Self::aliases_match(&alias_a, dst_key, commit);
10373                        if is_ab || is_ba {
10374                            active.insert((et.clone(), src_key.clone(), dst_key.clone()));
10375                        }
10376                    }
10377                    WalRecord::InsertEdgeId { etype, src, dst } => {
10378                        let etype_str = match self.syms.resolve(*etype) {
10379                            Some(s) => s.to_string(),
10380                            None => continue,
10381                        };
10382                        // Use key_of_historical so tombstoned nodes resolve.
10383                        let src_key = self.ids.key_of_historical(*src);
10384                        let dst_key = self.ids.key_of_historical(*dst);
10385                        let is_ab = src_key == Some(a) && dst_key == Some(b);
10386                        let is_ba = src_key == Some(b) && dst_key == Some(a);
10387                        if is_ab || is_ba {
10388                            active.insert((
10389                                etype_str,
10390                                src_key.unwrap().to_string(),
10391                                dst_key.unwrap().to_string(),
10392                            ));
10393                        }
10394                    }
10395                    WalRecord::DeleteEdge {
10396                        edge_type: et,
10397                        src_key,
10398                        dst_key,
10399                    } => {
10400                        let is_ab = Self::aliases_match(&alias_a, src_key, commit)
10401                            && Self::aliases_match(&alias_b, dst_key, commit);
10402                        let is_ba = Self::aliases_match(&alias_b, src_key, commit)
10403                            && Self::aliases_match(&alias_a, dst_key, commit);
10404                        if is_ab || is_ba {
10405                            active.remove(&(et.clone(), src_key.clone(), dst_key.clone()));
10406                        }
10407                    }
10408                    WalRecord::DeleteNode { key: k }
10409                        if Self::aliases_match(&alias_a, k, commit)
10410                            || Self::aliases_match(&alias_b, k, commit) =>
10411                    {
10412                        // All edges touching the deleted node are gone.
10413                        active.retain(|(_, s, d)| s != k && d != k);
10414                    }
10415                    WalRecord::DerivedEdgeAdded {
10416                        edge_type: et,
10417                        src_key,
10418                        dst_key,
10419                        ..
10420                    } => {
10421                        let is_ab = Self::aliases_match(&alias_a, src_key, commit)
10422                            && Self::aliases_match(&alias_b, dst_key, commit);
10423                        let is_ba = Self::aliases_match(&alias_b, src_key, commit)
10424                            && Self::aliases_match(&alias_a, dst_key, commit);
10425                        if is_ab || is_ba {
10426                            active.insert((et.clone(), src_key.clone(), dst_key.clone()));
10427                        }
10428                    }
10429                    WalRecord::DerivedEdgeRetracted {
10430                        edge_type: et,
10431                        src_key,
10432                        dst_key,
10433                        ..
10434                    } => {
10435                        let is_ab = Self::aliases_match(&alias_a, src_key, commit)
10436                            && Self::aliases_match(&alias_b, dst_key, commit);
10437                        let is_ba = Self::aliases_match(&alias_b, src_key, commit)
10438                            && Self::aliases_match(&alias_a, dst_key, commit);
10439                        if is_ab || is_ba {
10440                            active.remove(&(et.clone(), src_key.clone(), dst_key.clone()));
10441                        }
10442                    }
10443                    _ => {}
10444                }
10445            }
10446        }
10447
10448        Ok(active.iter().any(|(et, _, _)| et == edge_type))
10449    }
10450
10451    /// Every edge incident to `key` — either endpoint — that existed at WAL
10452    /// commit `commit`, from ONE scan of the WAL.
10453    ///
10454    /// This is the bulk form of [`was_linked`](GraphDb::was_linked): answering
10455    /// "what did K's relationships look like at commit C" with one call instead
10456    /// of one [`edge_history`](GraphDb::edge_history) per candidate partner.
10457    /// The two agree edge for edge.
10458    ///
10459    /// Results are sorted by `(edge_type, src_key, dst_key)`.
10460    ///
10461    /// ## Horizon
10462    ///
10463    /// Valid commit indices are `wal_horizon_floor()..wal_total_commits()`;
10464    /// anything outside is [`GraphError::CommitOutOfRange`], exactly like
10465    /// `was_linked`. An unknown key is not an error — it simply had no edges.
10466    ///
10467    /// ## Derived edges
10468    ///
10469    /// `DerivedEdgeAdded` / `DerivedEdgeRetracted` markers carry rule
10470    /// attribution, so a rule-owned edge comes back with `derived: true` and
10471    /// `rule: Some(name)`.
10472    ///
10473    /// ## Renames
10474    ///
10475    /// `key` is matched through the same commit-bounded alias intervals
10476    /// `edge_history` uses, so querying a node's *current* key surfaces edges
10477    /// written under an earlier name. Endpoint keys in the result are reported
10478    /// under the name the node carries today, so they can be fed straight back
10479    /// into `node_info`, `explain` or another `edges_at`.
10480    ///
10481    /// ## Masks
10482    ///
10483    /// Like `edge_history` and `node_history`, this reads the WAL regardless of
10484    /// any role mask. Apply masking at the caller level.
10485    pub fn edges_at(&self, key: &str, commit: u64) -> Result<Vec<EdgeAt>> {
10486        use core_storage::wal::WalRecord;
10487
10488        let (frames, _) = self.all_frames()?;
10489        let total_commits = self.wal_horizon_floor + frames.len() as u64;
10490
10491        // Horizon floor: commits in pruned archives are unreachable.
10492        if commit < self.wal_horizon_floor || commit >= total_commits {
10493            return Err(GraphError::CommitOutOfRange {
10494                commit,
10495                total: total_commits,
10496                floor: self.wal_horizon_floor,
10497            });
10498        }
10499
10500        // Commit-bounded historical names of `key` (handles RenameNode).
10501        let alias = self.build_key_alias_intervals(&frames, key);
10502
10503        // Forward rename chain, for reporting endpoints under their current
10504        // names: old key → [(commit, new key)] in ascending commit order.
10505        // Built over the whole WAL, not just the prefix up to `commit`, because
10506        // a rename after `commit` still changes what the node is called today.
10507        let mut renames: HashMap<String, Vec<(u64, String)>> = HashMap::new();
10508        for (local_i, frame) in frames.iter().enumerate() {
10509            let c = self.wal_horizon_floor + local_i as u64;
10510            let records: &[WalRecord] = match frame {
10511                WalRecord::Batch(inner) => inner.as_slice(),
10512                single => std::slice::from_ref(single),
10513            };
10514            for rec in records {
10515                if let WalRecord::RenameNode { old_key, new_key } = rec {
10516                    renames
10517                        .entry(old_key.clone())
10518                        .or_default()
10519                        .push((c, new_key.clone()));
10520                }
10521            }
10522        }
10523
10524        // The name a node written as `k` at commit `from` carries today.
10525        // Follows the first rename at or after `from`, then keeps going. The
10526        // iteration cap bounds a rename cycle inside a single batch.
10527        let canon = |k: &str, from: u64| -> String {
10528            if renames.is_empty() {
10529                return k.to_string();
10530            }
10531            let mut cur = k.to_string();
10532            let mut at = from;
10533            for _ in 0..64 {
10534                match renames
10535                    .get(&cur)
10536                    .and_then(|v| v.iter().find(|(c, _)| *c >= at))
10537                {
10538                    Some((c, new)) => {
10539                        at = *c;
10540                        cur = new.clone();
10541                    }
10542                    None => break,
10543                }
10544            }
10545            cur
10546        };
10547
10548        let local_commit = commit - self.wal_horizon_floor;
10549        // (edge_type, src_key, dst_key) → (derived, rule)
10550        let mut active: BTreeMap<(String, String, String), (bool, Option<String>)> =
10551            BTreeMap::new();
10552
10553        for (local_i, frame) in frames.iter().enumerate().take((local_commit + 1) as usize) {
10554            let c = self.wal_horizon_floor + local_i as u64;
10555            let records: &[WalRecord] = match frame {
10556                WalRecord::Batch(inner) => inner.as_slice(),
10557                single => std::slice::from_ref(single),
10558            };
10559
10560            for rec in records {
10561                match rec {
10562                    WalRecord::InsertEdge {
10563                        edge_type,
10564                        src_key,
10565                        dst_key,
10566                    } => {
10567                        if Self::aliases_match(&alias, src_key, c)
10568                            || Self::aliases_match(&alias, dst_key, c)
10569                        {
10570                            active.insert(
10571                                (edge_type.clone(), canon(src_key, c), canon(dst_key, c)),
10572                                (false, None),
10573                            );
10574                        }
10575                    }
10576                    WalRecord::InsertEdgeId { etype, src, dst } => {
10577                        let Some(etype_str) = self.syms.resolve(*etype) else {
10578                            continue;
10579                        };
10580                        // `key_of_historical` resolves tombstoned ids too, and
10581                        // already returns the node's current key — no rename
10582                        // canonicalisation needed on this arm.
10583                        let (Some(src_key), Some(dst_key)) = (
10584                            self.ids.key_of_historical(*src),
10585                            self.ids.key_of_historical(*dst),
10586                        ) else {
10587                            continue;
10588                        };
10589                        if src_key == key || dst_key == key {
10590                            active.insert(
10591                                (
10592                                    etype_str.to_string(),
10593                                    src_key.to_string(),
10594                                    dst_key.to_string(),
10595                                ),
10596                                (false, None),
10597                            );
10598                        }
10599                    }
10600                    WalRecord::DeleteEdge {
10601                        edge_type,
10602                        src_key,
10603                        dst_key,
10604                    } => {
10605                        if Self::aliases_match(&alias, src_key, c)
10606                            || Self::aliases_match(&alias, dst_key, c)
10607                        {
10608                            active.remove(&(
10609                                edge_type.clone(),
10610                                canon(src_key, c),
10611                                canon(dst_key, c),
10612                            ));
10613                        }
10614                    }
10615                    WalRecord::DeleteNode { key: k } => {
10616                        if active.is_empty() {
10617                            continue;
10618                        }
10619                        if Self::aliases_match(&alias, k, c) {
10620                            // Our node is gone; every incident edge goes with it.
10621                            active.clear();
10622                        } else {
10623                            // A partner is gone; its edges to us go with it.
10624                            let ck = canon(k, c);
10625                            active.retain(|(_, s, d), _| *s != ck && *d != ck);
10626                        }
10627                    }
10628                    WalRecord::DerivedEdgeAdded {
10629                        rule,
10630                        edge_type,
10631                        src_key,
10632                        dst_key,
10633                    } => {
10634                        if Self::aliases_match(&alias, src_key, c)
10635                            || Self::aliases_match(&alias, dst_key, c)
10636                        {
10637                            active.insert(
10638                                (edge_type.clone(), canon(src_key, c), canon(dst_key, c)),
10639                                (true, Some(rule.clone())),
10640                            );
10641                        }
10642                    }
10643                    WalRecord::DerivedEdgeRetracted {
10644                        edge_type,
10645                        src_key,
10646                        dst_key,
10647                        ..
10648                    } => {
10649                        if Self::aliases_match(&alias, src_key, c)
10650                            || Self::aliases_match(&alias, dst_key, c)
10651                        {
10652                            active.remove(&(
10653                                edge_type.clone(),
10654                                canon(src_key, c),
10655                                canon(dst_key, c),
10656                            ));
10657                        }
10658                    }
10659                    // InsertNode, SetProp, CreateRule, … do not move edges.
10660                    _ => {}
10661                }
10662            }
10663        }
10664
10665        // BTreeMap iteration is already (edge_type, src, dst) order.
10666        Ok(active
10667            .into_iter()
10668            .map(|((edge_type, src_key, dst_key), (derived, rule))| EdgeAt {
10669                edge_type,
10670                src_key,
10671                dst_key,
10672                derived,
10673                rule,
10674            })
10675            .collect())
10676    }
10677
10678    /// The derived edges that would be retracted and derived if `key.field`
10679    /// were set to `value` — computed WITHOUT writing anything.
10680    ///
10681    /// Nothing is committed and nothing on `self` is mutated: the rule engine's
10682    /// provenance, its candidate indexes, the topology and the property columns
10683    /// are all cloned first, the change is applied to the clone, and the real
10684    /// per-node re-derivation (`RuleEngine::on_node_changed` — the same call
10685    /// `set_prop` makes during apply) runs against it. The derived-edge deltas
10686    /// it emits are the answer, so rule semantics — predicates, top-k,
10687    /// via-hops, chaining, weights — are the engine's, not a re-implementation.
10688    ///
10689    /// Works on a read-only handle.
10690    ///
10691    /// **While a rule's vector index is still building** (`RuleStats::building`)
10692    /// the clone carries no pending-build state, so this reports the edges that
10693    /// rule would derive — which the live store will not derive until its
10694    /// backfill runs. Right about the end state, early about the timing.
10695    ///
10696    /// Returns `Err(KeyNotFound)` for an unknown or tombstoned key and
10697    /// `Err(ViewPropReadOnly)` for a field a view owns — matching
10698    /// [`set_prop`](GraphDb::set_prop)'s validation. A change with no effect
10699    /// (the node already holds `value`, or no rule watches `field`) returns
10700    /// empty lists.
10701    ///
10702    /// ## Cost
10703    ///
10704    /// One clone of the property columns, the topology overlay, the symbol
10705    /// interner, the edge properties and the provenance map, plus one candidate
10706    /// re-index (O(nodes × rules)). That is much cheaper than copying the store
10707    /// directory, but it is not free — this is an interactive "what if", not a
10708    /// hot path.
10709    pub fn what_if_set_prop(&self, key: &str, field: &str, value: Value) -> Result<WhatIf> {
10710        // The engine's provenance, HNSW and IVF state live in the mmap'd base
10711        // until something asks for them. On a store opened cold from a snapshot
10712        // this is the first ask, and without it the clone below starts from an
10713        // empty provenance map: nothing to retract, so `lost` comes back empty.
10714        self.ensure_v8_base_sections_loaded();
10715
10716        let empty = WhatIf {
10717            lost: Vec::new(),
10718            gained: Vec::new(),
10719        };
10720
10721        if let Some(view_name) = self.view_store.view_for_prop(field) {
10722            return Err(GraphError::ViewPropReadOnly {
10723                view_name: view_name.to_string(),
10724            });
10725        }
10726        MutPreview::new(self).check_live_key(key)?;
10727        let id = self
10728            .ids
10729            .get(key)
10730            .ok_or_else(|| GraphError::KeyNotFound { key: key.into() })?;
10731
10732        let rules: Vec<RuleDef> = self.engine.rules().cloned().collect();
10733        if rules.is_empty() {
10734            return Ok(empty);
10735        }
10736
10737        // No rule watches this field → no derivation can change.
10738        if !rules.iter().any(|r| r.watched_fields().contains(field)) {
10739            return Ok(empty);
10740        }
10741
10742        let old_value = build_props_view(&self.props, &self.base)
10743            .get(id, field)
10744            .map(|vr| vr.into_value());
10745        if old_value.as_ref() == Some(&value) {
10746            return Ok(empty);
10747        }
10748
10749        // --- Clone every piece of state the re-derivation writes to. ---
10750        let mut props = self.props.clone();
10751        let mut topo = self.topo.clone();
10752        let mut syms = self.syms.clone();
10753        let mut edge_props = self.edge_props.clone();
10754
10755        let mut tripped: BTreeMap<String, bool> = BTreeMap::new();
10756        let mut fires: BTreeMap<String, u64> = BTreeMap::new();
10757        for r in &rules {
10758            tripped.insert(r.name.clone(), self.engine.is_tripped(&r.name));
10759            fires.insert(r.name.clone(), self.engine.fire_count(&r.name));
10760        }
10761        // `provenance()` decodes retained snapshot bytes on first use; the
10762        // engine clone needs the real map, not an empty one.
10763        let provenance = self.engine.provenance().clone();
10764        let mut engine = core_rules::RuleEngine::from_persist(rules, provenance, tripped, fires);
10765
10766        // Build the candidate indexes from the state BEFORE the change, exactly
10767        // as apply() sees them: `on_node_changed` withdraws the node under its
10768        // old value and refiles it under the new one, so the index must not
10769        // already reflect the change.
10770        engine.reindex_all_load_state(
10771            &self.ids,
10772            &syms,
10773            &self.labels,
10774            build_props_view(&self.props, &self.base),
10775            self.engine.export_ivf_state(),
10776            self.engine.export_hnsw_state_passthrough(),
10777        );
10778        engine.set_emit_deltas(true);
10779
10780        // --- Apply the hypothetical change and re-derive. ---
10781        props.set(id, field, value);
10782        {
10783            let mut gm = make_graph_mut(
10784                &self.ids,
10785                &mut syms,
10786                &self.labels,
10787                build_props_view(&props, &self.base),
10788                &mut topo,
10789                &self.base,
10790                &mut edge_props,
10791            );
10792            engine.on_node_changed(id, Some((field, old_value)), &mut gm);
10793        }
10794
10795        let mut lost: BTreeSet<EdgeAt> = BTreeSet::new();
10796        let mut gained: BTreeSet<EdgeAt> = BTreeSet::new();
10797        for d in engine.drain_deltas() {
10798            let edge = EdgeAt {
10799                edge_type: d.edge_type,
10800                src_key: d.src_key,
10801                dst_key: d.dst_key,
10802                derived: true,
10803                rule: Some(d.rule),
10804            };
10805            if d.fired {
10806                gained.insert(edge);
10807            } else {
10808                lost.insert(edge);
10809            }
10810        }
10811        // An edge retracted and re-derived within the same re-derivation (top-k
10812        // churn) is not a change the caller would see.
10813        let churn: Vec<EdgeAt> = lost.intersection(&gained).cloned().collect();
10814        for e in churn {
10815            lost.remove(&e);
10816            gained.remove(&e);
10817        }
10818
10819        Ok(WhatIf {
10820            lost: lost.into_iter().collect(),
10821            gained: gained.into_iter().collect(),
10822        })
10823    }
10824
10825    pub fn edge_count(&self) -> u64 {
10826        self.topo_view().edge_count()
10827    }
10828
10829    /// Live/tombstone/edge counts plus per-rule provenance size, trip latch,
10830    /// and fire counter (includes rebuild evaluations). Rules are sorted by name.
10831    pub fn stats(&self) -> Stats {
10832        self.ensure_v8_base_sections_loaded();
10833        let building = self.engine.builds_in_progress();
10834        let rules: Vec<RuleStats> = self
10835            .engine
10836            .rules()
10837            .map(|r| RuleStats {
10838                name: r.name.clone(),
10839                edges: self
10840                    .engine
10841                    .provenance()
10842                    .get(&r.name)
10843                    .map(|s| s.len() as u64)
10844                    .unwrap_or(0),
10845                tripped: self.engine.is_tripped(&r.name),
10846                fires: self.engine.fire_count(&r.name),
10847                approximate: r.approximate,
10848                building: building.iter().find(|b| b.rule == r.name).cloned(),
10849            })
10850            .collect();
10851        Stats {
10852            nodes_live: self.ids.live_len(),
10853            nodes_tombstoned: self.ids.len() - self.ids.live_len(),
10854            edges: self.topo_view().edge_count(),
10855            rules,
10856            chain_truncations: self.engine.chain_truncations(),
10857            history_floor: self.wal_horizon_floor,
10858            namespaces: self.namespace_stats(),
10859        }
10860    }
10861
10862    /// On-disk size of the WAL file in bytes.
10863    ///
10864    /// Reads file metadata without loading WAL contents.  Returns `Err` for
10865    /// in-memory (`SimFs`) databases where no WAL file exists on disk.
10866    pub fn wal_size_bytes(&self) -> std::io::Result<u64> {
10867        let path = self.fs.wal_path().ok_or_else(|| {
10868            std::io::Error::new(
10869                std::io::ErrorKind::Unsupported,
10870                "wal_path not available for this Fs implementation",
10871            )
10872        })?;
10873        Ok(std::fs::metadata(path)?.len())
10874    }
10875
10876    /// Set the slow-query threshold.  Queries whose execution time equals or
10877    /// exceeds `ms` milliseconds are logged.  Pass `0` to disable.
10878    ///
10879    /// Use this setter in tests — the environment variable
10880    /// `MUSHROOMDB_SLOW_QUERY_MS` is process-global and races parallel test
10881    /// threads.
10882    pub fn set_slow_query_threshold_ms(&mut self, ms: u64) {
10883        self.slow_query_threshold_ms = ms;
10884    }
10885
10886    /// Snapshot of the slow-query ring buffer and lifetime counter.
10887    pub fn slow_query_snapshot(&self) -> SlowQuerySnapshot {
10888        let log = self.slow_queries.lock().unwrap_or_else(|e| e.into_inner());
10889        SlowQuerySnapshot {
10890            threshold_ms: self.slow_query_threshold_ms,
10891            count: log.total,
10892            last: log.entries.iter().cloned().collect(),
10893        }
10894    }
10895
10896    /// Instant the database was opened.  Used by consumers (e.g. `/metrics`)
10897    /// to compute uptime.
10898    pub fn started_at(&self) -> std::time::Instant {
10899        self.started_at
10900    }
10901
10902    /// On-disk snapshot format version this binary writes and reads.
10903    pub fn format_version() -> u16 {
10904        core_storage::snapshot::VERSION
10905    }
10906
10907    /// Test-support: total bytes appended (SimFs only usage).
10908    pub fn fs_total_appended(&self) -> usize
10909    where
10910        F: FsIntrospect,
10911    {
10912        self.fs.total_appended()
10913    }
10914
10915    /// Test-support: successful `Fs::sync` calls (SimFs / counting fs).
10916    pub fn fs_sync_count(&self) -> usize
10917    where
10918        F: FsIntrospect,
10919    {
10920        self.fs.sync_count()
10921    }
10922
10923    /// Consume the db, returning its fs (for crash simulation).
10924    pub fn into_fs(self) -> F {
10925        self.fs
10926    }
10927
10928    pub fn snapshot(&mut self) -> Result<()> {
10929        self.snapshot_with(SnapshotOptions::default())
10930    }
10931
10932    /// Snapshot with explicit options.
10933    ///
10934    /// # `keep_wal`
10935    ///
10936    /// When `keep_wal` is `false` (the default, same as [`snapshot`]):
10937    ///   - The WAL is replaced with a minimal baseline containing one
10938    ///     `EnableFulltext` record per active declaration.  All pre-snapshot
10939    ///     history is discarded; `open_at` can only reach post-snapshot commits.
10940    ///
10941    /// When `keep_wal` is `true`:
10942    ///   - The WAL is left intact.  All pre-snapshot commits remain reachable
10943    ///     via `open_at`.  The existing WAL already contains the original
10944    ///     `EnableFulltext` records, so no baseline re-write is needed; the
10945    ///     recovery guards in `apply()` silently skip any duplicate records on
10946    ///     replay.
10947    ///   - Crash window: a crash after the snapshot write but before the next
10948    ///     WAL write leaves the full pre-snapshot WAL intact.  On reopen the
10949    ///     snapshot is loaded and the WAL replayed idempotently over it — safe
10950    ///     because every `apply()` arm is idempotent when replayed over an
10951    ///     already-current snapshot.
10952    pub fn snapshot_with(&mut self, opts: SnapshotOptions) -> Result<()> {
10953        if self.read_only {
10954            return Err(GraphError::ReadOnly);
10955        }
10956        // A snapshot rewrites `wal.bin` through a tmp+rename, so a peer that is
10957        // appending ends up holding a descriptor on an unlinked inode and loses
10958        // commits it believes durable. Snapshotting therefore requires the
10959        // cross-process write lock, exactly as appending does. Unlike the WAL
10960        // append path this does not go through `log_then_apply_with`, so both
10961        // guards are repeated here.
10962        if self.degraded {
10963            return Err(GraphError::Io(std::io::Error::other(
10964                "database degraded after group-commit fsync failure; reopen required",
10965            )));
10966        }
10967        if self.lock_denied {
10968            return Err(GraphError::Busy { holder: None });
10969        }
10970        // Capture whether snapshot.bin already existed BEFORE this snapshot write.
10971        // Used by the archive path's conservative genesis-chain check: if a prior
10972        // snapshot exists but wal.truncated does not, we cannot distinguish a
10973        // legacy store (may have been truncated in an older code version) from a
10974        // new store that only used keep_wal=true.  Conservative: refuse genesis in
10975        // both cases.  Must be sampled here, before the snapshot write below.
10976        let had_prior_snapshot = self.fs.snapshot_path().map(|p| p.exists()).unwrap_or(false);
10977        self.ensure_v8_base_sections_loaded();
10978        // Ensure provenance is decoded before to_persist() clones it.
10979        self.engine.ensure_provenance_loaded_mut();
10980        let (rule_defs_typed, provenance, rule_tripped, rule_fires) = self.engine.to_persist();
10981        let rule_defs = rule_defs_typed
10982            .iter()
10983            .map(|r| bincode::serialize(r).expect("RuleDef serialize cannot fail"))
10984            .collect();
10985        // Collect HNSW state and IVF state.  When indexes are not yet
10986        // populated (clean open, no mutation since open), pass the retained
10987        // raw bytes through directly so that migrate/snapshot does not
10988        // silently discard fitted approximate-rule indexes.
10989        let hnsw_state = self.engine.export_hnsw_state_passthrough();
10990        let ivf_bytes = if !self.engine.indexes_populated() {
10991            // Pass retained IVF bytes through unchanged (no re-encode).
10992            self.engine.retained_ivf_bytes_clone().unwrap_or_default()
10993        } else {
10994            // Indexes live: encode from current state.
10995            let raw_ivf = self.engine.export_ivf_state();
10996            let ivf_state_map: BTreeMap<String, core_storage::snapshot::PerRuleIvfState> = raw_ivf
10997                .into_iter()
10998                .map(|(name, ((sc, sa, sd), (dc, da, dd)))| {
10999                    (
11000                        name,
11001                        core_storage::snapshot::PerRuleIvfState {
11002                            src: core_storage::snapshot::SideIvfState {
11003                                centroids: sc,
11004                                clusters: sa,
11005                                drift: sd,
11006                            },
11007                            dst: core_storage::snapshot::SideIvfState {
11008                                centroids: dc,
11009                                clusters: da,
11010                                drift: dd,
11011                            },
11012                        },
11013                    )
11014                })
11015                .collect();
11016            if ivf_state_map.is_empty() {
11017                Vec::new()
11018            } else {
11019                bincode::serialize(&ivf_state_map).expect("IVF state serialize cannot fail")
11020            }
11021        };
11022        let view_defs: Vec<Vec<u8>> = self
11023            .view_store
11024            .views()
11025            .map(|v| bincode::serialize(v).expect("ViewDef serialize cannot fail"))
11026            .collect();
11027        if self.base.is_some() {
11028            // V8 merge-snapshot path: encode base+overlay into a new V8 snapshot,
11029            // write it atomically, remap it as the new base, then clear the overlay.
11030            let meta = V8Meta {
11031                labels: self.labels.clone(),
11032                edge_props: self.edge_props.clone(),
11033                rule_defs,
11034                provenance,
11035                rule_tripped,
11036                rule_fires,
11037                ivf_bytes,
11038                view_defs,
11039                wal_truncated: !opts.keep_wal,
11040                hnsw: hnsw_state,
11041                last_change: self.last_change.clone(),
11042            };
11043            let mut buf: Vec<u8> = Vec::new();
11044            {
11045                // Clone the Arc so the old base stays alive while we encode.
11046                // The borrow of archived_csr (into old_base's mmap) is released
11047                // at the end of this block, before we replace self.base.
11048                let old_base = self.base.clone().expect("is_some checked above");
11049                let archived_csr = old_base.topology().map_err(|e| GraphError::Corrupt {
11050                    detail: format!("v8 snapshot: topology section: {e:?}"),
11051                })?;
11052                let archived_cols = old_base.columns().map_err(|e| GraphError::Corrupt {
11053                    detail: format!("v8 snapshot: columns section: {e:?}"),
11054                })?;
11055                // `None` when the base predates V9 — the migration path: its
11056                // string columns still carry their own tables and this snapshot
11057                // is the rewrite that collapses them into section 12.
11058                let archived_strings =
11059                    old_base
11060                        .string_table()
11061                        .transpose()
11062                        .map_err(|e| GraphError::Corrupt {
11063                            detail: format!("v8 snapshot: strings section: {e:?}"),
11064                        })?;
11065                let archived_edge_props =
11066                    old_base
11067                        .edge_props_section()
11068                        .map_err(|e| GraphError::Corrupt {
11069                            detail: format!("v8 snapshot: edge_props section: {e:?}"),
11070                        })?;
11071                let edge_props_raw =
11072                    old_base
11073                        .edge_props_raw_bytes()
11074                        .map_err(|e| GraphError::Corrupt {
11075                            detail: format!("v8 snapshot: edge_props raw bytes: {e:?}"),
11076                        })?;
11077                let prov_raw =
11078                    old_base
11079                        .provenance_raw_bytes()
11080                        .map_err(|e| GraphError::Corrupt {
11081                            detail: format!("v8 snapshot: provenance raw bytes: {e:?}"),
11082                        })?;
11083                encode_v8(
11084                    Some(archived_csr),
11085                    Some(archived_cols),
11086                    archived_strings,
11087                    Some((archived_edge_props, edge_props_raw)),
11088                    Some(prov_raw),
11089                    &self.topo,
11090                    &self.props,
11091                    &self.ids,
11092                    &self.syms,
11093                    &meta,
11094                    &mut buf,
11095                )?;
11096            }
11097            self.fs.write_atomic(FileId::Snapshot, &buf)?;
11098            // Remap the freshly-written snapshot as the new base.
11099            // C2: use file mmap on RealFs; fall back to from_bytes on SimFs.
11100            let new_base = if let Some(snap_path) = self.fs.snapshot_path() {
11101                core_storage::v8::MappedBase::map(&snap_path)
11102            } else {
11103                core_storage::v8::MappedBase::from_bytes(buf)
11104            }
11105            .map_err(|e| GraphError::Corrupt {
11106                detail: format!("v8 snapshot: remap new base: {e:?}"),
11107            })?;
11108            self.base = Some(Arc::new(new_base));
11109            // Clear the overlay and prop tombstones — all data is now in the new base.
11110            self.topo = Topology::new();
11111            self.props = core_storage::columns::ColumnStore::new();
11112        } else {
11113            // Legacy path (V5–V7 stores without a V8 base).
11114            //
11115            // Memory-diet path: build V8Meta directly from &self — no SnapshotState
11116            // clone and no encode_v8_from_state intermediate clones.  The big
11117            // structures (self.topo, self.props) are borrowed, not cloned.
11118            // self.edge_props is moved (not cloned) because we immediately clear it
11119            // when we remap the new V8 snapshot as self.base (see below).
11120            //
11121            // Eliminates from peak RSS vs. the old SnapshotState path:
11122            //   • self.topo.clone()      (~topology HashMap footprint)
11123            //   • self.props.clone()     (~column-store footprint)
11124            //   • encode_v8_from_state V8Meta secondary clones (labels, edge_props, …)
11125            let meta = V8Meta {
11126                labels: self.labels.clone(),
11127                wal_truncated: !opts.keep_wal,
11128                // Move edge_props out so the large overlay is freed when meta
11129                // drops at end of this block (self.edge_props is now empty; reads
11130                // after base assignment go through the mmap'd base section).
11131                edge_props: std::mem::take(&mut self.edge_props),
11132                rule_defs,
11133                provenance,
11134                rule_tripped,
11135                rule_fires,
11136                ivf_bytes,
11137                view_defs,
11138                hnsw: hnsw_state,
11139                last_change: self.last_change.clone(),
11140            };
11141            let mut buf = Vec::new();
11142            encode_v8(
11143                None,
11144                None,
11145                None,
11146                None,
11147                None,
11148                &self.topo,
11149                &self.props,
11150                &self.ids,
11151                &self.syms,
11152                &meta,
11153                &mut buf,
11154            )?;
11155            // meta (and the moved edge_props inside it) is no longer needed;
11156            // drop it before the write to keep the peak window narrow.
11157            drop(meta);
11158            self.fs.write_atomic(FileId::Snapshot, &buf)?;
11159            // Remap the freshly-written V8 snapshot as self.base.
11160            // On RealFs: drop the encode buffer before mmap to recover ~1.9 GiB.
11161            // On SimFs (tests): pass buf to from_bytes.
11162            let new_base = if let Some(snap_path) = self.fs.snapshot_path() {
11163                drop(buf);
11164                core_storage::v8::MappedBase::map(&snap_path)
11165            } else {
11166                core_storage::v8::MappedBase::from_bytes(buf)
11167            }
11168            .map_err(|e| GraphError::Corrupt {
11169                detail: format!("v8 snapshot: remap new base (legacy path): {e:?}"),
11170            })?;
11171            self.base = Some(Arc::new(new_base));
11172            // Free the large heap-allocated decoded state — all data is now in the
11173            // mmap'd base.  Mirrors the V8 merge-snapshot path (see above).
11174            // self.edge_props was already moved into meta and is effectively empty.
11175            self.topo = Topology::new();
11176            self.props = core_storage::columns::ColumnStore::new();
11177        }
11178
11179        if opts.archive_wal {
11180            // History-preserving snapshot (Task 4):
11181            //   1. Snapshot already written above (write_atomic → fsynced).
11182            //   2. Rename WAL → wal.<commit_seq>.archive  (atomic, same fs).
11183            //      Crash window B: crash here leaves archive present, WAL
11184            //      absent.  Reopen: snapshot loaded (full state), no WAL
11185            //      replay.  Archive is NOT replayed into live state — it is
11186            //      pre-snapshot by construction.  Safe.
11187            //   3. Optionally write genesis marker (first archive only, no
11188            //      prior WAL truncation).
11189            //   4. Prune old archives (retention), update horizon floor.
11190            //      Pruning invalidates the genesis chain; delete marker.
11191            //   5. Write new minimal baseline WAL (write_atomic).
11192            //      Crash window C: crash here leaves new archive plus no live
11193            //      WAL.  Same as window B — handled above.
11194            //
11195            // Sample existing archives BEFORE the rename so we can detect
11196            // whether this is the first archive.
11197            let existing_archives = self.fs.list_archives()?;
11198            let is_first_archive = existing_archives.is_empty();
11199
11200            // Compute a globally-monotonic archive name: the name equals the
11201            // cumulative end-frame index of the archive in global commit space.
11202            //
11203            // Using `commit_seq` directly is UNSOUND across sessions: on reopen
11204            // commit_seq is seeded from max(last_change), which underestimates
11205            // the WAL depth when trailing commits (e.g. insert_edge) do not
11206            // update last_change.  A session-2 archive could then receive a name
11207            // ≤ the session-1 archive, causing incorrect sort order or collision.
11208            //
11209            // Instead: read and decode the live WAL here (before the rename) to
11210            // get its exact frame count, then add it to the last known global
11211            // end-frame index (the name of the most recent existing archive, or
11212            // wal_horizon_floor if no archives exist).  This is O(WAL size) but
11213            // snapshot is already serialising the full graph state, so the cost
11214            // is dominated.
11215            let live_wal_bytes_for_name = self.fs.read(FileId::Wal)?;
11216            let (live_frames_for_name, _) = decode_all(&live_wal_bytes_for_name);
11217            let archive_n = existing_archives
11218                .last()
11219                .copied()
11220                .unwrap_or(self.wal_horizon_floor)
11221                + live_frames_for_name.len() as u64;
11222            self.fs.archive_wal(archive_n)?;
11223
11224            // Genesis marker: written once when the first archive is taken
11225            // from a store that has never undergone a WAL-truncating snapshot.
11226            // When present, `open_at` may replay archive-resident commits from
11227            // empty state (the archive chain covers from global index 0).
11228            //
11229            // Two conditions must ALL hold:
11230            //   1. This is the first archive (existing_archives was empty).
11231            //   2. No snapshot.bin existed before this operation (had_prior_snapshot=false).
11232            //      A WAL-truncating snapshot (keep_wal=false) always writes snapshot.bin
11233            //      before truncating the WAL, so if any prior truncating snapshot was taken
11234            //      — even in a previous session — snapshot.bin is present and this condition
11235            //      is false.  This subsumes the cross-session truncation case without
11236            //      requiring a separate wal.truncated sidecar file.
11237            //      For legacy stores (snapshot.bin written by an older code version that
11238            //      may have truncated the WAL), the same conservative refusal applies:
11239            //      we cannot prove the chain is complete, so we refuse genesis (cost =
11240            //      no as-of-through-archives; never silent wrong data).
11241            //      On SimFs (snapshot_path() == None) had_prior_snapshot is always false,
11242            //      so SimFs always passes this check.
11243            if is_first_archive && !had_prior_snapshot {
11244                self.fs.write_genesis_marker()?;
11245                self.archive_genesis_chain = true;
11246            }
11247
11248            // Retention pruning: keep newest `keep` archives; delete oldest.
11249            // Pruning is the ONLY deletion site for archives.
11250            //
11251            // Crash-safety ordering (C1 fix):
11252            //   1. Count frames in surplus archives (reads only — no mutation).
11253            //   2. Advance and PERSIST the horizon floor FIRST via write-then-
11254            //      rename (atomic).  A crash after this point leaves orphaned
11255            //      archives on disk, but the floor is correct.  The opening
11256            //      cleanup sweep (`cleanup_orphaned_archives`) removes them on
11257            //      the next open, so the store is always safe to reopen.
11258            //   3. Delete the genesis marker (floor > 0 already blocks open_at
11259            //      via the conjunctive gate; marker cleanup is belt-and-suspenders).
11260            //   4. Delete surplus archives.  A crash between any two deletes
11261            //      leaves the floor committed and orphaned archives cleaned at
11262            //      next open — never a stale floor with a missing archive prefix.
11263            if let Some(keep) = self.wal_archive_retention {
11264                if keep > 0 {
11265                    let archives = self.fs.list_archives()?;
11266                    // archives is sorted ascending (oldest first)
11267                    if archives.len() as u32 > keep {
11268                        let surplus = archives.len() - keep as usize;
11269                        // Step 1: count pruned frames (reads, no mutation).
11270                        let mut pruned_frames = 0u64;
11271                        for &n in &archives[..surplus] {
11272                            let bytes = self.fs.read_archive(n)?;
11273                            let (frames, _) = decode_all(&bytes);
11274                            pruned_frames += frames.len() as u64;
11275                        }
11276                        // Step 2: advance and persist floor FIRST.
11277                        self.wal_horizon_floor += pruned_frames;
11278                        self.fs.write_horizon_floor(self.wal_horizon_floor)?;
11279                        // Step 3: delete genesis marker (floor > 0 already
11280                        // blocks open_at; this is belt-and-suspenders cleanup).
11281                        if pruned_frames > 0 && self.archive_genesis_chain {
11282                            self.fs.delete_genesis_marker()?;
11283                            self.archive_genesis_chain = false;
11284                        }
11285                        // Step 4: delete surplus archives.  Crash here →
11286                        // orphaned archives; cleaned at next open.
11287                        for &n in &archives[..surplus] {
11288                            self.fs.delete_archive(n)?;
11289                        }
11290                    }
11291                }
11292            }
11293
11294            // Write new minimal baseline WAL (mirrors the keep_wal=false path).
11295            let mut baseline_wal: Vec<u8> = Vec::new();
11296            for (label, field) in self.fulltext.enabled_pairs() {
11297                let rec = WalRecord::EnableFulltext {
11298                    label: label.clone(),
11299                    field: field.clone(),
11300                };
11301                baseline_wal.extend_from_slice(&encode_record(&rec));
11302            }
11303            for (label, field) in self.prop_index.enabled_pairs() {
11304                let rec = WalRecord::EnableIndex {
11305                    label: label.clone(),
11306                    field: field.clone(),
11307                };
11308                baseline_wal.extend_from_slice(&encode_record(&rec));
11309            }
11310            self.fs.write_atomic(FileId::Wal, &baseline_wal)?;
11311        } else if opts.keep_wal {
11312            // keep_wal=true: WAL is left untouched.  The existing WAL already
11313            // contains the EnableFulltext records from the original enable calls;
11314            // replay is idempotent (guards in apply() skip already-live entries).
11315            // No baseline re-write is needed or safe here — the full WAL history
11316            // must remain intact for open_at to reach pre-snapshot commits.
11317        } else {
11318            // keep_wal=false (default): truncate by replacing the WAL with a
11319            // minimal baseline of one EnableFulltext record per active pair.
11320            //
11321            // Crash-ordering: write_atomic is atomic.
11322            //   • Crash before snapshot write  → WAL unchanged.  Safe.
11323            //   • Crash after snapshot write but before this WAL write → full
11324            //     pre-snapshot WAL still present; open_with replays idempotently.
11325            //   • Crash after both writes → normal post-snapshot state.
11326            //
11327            // Genesis chain: a WAL-truncating snapshot breaks the archive chain
11328            // for any archives taken AFTER this point (their WAL slices would
11329            // not start at genesis).  Delete any existing genesis marker so that
11330            // open_at refuses archive-resident commits.  Future sessions are
11331            // covered by had_prior_snapshot: snapshot.bin written here persists
11332            // across sessions and prevents a later archiving session from
11333            // incorrectly claiming a complete genesis chain.
11334            if self.archive_genesis_chain {
11335                self.fs.delete_genesis_marker()?;
11336                self.archive_genesis_chain = false;
11337            }
11338            let mut baseline_wal: Vec<u8> = Vec::new();
11339            for (label, field) in self.fulltext.enabled_pairs() {
11340                let rec = WalRecord::EnableFulltext {
11341                    label: label.clone(),
11342                    field: field.clone(),
11343                };
11344                baseline_wal.extend_from_slice(&encode_record(&rec));
11345            }
11346            for (label, field) in self.prop_index.enabled_pairs() {
11347                let rec = WalRecord::EnableIndex {
11348                    label: label.clone(),
11349                    field: field.clone(),
11350                };
11351                baseline_wal.extend_from_slice(&encode_record(&rec));
11352            }
11353            self.fs.write_atomic(FileId::Wal, &baseline_wal)?;
11354        }
11355        // After snapshot the overlay may have changed (V8 merge path clears
11356        // self.topo and self.props). Refresh the MVCC fold so future readers
11357        // see the post-snapshot state rather than stale overlay data.
11358        self.fold_now();
11359        // We wrote the snapshot and (unless keep_wal) replaced the WAL, so both
11360        // markers this handle uses to detect other processes' work must be
11361        // re-taken from disk. Skipping this would make our own snapshot look
11362        // like a peer's on the next staleness check and force a needless
11363        // reload.
11364        self.wal_consumed = self.fs.wal_len().map_err(GraphError::Io)?;
11365        self.snapshot_ident = self.fs.snapshot_ident().map_err(GraphError::Io)?;
11366        Ok(())
11367    }
11368}
11369
11370/// Queued mutation for a [`BatchBuilder`] or [`GraphDb::commit_group`].
11371///
11372/// The `submit_batch` / `commit_group` APIs accept `Vec<BatchOp>` so that
11373/// callers can build a set of mutations without holding `&mut GraphDb` and
11374/// hand them off to the group-committing writer for durable, batched I/O.
11375pub enum BatchOp {
11376    InsertNode {
11377        label: String,
11378        key: String,
11379        props: Vec<(String, Value)>,
11380    },
11381    InsertEdge {
11382        edge_type: String,
11383        src_key: String,
11384        dst_key: String,
11385    },
11386    SetProp {
11387        key: String,
11388        field: String,
11389        value: Value,
11390    },
11391    RemoveProp {
11392        key: String,
11393        field: String,
11394    },
11395    DeleteEdge {
11396        edge_type: String,
11397        src_key: String,
11398        dst_key: String,
11399    },
11400    DeleteNode {
11401        key: String,
11402    },
11403    CreateRule(RuleDef),
11404    DeleteRule {
11405        name: String,
11406    },
11407    /// Rename a node's key. Validated: old must exist, new must not.
11408    RenameNode {
11409        old_key: String,
11410        new_key: String,
11411    },
11412    /// Insert an edge, auto-creating any missing endpoint as a plain node with
11413    /// `placeholder_label` and no props. Rules fire and last-change is updated
11414    /// for each created endpoint (normal InsertNode semantics in the batch frame).
11415    InsertEdgeUpsert {
11416        edge_type: String,
11417        src_key: String,
11418        dst_key: String,
11419        placeholder_label: String,
11420    },
11421}
11422
11423/// Three-way node visibility status used by `check_single_op_authz`.
11424enum NodeAuthzStatus {
11425    /// Node exists in the store and is in the role's read mask.
11426    Visible(String), // carries the node's label
11427    /// Node exists in the store but is NOT in the role's read mask.
11428    Hidden,
11429    /// Node does not exist in the store.
11430    Absent,
11431}
11432
11433/// Overlay of ops already accepted earlier in the same batch. Never written
11434/// back to the database — validation only.
11435#[derive(Default)]
11436struct Overlay {
11437    extra_keys: BTreeSet<String>,
11438    deleted_keys: BTreeSet<String>,
11439    extra_props: BTreeMap<(String, String), Value>,
11440    removed_props: BTreeSet<(String, String)>,
11441    extra_edges: BTreeSet<(String, String, String)>,
11442    deleted_edges: BTreeSet<(String, String, String)>,
11443    extra_rules: BTreeSet<String>,
11444    deleted_rules: BTreeSet<String>,
11445    /// `rule name → (via_edge, edge_type)` for every via-hop rule accepted
11446    /// earlier in this batch. Feeds the rule-chain cycle check, which otherwise
11447    /// sees only the rules already committed to the engine. Keyed by name so a
11448    /// later `DeleteRule` in the same batch drops the arc with the rule.
11449    extra_rule_arcs: BTreeMap<String, (String, String)>,
11450}
11451
11452/// Read-only view of live db state plus a batch overlay. Shared by single-op
11453/// public methods (empty overlay) and `commit_batch`.
11454struct MutPreview<'a, F: Fs> {
11455    db: &'a GraphDb<F>,
11456    overlay: Overlay,
11457}
11458
11459/// Shortest path from `start` to `target` following `arcs` (`from → to`), or
11460/// `None` if `target` is unreachable.
11461///
11462/// Used for rule-chain cycle detection, where an arc is "a rule hops over
11463/// `from` and writes `to`". Breadth-first over BTree-ordered adjacency, so the
11464/// reported path is stable for a given rule set, and iterative so a pathological
11465/// rule graph cannot overflow the stack.
11466fn find_cycle_through(arcs: &[(String, String)], start: &str, target: &str) -> Option<Vec<String>> {
11467    let mut adj: BTreeMap<&str, BTreeSet<&str>> = BTreeMap::new();
11468    for (from, to) in arcs {
11469        adj.entry(from.as_str()).or_default().insert(to.as_str());
11470    }
11471    let mut parent: BTreeMap<&str, &str> = BTreeMap::new();
11472    let mut visited: BTreeSet<&str> = BTreeSet::new();
11473    let mut queue: std::collections::VecDeque<&str> = std::collections::VecDeque::new();
11474    visited.insert(start);
11475    queue.push_back(start);
11476    while let Some(node) = queue.pop_front() {
11477        if node == target {
11478            let mut path = vec![node.to_string()];
11479            let mut cur = node;
11480            while let Some(&p) = parent.get(cur) {
11481                path.push(p.to_string());
11482                cur = p;
11483            }
11484            path.reverse();
11485            return Some(path);
11486        }
11487        for &next in adj.get(node).into_iter().flatten() {
11488            if visited.insert(next) {
11489                parent.insert(next, node);
11490                queue.push_back(next);
11491            }
11492        }
11493    }
11494    None
11495}
11496
11497impl<'a, F: Fs> MutPreview<'a, F> {
11498    fn new(db: &'a GraphDb<F>) -> Self {
11499        Self {
11500            db,
11501            overlay: Overlay::default(),
11502        }
11503    }
11504
11505    fn has_key(&self, key: &str) -> bool {
11506        if self.overlay.extra_keys.contains(key) {
11507            return true;
11508        }
11509        if self.overlay.deleted_keys.contains(key) {
11510            return false;
11511        }
11512        self.db.ids.get(key).is_some()
11513    }
11514
11515    fn has_prop(&self, key: &str, field: &str) -> bool {
11516        if !self.has_key(key) {
11517            return false;
11518        }
11519        let k = (key.to_string(), field.to_string());
11520        if self.overlay.removed_props.contains(&k) {
11521            return false;
11522        }
11523        if self.overlay.extra_props.contains_key(&k) {
11524            return true;
11525        }
11526        // Fresh identity (first insert in this batch, or delete+reinsert):
11527        // ignore props still sitting on the soon-to-be-tombstoned slot.
11528        if self.overlay.extra_keys.contains(key) {
11529            return false;
11530        }
11531        self.db.get_prop(key, field).is_some()
11532    }
11533
11534    fn has_edge(&self, edge_type: &str, src_key: &str, dst_key: &str) -> bool {
11535        let k = (
11536            edge_type.to_string(),
11537            src_key.to_string(),
11538            dst_key.to_string(),
11539        );
11540        if self.overlay.deleted_edges.contains(&k) {
11541            return false;
11542        }
11543        if self.overlay.extra_edges.contains(&k) {
11544            return true;
11545        }
11546        // A key created in this batch (including reinsert) has no db edges.
11547        if self.overlay.extra_keys.contains(src_key) || self.overlay.extra_keys.contains(dst_key) {
11548            return false;
11549        }
11550        if self.overlay.deleted_keys.contains(src_key)
11551            || self.overlay.deleted_keys.contains(dst_key)
11552        {
11553            return false;
11554        }
11555        let Some(src) = self.db.ids.get(src_key) else {
11556            return false;
11557        };
11558        let Some(dst) = self.db.ids.get(dst_key) else {
11559            return false;
11560        };
11561        let Some(sym) = self.db.syms.get(edge_type) else {
11562            return false;
11563        };
11564        self.db
11565            .topo_view()
11566            .neighbors(sym, Direction::Out, src)
11567            .binary_search(&dst)
11568            .is_ok()
11569    }
11570
11571    fn has_rule(&self, name: &str) -> bool {
11572        if self.overlay.extra_rules.contains(name) {
11573            return true;
11574        }
11575        if self.overlay.deleted_rules.contains(name) {
11576            return false;
11577        }
11578        self.db.engine.rules().any(|r| r.name == name)
11579    }
11580
11581    fn is_rule_owned(&self, edge_type: &str, src_key: &str, dst_key: &str) -> bool {
11582        if self.overlay.extra_keys.contains(src_key) || self.overlay.extra_keys.contains(dst_key) {
11583            return false;
11584        }
11585        if self.overlay.deleted_keys.contains(src_key)
11586            || self.overlay.deleted_keys.contains(dst_key)
11587        {
11588            return false;
11589        }
11590        let Some(src) = self.db.ids.get(src_key) else {
11591            return false;
11592        };
11593        let Some(dst) = self.db.ids.get(dst_key) else {
11594            return false;
11595        };
11596        let Some(et) = self.db.syms.get(edge_type) else {
11597            return false;
11598        };
11599        // extra_rules is deliberately not consulted: a CreateRule earlier in
11600        // this batch has not fired, so it contributes no provenance. That is
11601        // the documented rule-window gap (see GraphDb::batch).
11602        if self.overlay.deleted_rules.is_empty() {
11603            return self.db.engine.is_owned(et, src, dst);
11604        }
11605        for (rule, triples) in self.db.engine.provenance() {
11606            if self.overlay.deleted_rules.contains(rule) {
11607                continue;
11608            }
11609            if triples.contains(&(et, src, dst)) {
11610                return true;
11611            }
11612        }
11613        false
11614    }
11615
11616    fn check_insert_node(&self, key: &str) -> Result<()> {
11617        if self.has_key(key) {
11618            Err(GraphError::DuplicateKey { key: key.into() })
11619        } else {
11620            Ok(())
11621        }
11622    }
11623
11624    fn check_live_key(&self, key: &str) -> Result<()> {
11625        if self.has_key(key) {
11626            Ok(())
11627        } else {
11628            Err(GraphError::KeyNotFound { key: key.into() })
11629        }
11630    }
11631
11632    fn prepare_insert_edge(&self, edge_type: &str, src_key: &str, dst_key: &str) -> Result<bool> {
11633        for k in [src_key, dst_key] {
11634            if !self.has_key(k) {
11635                return Err(GraphError::KeyNotFound { key: k.into() });
11636            }
11637        }
11638        if self.is_rule_owned(edge_type, src_key, dst_key) {
11639            return Err(GraphError::RuleOwned {
11640                detail: format!("edge {edge_type} {src_key}→{dst_key} is rule-owned"),
11641            });
11642        }
11643        // A user-written edge stays inside one namespace. Derived edges do not
11644        // come through here — the engine adds them directly — and the rule
11645        // scoping check is what keeps those pure.
11646        let src_ns = self.namespace_in_batch(src_key);
11647        let dst_ns = self.namespace_in_batch(dst_key);
11648        if src_ns != dst_ns {
11649            return Err(GraphError::CrossNamespace {
11650                src: src_key.to_string(),
11651                src_ns,
11652                dst: dst_key.to_string(),
11653                dst_ns,
11654            });
11655        }
11656        Ok(!self.has_edge(edge_type, src_key, dst_key))
11657    }
11658
11659    fn prepare_remove_prop(&self, key: &str, field: &str) -> Result<bool> {
11660        self.check_live_key(key)?;
11661        // Removing `ns` is changing the namespace — to `default`, the namespace
11662        // an absent property names. It goes through this one choke-point and NOT
11663        // through `rewrite_wal_dense` (a `RemoveProp` needs no dense rewrite), so
11664        // the immutability rule has to be stated here as well. Without it the
11665        // node silently lands in `default` on the next open: the cross-namespace
11666        // edge guard is defeated and a default-bound role reads a tenant's node.
11667        if field == NS_PROP {
11668            let from = self.namespace_in_batch(key);
11669            if from != NS_DEFAULT {
11670                return Err(GraphError::NamespaceImmutable {
11671                    key: key.to_string(),
11672                    from,
11673                    to: NS_DEFAULT.to_string(),
11674                });
11675            }
11676            // Already in `default`: the removal changes no namespace. It is the
11677            // no-op `set_prop` to the current namespace is, not an error.
11678            return Ok(false);
11679        }
11680        Ok(self.has_prop(key, field))
11681    }
11682
11683    fn prepare_delete_edge(&self, edge_type: &str, src_key: &str, dst_key: &str) -> Result<bool> {
11684        for k in [src_key, dst_key] {
11685            if !self.has_key(k) {
11686                return Err(GraphError::KeyNotFound { key: k.into() });
11687            }
11688        }
11689        // Provenance-owned OR a live rule would derive this pair. User-first
11690        // edges that a later rule matches are not in `owned`, but deleting
11691        // them would leave a hole `rebuild_rule` immediately fills.
11692        if self.is_rule_owned(edge_type, src_key, dst_key) {
11693            return Err(GraphError::RuleOwned {
11694                detail: format!(
11695                    "edge {edge_type} {src_key}→{dst_key} is rule-owned; \
11696                     delete or change the owning rule"
11697                ),
11698            });
11699        }
11700        if self.would_derive(edge_type, src_key, dst_key) {
11701            return Err(GraphError::RuleOwned {
11702                detail: format!(
11703                    "edge {edge_type} {src_key}→{dst_key} is rule-owned; \
11704                     delete or change the owning rule, or a live rule would re-derive it"
11705                ),
11706            });
11707        }
11708        Ok(self.has_edge(edge_type, src_key, dst_key))
11709    }
11710
11711    /// True if any live rule (minus overlay-deleted names) would derive
11712    /// `(edge_type, src, dst)` from current overlay-visible props/labels.
11713    /// CreateRule names in `extra_rules` are ignored — same documented
11714    /// same-batch rule-window as [`Self::is_rule_owned`].
11715    fn would_derive(&self, edge_type: &str, src_key: &str, dst_key: &str) -> bool {
11716        if src_key == dst_key {
11717            return false;
11718        }
11719        let Some(src_label) = self.label_of(src_key) else {
11720            return false;
11721        };
11722        let Some(dst_label) = self.label_of(dst_key) else {
11723            return false;
11724        };
11725        for rule in self.db.engine.rules() {
11726            if self.overlay.deleted_rules.contains(&rule.name) {
11727                continue;
11728            }
11729            if rule.edge_type != edge_type {
11730                continue;
11731            }
11732            if rule.src_label != src_label || rule.dst_label != dst_label {
11733                continue;
11734            }
11735            let src_props = |f: &str| self.prop_value(src_key, f);
11736            let dst_props = |f: &str| self.prop_value(dst_key, f);
11737            let src_view = NodeView {
11738                key: src_key,
11739                props: &src_props,
11740            };
11741            let dst_view = NodeView {
11742                key: dst_key,
11743                props: &dst_props,
11744            };
11745            if evaluate(&rule.predicate, &src_view, &dst_view).is_some() {
11746                return true;
11747            }
11748        }
11749        false
11750    }
11751
11752    fn label_of(&self, key: &str) -> Option<String> {
11753        if self.overlay.deleted_keys.contains(key) {
11754            return None;
11755        }
11756        // Fresh identities created in this batch have no stored label in the
11757        // overlay; they cannot be provenance-owned yet either.
11758        let id = self.db.ids.get(key)?;
11759        let sym = self.db.labels.get(id as usize).copied()?;
11760        if sym == u32::MAX {
11761            return None;
11762        }
11763        self.db.syms.resolve(sym).map(str::to_string)
11764    }
11765
11766    /// The namespace `key` is in as this batch sees it — including a node
11767    /// inserted earlier in the same batch, which the store does not have yet.
11768    fn namespace_in_batch(&self, key: &str) -> String {
11769        namespace_of_value(self.prop_value(key, NS_PROP).as_ref()).to_string()
11770    }
11771
11772    fn prop_value(&self, key: &str, field: &str) -> Option<Value> {
11773        if !self.has_key(key) {
11774            return None;
11775        }
11776        let k = (key.to_string(), field.to_string());
11777        if self.overlay.removed_props.contains(&k) {
11778            return None;
11779        }
11780        if let Some(v) = self.overlay.extra_props.get(&k) {
11781            return Some(v.clone());
11782        }
11783        if self.overlay.extra_keys.contains(key) {
11784            return None;
11785        }
11786        self.db.get_prop(key, field)
11787    }
11788
11789    fn check_create_rule(&self, def: &RuleDef) -> Result<()> {
11790        def.validate()
11791            .map_err(|e| GraphError::RuleInvalid { detail: e })?;
11792        if self.has_rule(&def.name) {
11793            return Err(GraphError::RuleInvalid {
11794                detail: format!("rule {:?} already exists", def.name),
11795            });
11796        }
11797        // Rule-chain cycle rejection. Derived edges feed via-hop rules, so a
11798        // rule set forms a graph whose arcs are "hops over `via_edge`, writes
11799        // `edge_type`". A cycle in that graph is a rule set that would re-fire
11800        // itself forever; the engine's depth cap would silently truncate it
11801        // instead, leaving an arbitrary partial result. Reject it here, the one
11802        // place that sees the whole rule set.
11803        //
11804        // Rules accepted earlier in the same batch count too: the overlay
11805        // carries their arcs, so a cycle cannot be assembled one op at a time.
11806        if let Some(via) = def.via_edge.as_deref() {
11807            if via == def.edge_type {
11808                return Err(GraphError::RuleInvalid {
11809                    detail: format!("rule chain cycle: {} -> {}", via, def.edge_type),
11810                });
11811            }
11812            let mut arcs: Vec<(String, String)> = self
11813                .db
11814                .engine
11815                .rules()
11816                .filter(|r| !self.overlay.deleted_rules.contains(&r.name))
11817                .filter_map(|r| r.via_edge.clone().map(|v| (v, r.edge_type.clone())))
11818                .collect();
11819            arcs.extend(self.overlay.extra_rule_arcs.values().cloned());
11820            arcs.push((via.to_string(), def.edge_type.clone()));
11821            if let Some(path) = find_cycle_through(&arcs, &def.edge_type, via) {
11822                return Err(GraphError::RuleInvalid {
11823                    detail: format!("rule chain cycle: {} -> {}", via, path.join(" -> ")),
11824                });
11825            }
11826        }
11827        Ok(())
11828    }
11829
11830    fn check_delete_rule(&self, name: &str) -> Result<()> {
11831        if self.has_rule(name) {
11832            Ok(())
11833        } else {
11834            Err(GraphError::RuleNotFound { name: name.into() })
11835        }
11836    }
11837
11838    fn note_insert_node(&mut self, key: &str, props: &[(String, Value)]) {
11839        self.overlay.deleted_keys.remove(key);
11840        self.overlay.extra_keys.insert(key.to_string());
11841        self.overlay.extra_props.retain(|(k, _), _| k != key);
11842        self.overlay.removed_props.retain(|(k, _)| k != key);
11843        for (field, value) in props {
11844            self.overlay
11845                .extra_props
11846                .insert((key.to_string(), field.clone()), value.clone());
11847        }
11848    }
11849
11850    fn note_insert_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) {
11851        let k = (
11852            edge_type.to_string(),
11853            src_key.to_string(),
11854            dst_key.to_string(),
11855        );
11856        self.overlay.deleted_edges.remove(&k);
11857        self.overlay.extra_edges.insert(k);
11858    }
11859
11860    fn note_set_prop(&mut self, key: &str, field: &str, value: &Value) {
11861        let k = (key.to_string(), field.to_string());
11862        self.overlay.removed_props.remove(&k);
11863        self.overlay.extra_props.insert(k, value.clone());
11864    }
11865
11866    fn note_remove_prop(&mut self, key: &str, field: &str) {
11867        let k = (key.to_string(), field.to_string());
11868        self.overlay.extra_props.remove(&k);
11869        self.overlay.removed_props.insert(k);
11870    }
11871
11872    fn note_delete_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) {
11873        let k = (
11874            edge_type.to_string(),
11875            src_key.to_string(),
11876            dst_key.to_string(),
11877        );
11878        self.overlay.extra_edges.remove(&k);
11879        self.overlay.deleted_edges.insert(k);
11880    }
11881
11882    fn note_delete_node(&mut self, key: &str) {
11883        self.overlay.extra_keys.remove(key);
11884        self.overlay.deleted_keys.insert(key.to_string());
11885        self.overlay.extra_props.retain(|(k, _), _| k != key);
11886        self.overlay.removed_props.retain(|(k, _)| k != key);
11887        self.overlay
11888            .extra_edges
11889            .retain(|(_, s, d)| s != key && d != key);
11890        self.overlay
11891            .deleted_edges
11892            .retain(|(_, s, d)| s != key && d != key);
11893    }
11894
11895    fn note_create_rule(&mut self, def: &RuleDef) {
11896        self.overlay.deleted_rules.remove(&def.name);
11897        self.overlay.extra_rules.insert(def.name.clone());
11898        // Rules accepted earlier in this batch are not in the engine yet, so
11899        // the cycle check would not see their arcs. Keep the arc, not just the
11900        // name, so a batch cannot smuggle in a cycle one op at a time.
11901        if let Some(via) = def.via_edge.clone() {
11902            self.overlay
11903                .extra_rule_arcs
11904                .insert(def.name.clone(), (via, def.edge_type.clone()));
11905        }
11906    }
11907
11908    fn check_rename_node(&self, old: &str, new: &str) -> Result<()> {
11909        if !self.has_key(old) {
11910            return Err(GraphError::KeyNotFound { key: old.into() });
11911        }
11912        if self.has_key(new) {
11913            return Err(GraphError::DuplicateKey { key: new.into() });
11914        }
11915        Ok(())
11916    }
11917
11918    fn note_rename_node(&mut self, old: &str, new: &str) {
11919        // Mark old as deleted so subsequent batch ops cannot reference it.
11920        self.overlay.extra_keys.remove(old);
11921        self.overlay.deleted_keys.insert(old.to_string());
11922        // Mark new as extra so subsequent batch ops can reference it.
11923        self.overlay.deleted_keys.remove(new);
11924        self.overlay.extra_keys.insert(new.to_string());
11925        // Migrate any overlay props from old key to new key.
11926        let new_str = new.to_string();
11927        let transferred: Vec<((String, String), Value)> = self
11928            .overlay
11929            .extra_props
11930            .iter()
11931            .filter(|((k, _), _)| k.as_str() == old)
11932            .map(|((_, f), v)| ((new_str.clone(), f.clone()), v.clone()))
11933            .collect();
11934        self.overlay
11935            .extra_props
11936            .retain(|(k, _), _| k.as_str() != old);
11937        for (k, v) in transferred {
11938            self.overlay.extra_props.insert(k, v);
11939        }
11940        // Migrate removed_props.
11941        let transferred_removed: Vec<(String, String)> = self
11942            .overlay
11943            .removed_props
11944            .iter()
11945            .filter(|(k, _)| k.as_str() == old)
11946            .map(|(_, f)| (new_str.clone(), f.clone()))
11947            .collect();
11948        self.overlay
11949            .removed_props
11950            .retain(|(k, _)| k.as_str() != old);
11951        for k in transferred_removed {
11952            self.overlay.removed_props.insert(k);
11953        }
11954    }
11955
11956    fn note_delete_rule(&mut self, name: &str) {
11957        self.overlay.extra_rules.remove(name);
11958        // Drop its chain arc too: a rule created and then deleted in the same
11959        // batch must not make a later, legal rule look like a cycle.
11960        self.overlay.extra_rule_arcs.remove(name);
11961        self.overlay.deleted_rules.insert(name.to_string());
11962        // Treat the deleted rule's current provenance as gone so a later
11963        // delete_edge of those triples is a no-op (matches sequential).
11964        if let Some(triples) = self.db.engine.provenance().get(name) {
11965            for &(et, s, d) in triples {
11966                let Some(etype) = self.db.syms.resolve(et) else {
11967                    continue;
11968                };
11969                let Some(src) = self.db.ids.key_of(s) else {
11970                    continue;
11971                };
11972                let Some(dst) = self.db.ids.key_of(d) else {
11973                    continue;
11974                };
11975                let k = (etype.to_string(), src.to_string(), dst.to_string());
11976                self.overlay.extra_edges.remove(&k);
11977                self.overlay.deleted_edges.insert(k);
11978            }
11979        }
11980    }
11981}
11982
11983/// Collects mutations and commits them as one WAL `Batch` frame.
11984///
11985/// Holds `&mut GraphDb` for its lifetime. Queue with the same method names
11986/// as [`GraphDb`]; call [`commit`](Self::commit) to validate, log, and apply.
11987/// See [`GraphDb::batch`] for validation and atomicity rules.
11988pub struct BatchBuilder<'a, F: Fs> {
11989    db: &'a mut GraphDb<F>,
11990    ops: Vec<BatchOp>,
11991}
11992
11993impl<'a, F: Fs> BatchBuilder<'a, F> {
11994    pub fn insert_node(
11995        &mut self,
11996        label: &str,
11997        key: &str,
11998        props: Vec<(String, Value)>,
11999    ) -> &mut Self {
12000        self.ops.push(BatchOp::InsertNode {
12001            label: label.into(),
12002            key: key.into(),
12003            props,
12004        });
12005        self
12006    }
12007
12008    pub fn insert_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) -> &mut Self {
12009        self.ops.push(BatchOp::InsertEdge {
12010            edge_type: edge_type.into(),
12011            src_key: src_key.into(),
12012            dst_key: dst_key.into(),
12013        });
12014        self
12015    }
12016
12017    pub fn set_prop(&mut self, key: &str, field: &str, value: Value) -> &mut Self {
12018        self.ops.push(BatchOp::SetProp {
12019            key: key.into(),
12020            field: field.into(),
12021            value,
12022        });
12023        self
12024    }
12025
12026    pub fn remove_prop(&mut self, key: &str, field: &str) -> &mut Self {
12027        self.ops.push(BatchOp::RemoveProp {
12028            key: key.into(),
12029            field: field.into(),
12030        });
12031        self
12032    }
12033
12034    pub fn delete_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) -> &mut Self {
12035        self.ops.push(BatchOp::DeleteEdge {
12036            edge_type: edge_type.into(),
12037            src_key: src_key.into(),
12038            dst_key: dst_key.into(),
12039        });
12040        self
12041    }
12042
12043    pub fn delete_node(&mut self, key: &str) -> &mut Self {
12044        self.ops.push(BatchOp::DeleteNode { key: key.into() });
12045        self
12046    }
12047
12048    pub fn create_rule(&mut self, def: RuleDef) -> &mut Self {
12049        self.ops.push(BatchOp::CreateRule(def));
12050        self
12051    }
12052
12053    pub fn delete_rule(&mut self, name: &str) -> &mut Self {
12054        self.ops.push(BatchOp::DeleteRule { name: name.into() });
12055        self
12056    }
12057
12058    /// Queue a node-rename in this batch.
12059    ///
12060    /// Validation (old exists, new not taken) runs at commit time.
12061    pub fn rename_node(&mut self, old_key: &str, new_key: &str) -> &mut Self {
12062        self.ops.push(BatchOp::RenameNode {
12063            old_key: old_key.into(),
12064            new_key: new_key.into(),
12065        });
12066        self
12067    }
12068
12069    /// Queue an edge insert with endpoint auto-creation.
12070    ///
12071    /// Any missing endpoint is created as a plain node `{key, label:
12072    /// placeholder_label, no props}` inside this batch frame. Rules fire and
12073    /// last-change is updated for each auto-created node.
12074    pub fn insert_edge_upsert(
12075        &mut self,
12076        edge_type: &str,
12077        src_key: &str,
12078        dst_key: &str,
12079        placeholder_label: &str,
12080    ) -> &mut Self {
12081        self.ops.push(BatchOp::InsertEdgeUpsert {
12082            edge_type: edge_type.into(),
12083            src_key: src_key.into(),
12084            dst_key: dst_key.into(),
12085            placeholder_label: placeholder_label.into(),
12086        });
12087        self
12088    }
12089
12090    /// Validate every queued op, then log one `Batch` frame and apply.
12091    /// Empty / all-noop batches return `Ok(())` without writing the WAL.
12092    /// A second `commit()` after a successful one is an empty-batch no-op
12093    /// (queued ops were taken).
12094    /// Takes `&mut self` so it chains after the queue methods (`b.insert_node(..).commit()`)
12095    /// and also works as `let mut b = db.batch(); b.insert_node(..); b.commit()`.
12096    ///
12097    /// **Rule-window limitation:** batch validation cannot see edges that a
12098    /// rule created earlier in the *same* batch will derive at apply time, so
12099    /// a `delete_edge` / `insert_edge` in that window is silently no-oped
12100    /// where sequential calls would return `Err(RuleOwned)`. State integrity
12101    /// is unaffected (idempotent apply, provenance intact). Create rules in
12102    /// their own batch, or sequentially, when later ops may touch derived
12103    /// edges.
12104    /// Validate every queued op and commit atomically.
12105    ///
12106    /// Returns `(nodes_inserted, edges_inserted)` — the counts of node and edge
12107    /// WAL records actually written (duplicate edges are silent no-ops and are
12108    /// NOT counted). Both are 0 when the batch is empty or all-noop.
12109    pub fn commit(&mut self) -> Result<(usize, usize)> {
12110        let ops = std::mem::take(&mut self.ops);
12111        self.db.commit_batch(ops)
12112    }
12113
12114    /// Same as [`commit`](Self::commit) but tail the inner events with
12115    /// [`MutationEvent::Ingested`] instead of [`MutationEvent::BatchApplied`].
12116    pub(crate) fn commit_ingest(&mut self, label: &str, inserted: usize) -> Result<(usize, usize)> {
12117        let ops = std::mem::take(&mut self.ops);
12118        self.db
12119            .commit_logged_batch(ops, Some((label.to_string(), inserted)), None)
12120    }
12121}
12122
12123pub struct NodeRef<'a, F: Fs> {
12124    db: &'a GraphDb<F>,
12125    id: u32,
12126}
12127
12128impl<'a, F: Fs> NodeRef<'a, F> {
12129    pub fn key(&self) -> &str {
12130        self.db.ids.key_of(self.id).expect("dense ids")
12131    }
12132
12133    pub fn label(&self) -> &str {
12134        let sym = self
12135            .db
12136            .labels
12137            .get(self.id as usize)
12138            .copied()
12139            .filter(|&s| s != u32::MAX)
12140            .expect("real nodes always have a label; u32::MAX sentinel cannot occur");
12141        self.db.syms.resolve(sym).expect("interned label symbol")
12142    }
12143
12144    pub fn prop(&self, field: &str) -> Option<Value> {
12145        self.db
12146            .props_view()
12147            .get(self.id, field)
12148            .map(|vr| vr.into_value())
12149    }
12150
12151    /// All stored fields for this node, sorted by field name.
12152    ///
12153    /// Reads from the full base+overlay view so that props stored only in the
12154    /// V8 snapshot base (i.e. before any post-snapshot WAL writes) are visible.
12155    pub fn props(&self) -> BTreeMap<String, Value> {
12156        let mut out = BTreeMap::new();
12157        let pv = self.db.props_view();
12158        for field in pv.field_names() {
12159            if let Some(vr) = pv.get(self.id, &field) {
12160                out.insert(field, vr.into_value());
12161            }
12162        }
12163        out
12164    }
12165
12166    /// depth-N BFS as a ResultSet: columns ["key","label","depth"], BFS order.
12167    pub fn neighborhood(&self, depth: u32, edge_types: Option<&[&str]>, dir: Dir) -> ResultSet {
12168        let view = self.db.view();
12169        let resolved: Option<Vec<u32>> = edge_types.map(|names| {
12170            names
12171                .iter()
12172                .filter_map(|name| view.syms.get(name))
12173                .collect()
12174        });
12175        let nb = neighborhood(&view, self.id, depth, resolved.as_deref(), dir);
12176        let mut rs = ResultSet::new(vec!["key".into(), "label".into(), "depth".into()]);
12177        for (nid, d) in nb.nodes {
12178            let key = view.key_of(nid);
12179            let label = view
12180                .label_of(nid)
12181                .expect("real nodes always have a label; u32::MAX sentinel cannot occur");
12182            rs.push_row(vec![
12183                Some(Value::Str(key.to_string())),
12184                Some(Value::Str(label.to_string())),
12185                Some(Value::Int(d as i64)),
12186            ]);
12187        }
12188        rs
12189    }
12190
12191    /// 1-hop, Both directions: edge-type name → sorted unique neighbor keys.
12192    pub fn grouped_by_edge_type(&self) -> BTreeMap<String, Vec<String>> {
12193        let view = self.db.view();
12194        let mut groups: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
12195        for e in expand(&view, self.id, None, Dir::Both) {
12196            // Skip edges with unknown etypes (only possible from corrupt large
12197            // TOPOLOGY section; function returns BTreeMap not Result).
12198            let Some(etype) = view.syms.resolve(e.etype) else {
12199                continue;
12200            };
12201            let etype = etype.to_string();
12202            let nbr = if e.src == self.id { e.dst } else { e.src };
12203            groups
12204                .entry(etype)
12205                .or_default()
12206                .insert(view.key_of(nbr).to_string());
12207        }
12208        groups
12209            .into_iter()
12210            .map(|(k, v)| (k, v.into_iter().collect()))
12211            .collect()
12212    }
12213}
12214
12215#[cfg(test)]
12216mod tests {
12217    use super::*;
12218    use core_rules::Predicate;
12219
12220    fn tmp_dir(name: &str) -> std::path::PathBuf {
12221        let d =
12222            std::env::temp_dir().join(format!("graphdb-db-unit-{}-{}", name, std::process::id()));
12223        let _ = std::fs::remove_dir_all(&d);
12224        d
12225    }
12226
12227    fn fk_rule() -> RuleDef {
12228        RuleDef {
12229            name: "works_at".into(),
12230            src_label: "Person".into(),
12231            dst_label: "Org".into(),
12232            predicate: Predicate::KeyMatch {
12233                field: "org_id".into(),
12234            },
12235            edge_type: "WORKS_AT".into(),
12236            weight_prop: None,
12237            max_edges: None,
12238            approximate: false,
12239            via_label: None,
12240            via_edge: None,
12241            via_dir: None,
12242            namespace: None,
12243        }
12244    }
12245
12246    /// Regression guard for the no-views delta-copy fast path.
12247    ///
12248    /// When no views are defined, `pending_deltas_since().to_vec()` must never
12249    /// be called — even during a large CreateRule backfill. The DELTA_COPY_COUNT
12250    /// thread-local is incremented inside every `if !view_store.is_empty()` block;
12251    /// a count of 0 after the entire sequence proves the guard fires correctly.
12252    #[test]
12253    fn no_delta_copy_when_no_views() {
12254        DELTA_COPY_COUNT.with(|c| c.set(0));
12255        let dir = tmp_dir("no-delta-copy");
12256        {
12257            let mut db = GraphDb::open(&dir).unwrap();
12258            // Insert 50 Org + 50 Person nodes with FK links.
12259            for i in 0..50u32 {
12260                db.insert_node("Org", &format!("o{i}"), vec![]).unwrap();
12261            }
12262            for i in 0..50u32 {
12263                db.insert_node(
12264                    "Person",
12265                    &format!("p{i}"),
12266                    vec![("org_id".into(), Value::Str(format!("o{i}")))],
12267                )
12268                .unwrap();
12269            }
12270            // CreateRule backfill should NOT invoke to_vec() when no views are defined.
12271            db.create_rule(fk_rule()).unwrap();
12272
12273            // Counter must stay 0 — no views, no copies.
12274            let copies = DELTA_COPY_COUNT.with(|c| c.get());
12275            assert_eq!(
12276                copies, 0,
12277                "pending_deltas_since().to_vec() called despite no views"
12278            );
12279
12280            // Derived edges must still be correct (the guard skips only the
12281            // empty delta propagation loop, not the rule application itself).
12282            let nbrs = db.neighbors("p0", "WORKS_AT", Direction::Out).unwrap();
12283            assert_eq!(
12284                nbrs,
12285                vec!["o0"],
12286                "rule must derive edges even with no views"
12287            );
12288        }
12289        let _ = std::fs::remove_dir_all(&dir);
12290    }
12291
12292    /// Gating regression: subscribe AFTER a backfill must see no stale events.
12293    /// subscribe BEFORE a backfill must see every edge-fire event.
12294    #[test]
12295    fn subscribe_after_backfill_no_stale_events() {
12296        let dir = tmp_dir("sub-after-backfill");
12297        {
12298            let mut db = GraphDb::open(&dir).unwrap();
12299            for i in 0..10u32 {
12300                db.insert_node("Org", &format!("o{i}"), vec![]).unwrap();
12301                db.insert_node(
12302                    "Person",
12303                    &format!("p{i}"),
12304                    vec![("org_id".into(), Value::Str(format!("o{i}")))],
12305                )
12306                .unwrap();
12307            }
12308            // Create rule BEFORE subscribing — emit_deltas is false during backfill.
12309            db.create_rule(fk_rule()).unwrap();
12310
12311            // Subscribe AFTER the backfill — queue must be empty (no stale events).
12312            let sub = db.subscribe_all_rules().unwrap();
12313            // No events should have queued for the prior backfill.
12314            assert!(
12315                sub.try_recv().is_none(),
12316                "subscribe after backfill must see no stale events"
12317            );
12318
12319            // Inserting a new node now should fire an event (emit_deltas is now true).
12320            db.insert_node("Org", "o_new", vec![]).unwrap();
12321            db.insert_node(
12322                "Person",
12323                "p_new",
12324                vec![("org_id".into(), Value::Str("o_new".into()))],
12325            )
12326            .unwrap();
12327            let ev = sub.recv_timeout(std::time::Duration::from_millis(200));
12328            assert!(
12329                ev.is_some(),
12330                "edge-fire event must arrive after subscribe (emit_deltas=true)"
12331            );
12332        }
12333        let _ = std::fs::remove_dir_all(&dir);
12334    }
12335
12336    /// Gating regression: subscribe BEFORE a backfill → events flow.
12337    #[test]
12338    fn subscribe_before_backfill_events_flow() {
12339        let dir = tmp_dir("sub-before-backfill");
12340        {
12341            let mut db = GraphDb::open(&dir).unwrap();
12342            // Subscribe FIRST — emit_deltas becomes true.
12343            let sub = db.subscribe_all_rules().unwrap();
12344
12345            for i in 0..5u32 {
12346                db.insert_node("Org", &format!("o{i}"), vec![]).unwrap();
12347                db.insert_node(
12348                    "Person",
12349                    &format!("p{i}"),
12350                    vec![("org_id".into(), Value::Str(format!("o{i}")))],
12351                )
12352                .unwrap();
12353            }
12354            // Backfill fires with emit_deltas=true → events queued.
12355            db.create_rule(fk_rule()).unwrap();
12356
12357            // Should receive at least one edge-fired event from the backfill.
12358            let mut received = 0usize;
12359            while sub.try_recv().is_some() {
12360                received += 1;
12361            }
12362            assert!(
12363                received > 0,
12364                "subscribe before backfill must receive edge-fire events (got 0)"
12365            );
12366        }
12367        let _ = std::fs::remove_dir_all(&dir);
12368    }
12369
12370    /// Companion: when a view IS defined, the delta path fires and view values update.
12371    #[test]
12372    fn delta_copy_fires_when_view_exists() {
12373        use core_rules::ViewSource;
12374        DELTA_COPY_COUNT.with(|c| c.set(0));
12375        let dir = tmp_dir("delta-copy-with-view");
12376        {
12377            let mut db = GraphDb::open(&dir).unwrap();
12378            db.insert_node("Org", "o1", vec![]).unwrap();
12379            db.insert_node(
12380                "Person",
12381                "p1",
12382                vec![("org_id".into(), Value::Str("o1".into()))],
12383            )
12384            .unwrap();
12385            // Declare a Degree view so is_empty() returns false.
12386            db.create_view(ViewDef {
12387                name: "degree_out".into(),
12388                label: "Person".into(),
12389                view_prop: "degree_out".into(),
12390                source: ViewSource::Degree {
12391                    edge_type: "WORKS_AT".into(),
12392                    direction: Direction::Out,
12393                },
12394            })
12395            .unwrap();
12396            db.create_rule(fk_rule()).unwrap();
12397
12398            // At least one delta copy should have happened (CreateRule backfill).
12399            let copies = DELTA_COPY_COUNT.with(|c| c.get());
12400            assert!(
12401                copies > 0,
12402                "expected delta copy to fire when a view is defined"
12403            );
12404
12405            // View value should be computed: p1 has one WORKS_AT out-edge.
12406            let info = db.node_info("p1").unwrap();
12407            let degree = info.props.get("degree_out");
12408            assert!(
12409                degree.is_some(),
12410                "view prop should be written to node props"
12411            );
12412        }
12413        let _ = std::fs::remove_dir_all(&dir);
12414    }
12415
12416    /// Regression: `open_at_with` must call `rebuild_all` after WAL replay so
12417    /// derived-edge-driven view values reflect the as-of state rather than just
12418    /// the initial backfill written at `CreateView` time.
12419    ///
12420    /// Base WAL frames (indices 0..=5 before history markers):
12421    ///   0: insert Org "o1"
12422    ///   1: create_view "employee_count" (Degree / WORKS_AT / In) on Org
12423    ///   2: create_rule fk_rule (WORKS_AT, Person→Org via org_id)
12424    ///   3: insert Person "p1" → rule fires WORKS_AT p1→o1 (degree = 1)  ← mid
12425    ///   4: insert Person "p2" → rule fires WORKS_AT p2→o1 (degree = 2)
12426    ///   5: insert Person "p3" → rule fires WORKS_AT p3→o1 (degree = 3)  ← latest
12427    ///
12428    /// Each rule-fire also appends a DerivedEdgeAdded history-marker frame (state
12429    /// no-op), so the total commit count is higher than the base frame count.
12430    /// The "latest" open_at commit is computed dynamically via `wal_commit_count_at`.
12431    ///
12432    /// Without `rebuild_all`, the as-of instance's "emp" view stays at the
12433    /// initial backfill value (0) instead of reflecting the replayed derived edges.
12434    #[test]
12435    fn open_at_derived_edge_view_values_correct() {
12436        use core_rules::ViewSource;
12437        let dir = tmp_dir("open-at-view-rebuild");
12438        {
12439            let mut db = GraphDb::open(&dir).unwrap();
12440            // frame 0
12441            db.insert_node("Org", "o1", vec![]).unwrap();
12442            // frame 1: create view — initial backfill sees 0 derived edges (none fired yet)
12443            db.create_view(ViewDef {
12444                name: "employee_count".into(),
12445                label: "Org".into(),
12446                view_prop: "emp".into(),
12447                source: ViewSource::Degree {
12448                    edge_type: "WORKS_AT".into(),
12449                    direction: Direction::In,
12450                },
12451            })
12452            .unwrap();
12453            // frame 2: create rule — no Persons yet; backfill is a no-op
12454            db.create_rule(fk_rule()).unwrap();
12455            // frame 3: p1 — rule fires WORKS_AT p1→o1; degree = 1
12456            db.insert_node(
12457                "Person",
12458                "p1",
12459                vec![("org_id".into(), Value::Str("o1".into()))],
12460            )
12461            .unwrap();
12462            // frame 4: p2 — degree = 2
12463            db.insert_node(
12464                "Person",
12465                "p2",
12466                vec![("org_id".into(), Value::Str("o1".into()))],
12467            )
12468            .unwrap();
12469            // frame 5: p3 — degree = 3
12470            db.insert_node(
12471                "Person",
12472                "p3",
12473                vec![("org_id".into(), Value::Str("o1".into()))],
12474            )
12475            .unwrap();
12476            // Sanity: normal open sees degree = 3.
12477            assert_eq!(
12478                db.get_view_prop("o1", "emp"),
12479                Some(Value::Int(3)),
12480                "normal db must show degree 3 after 3 derived edges"
12481            );
12482        } // WAL flushed
12483
12484        // Re-open normally to get the authoritative reference value.
12485        let normal_db = GraphDb::open(&dir).unwrap();
12486        let normal_emp = normal_db.get_view_prop("o1", "emp");
12487        assert_eq!(
12488            normal_emp,
12489            Some(Value::Int(3)),
12490            "re-opened normal db must show degree 3"
12491        );
12492
12493        // Latest as-of (last WAL commit): must match the normal open.
12494        // History-marker frames are appended after each rule-fire, so the total
12495        // commit count is computed dynamically rather than hardcoded.
12496        let total = crate::wal_commit_count_at(&dir).unwrap();
12497        let aof_latest = GraphDb::open_at(&dir, total - 1).unwrap();
12498        assert_eq!(
12499            aof_latest.get_view_prop("o1", "emp"),
12500            normal_emp,
12501            "open_at latest: derived-edge view must equal normal open (rebuild_all required)"
12502        );
12503
12504        // Mid-history as-of (commit 3 = p1 insert Batch frame): only p1; degree = 1.
12505        // The DerivedEdgeAdded marker for p1 is at frame 4 (state no-op on replay),
12506        // so replaying 0..=3 correctly re-derives only the p1→o1 edge.
12507        let aof_mid = GraphDb::open_at(&dir, 3).unwrap();
12508        assert_eq!(
12509            aof_mid.get_view_prop("o1", "emp"),
12510            Some(Value::Int(1)),
12511            "open_at mid-history: only p1 exists at frame 3, degree must be 1"
12512        );
12513
12514        let _ = std::fs::remove_dir_all(&dir);
12515    }
12516
12517    /// Pin: subscribe_* on an as-of instance must return Err(ReadOnly) —
12518    /// as-of instances never commit, so distribute_events never runs and any
12519    /// subscription would wait forever.
12520    #[test]
12521    fn subscribe_on_as_of_returns_read_only_error() {
12522        let dir = tmp_dir("sub-as-of-read-only");
12523        {
12524            let mut db = GraphDb::open(&dir).unwrap();
12525            db.insert_node("Org", "o1", vec![]).unwrap();
12526            db.create_rule(fk_rule()).unwrap();
12527        }
12528        let mut aof = GraphDb::open_at(&dir, 0).unwrap();
12529
12530        assert!(
12531            matches!(
12532                aof.subscribe_all_rules(),
12533                Err(core_storage::GraphError::ReadOnly)
12534            ),
12535            "subscribe_all_rules on as-of must return ReadOnly"
12536        );
12537        assert!(
12538            matches!(
12539                aof.subscribe_writes(),
12540                Err(core_storage::GraphError::ReadOnly)
12541            ),
12542            "subscribe_writes on as-of must return ReadOnly"
12543        );
12544        assert!(
12545            matches!(
12546                aof.subscribe_rule("works_at"),
12547                Err(core_storage::GraphError::ReadOnly)
12548            ),
12549            "subscribe_rule on as-of must return ReadOnly"
12550        );
12551        let _ = std::fs::remove_dir_all(&dir);
12552    }
12553
12554    /// Regression: a failed dense WAL rewrite must not leave speculative
12555    /// interns in `syms`. If it does, the next successful mutation logs an
12556    /// `Intern` record with an inflated id; replay (which never saw the
12557    /// orphans) assigns a smaller id and the WAL becomes unreplayable.
12558    #[test]
12559    fn dense_rewrite_error_rolls_back_speculative_interns() {
12560        let dir = tmp_dir("dense-rewrite-rollback");
12561        {
12562            let mut db = GraphDb::open(&dir).unwrap();
12563            db.insert_node("Person", "a", vec![]).unwrap();
12564
12565            // Bypass MutPreview validation to hit the rewrite's own error path
12566            // (same shape as an id-exhaustion failure mid-rewrite). The
12567            // InsertEdge arm interns the edge type before it resolves keys.
12568            let err = db.rewrite_wal_dense(vec![WalRecord::InsertEdge {
12569                edge_type: "ORPHAN_TYPE".into(),
12570                src_key: "missing".into(),
12571                dst_key: "a".into(),
12572            }]);
12573            assert!(err.is_err(), "rewrite of a missing src key must fail");
12574            assert_eq!(
12575                db.syms.get("ORPHAN_TYPE"),
12576                None,
12577                "failed rewrite must roll back speculative interns"
12578            );
12579
12580            // A later successful mutation must produce a replayable WAL.
12581            db.set_prop("a", "later_field", Value::Int(2)).unwrap();
12582        }
12583        let db = GraphDb::open(&dir).expect("WAL must replay after failed rewrite");
12584        assert_eq!(db.get_prop("a", "later_field"), Some(Value::Int(2)));
12585        let _ = std::fs::remove_dir_all(&dir);
12586    }
12587}